From 88c4c5dd137ee5a9c560f7c44cb83777df5720ee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:54:22 -0700 Subject: [PATCH 01/55] Finish structured agent readiness before delivering held work --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + ...-structured-session-adapter-resume.test.ts | 2 +- .../claude/claude-structured-real-cli.test.ts | 2 +- .../claude-structured-resume-point.test.ts | 2 +- .../claude-structured-session-adapter.test.ts | 6 +- .../codex-structured-session-adapter.test.ts | 4 +- ...gent-model-catalog-saved-only-read.test.ts | 45 ++++ .../agent-model-catalog-service.ts | 4 +- ...-agent-session-accept-then-deliver.test.ts | 48 ++-- .../structured-agent-session-acquisition.ts | 2 +- .../structured-agent-session-adapter.ts | 6 +- ...ured-agent-session-append-delivery.test.ts | 4 +- .../structured-agent-session-attach-flow.ts | 22 +- ...ured-agent-session-attach-orchestration.ts | 60 +++-- ...-agent-session-claude-option-queue.test.ts | 74 ++++-- ...ured-agent-session-command-readers.test.ts | 2 +- ...d-agent-session-conversation-close.test.ts | 5 +- ...red-agent-session-conversation-lifetime.ts | 51 ++-- .../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-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.ts | 6 +- ...gent-session-instant-start.test-support.ts | 35 +++ ...ured-agent-session-late-settlement.test.ts | 51 ++-- ...gent-session-legacy-handoff-record.test.ts | 23 +- ...ssion-main-agent-working-agreement.test.ts | 55 ++-- ...structured-agent-session-mutation-plans.ts | 2 +- ...ed-agent-session-option-settlement.test.ts | 17 +- ...uctured-agent-session-options-read.test.ts | 87 ++++++- .../structured-agent-session-options-read.ts | 4 +- ...gent-session-provider-child-record.test.ts | 27 +- ...-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 | 44 ++-- ...ructured-agent-session-provider-started.ts | 190 +++++++++++--- ...tured-agent-session-queued-compact.test.ts | 3 +- ...ueued-message-rig-provider.test-fixture.ts | 10 +- ...session-queued-message-rig.test-fixture.ts | 2 +- ...ured-agent-session-queued-messages.test.ts | 3 +- ...ctured-agent-session-refusal-retry.test.ts | 57 +++-- ...uctured-agent-session-repeat-press.test.ts | 47 ++-- .../structured-agent-session-rest-test-rig.ts | 12 +- ...gent-session-restart-context-clear.test.ts | 4 +- ...tured-agent-session-restart-resume.test.ts | 90 +++++-- ...session-restart-status-publication.test.ts | 6 +- ...tured-agent-session-rewind-at-rest.test.ts | 67 ++--- ...red-agent-session-rewind-live-send.test.ts | 67 ++--- ...t-session-rewind-stop-note.test-fixture.ts | 2 + .../structured-agent-session-rewind.test.ts | 145 ++++++----- .../structured-agent-session-rewind.ts | 34 +-- ...red-agent-session-send-preparation.test.ts | 26 +- ...ructured-agent-session-send-preparation.ts | 38 ++- ...session-send-restarts-failed-start.test.ts | 4 +- ...agent-session-settled-attach-retry.test.ts | 23 +- ...agent-session-start-failure-writer.test.ts | 4 +- .../structured-agent-session-start-listing.ts | 58 +++++ ...agent-session-startup-attempt-host.test.ts | 32 +-- ...tructured-agent-session-startup-attempt.ts | 14 +- ...structured-agent-session-startup-intent.ts | 137 ++++++++++ ...ctured-agent-session-startup-picks.test.ts | 237 ++++++++++++++++++ ...red-agent-session-stop-never-waits.test.ts | 11 +- ...red-agent-session-surface-lifetime.test.ts | 23 +- .../structured-agent-session-thread-goal.ts | 31 ++- .../structured-agent-session-turns.ts | 5 +- ...ured-agent-session-unrun-send-hold.test.ts | 3 - ...tured-agent-session-working-at-teardown.ts | 5 +- .../structured-conversation-command.test.ts | 15 ++ .../structured-conversation-command.ts | 12 + .../structured-conversation-compaction.ts | 13 + .../structured-rewind-refusal.ts | 19 ++ ...-session-acquisition-failure-settlement.ts | 3 +- .../agent-session-lease-transitions.ts | 23 +- .../agent-session-record-store.test.ts | 5 +- .../runtime/agent-session-record-store.ts | 4 +- ...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 +- .../structured-agent-session-runtime.ts | 13 +- ...tured-agent-session-transport-overrides.ts | 15 ++ .../structured-claude-pending-rewind.test.ts | 3 +- .../NativeChatStructuredSession.tsx | 3 +- ...se-native-chat-provisional-launch.test.tsx | 34 ++- .../use-native-chat-provisional-launch.ts | 29 ++- ...e-structured-agent-session-option-state.ts | 14 +- ...session-options.start-keeps-picks.test.tsx | 148 +++++++++++ .../use-structured-agent-session.ts | 2 +- src/shared/agent-session-record.ts | 6 +- ...ss-version-agent-session-wire.unit.test.ts | 237 ++++++++++++------ .../structured-agent-session-host-fixture.ts | 41 +++ ...-chat-owner-status-activation.unit.test.ts | 52 ++-- 101 files changed, 2565 insertions(+), 778 deletions(-) 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 create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-start-listing.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/runtime/rpc/methods/orchestration-structured-worker-start-deadline.test.ts create mode 100644 src/main/runtime/structured-agent-session-transport-overrides.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.start-keeps-picks.test.tsx diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index cd058eb9c5b4..7d12b6cf1d8f 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -167,6 +167,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ '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', 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..05165c362123 100644 --- a/src/main/acp/acp-structured-session-adapter-resume.test.ts +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -334,7 +334,7 @@ describe('reattaching a Grok chat the journal holds', () => { handle: { nativeId: PROVIDER_SESSION }, replaces: { key: 'acp-key-saved-1', reason: 'restore-failed', replacedAt: 5_000 } }) - expect(acquired.link.supersedesKey).toBeUndefined() + expect(acquired.link?.supersedesKey).toBeUndefined() expect(rig.sent('session/new')).toHaveLength(1) const rows = await notRestoredRows(rig) expect(rows).toHaveLength(1) 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-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index a24a869ec123..7dd8c4c1c64b 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -442,7 +442,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 +489,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') ) diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 2fcd0a41f6be..98b55cbdc94a 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -216,8 +216,8 @@ describe('CodexStructuredSessionAdapter.acquire', () => { path: '/rollouts/thread-proven.jsonl' }) }) - expect(acquisition.link.origin).toBe('resumed') - expect(acquisition.link.handle).toEqual(codexProviderHandle('thread-proven')) + expect(acquisition.link?.origin).toBe('resumed') + expect(acquisition.link?.handle).toEqual(codexProviderHandle('thread-proven')) }) it('starts a thread in place of a creation Codex never saved, and says which it replaced', async () => { 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-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 29a033a4e7b9..71ea4eb72968 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,8 +67,10 @@ let acquire: Mock let dispatch: Mock let adapterExtras: Partial let idleMs: number +let clock: number const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, acquisitionGeneration: `generation-${acquire.mock.calls.length}`, link: { @@ -81,12 +84,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 +103,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 +116,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 +609,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 +641,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 +739,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 +805,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 +815,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 +851,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.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index db83f5c2e7de..09efedf111b6 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 @@ -127,7 +127,7 @@ export async function acquireOwner( const proved = await input.store.proveOwner({ sessionId: record.sessionId, fence, - link: acquired.link, + ...(acquired.link ? { link: acquired.link } : {}), now: input.now(), ...(options ? { options } : {}) }) 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 958c71d80fcc..c7530996d129 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 @@ -106,7 +106,7 @@ export { * the process the host can probe, and the provider handle it was minted with. */ export type AgentSessionAcquisition = { process: AgentSessionProcessIdentity - link: AgentSessionProviderHandleLink + link?: AgentSessionProviderHandleLink /** Host-local identity for this exact provider child, distinct even when the durable fence is * reused by a superseding acquisition. */ acquisitionGeneration?: string @@ -210,6 +210,8 @@ export type StructuredAgentSessionEndedEvent = { /** The child a publish-first acquire handed over has now proven its start: startup facts applied. * What it reports from here on is fact, not a catalog guess. */ export type StructuredAgentSessionStartedEvent = { + /** The handle supplied when the provider finishes starting. */ + link?: AgentSessionProviderHandleLink type: 'started' sessionId: string fence: number @@ -243,7 +245,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 = 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..c5ce6a79984c 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 @@ -118,6 +118,7 @@ beforeEach(async () => { resetHostTestOperationIds() generation = 0 acquire = vi.fn(async ({ fence, spawnToken }) => ({ + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -126,8 +127,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 c3271ff3d477..bf9cb09be6a5 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 @@ -51,8 +51,11 @@ import type { StructuredAgentSessionStartupAttempt } from './structured-agent-se import type { StructuredAgentSessionStartupProgress } from './structured-agent-session-startup-attempt' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' + import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' +export type AttachedOwner = 'acquired' | 'kept' + export type AttachFlowInput = { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter @@ -71,17 +74,16 @@ export type AttachFlowInput = { onStartupAttempt?: ( attempt: StructuredAgentSessionStartupAttempt ) => StructuredAgentSessionStartupProgress - /** A `ready` child's start listing, handed over once its owner is proven. */ + /** A completed acquisition's start listing. */ onStartCatalogListing?: (listing: AgentModelCatalogLiveListing) => void /** 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, kept a live one, or left the chat at rest. */ onAttached: ( attached: AttachedJournal, acquisitionGeneration: string | null, - acquiredOwner: boolean, + owner: AttachedOwner, providerChildPhase: StructuredAgentSessionProviderChildPhase ) => Promise | void /** Host-owned provider sink, bound to the journal inside `onAttached`. */ @@ -131,7 +133,7 @@ export async function performAttach( let record: AgentSessionRecord let acquisitionGeneration: string | null = null - let acquiredOwner = false + let owner: AttachedOwner = 'kept' let providerChildPhase: StructuredAgentSessionProviderChildPhase = 'ready' let reservedRecord: AgentSessionRecord | null = null let unsupportedReservationSettlementAttempted = false @@ -196,14 +198,16 @@ 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 { 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 = { @@ -264,7 +268,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, providerChildPhase) 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 5ababd586e6a..bcbd787231b8 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 { @@ -207,9 +211,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') } @@ -218,15 +220,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, providerChildPhase) => { 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, @@ -246,19 +249,24 @@ async function runAttachUnderAbort( 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 }) + // An acquire reports its phase; a re-attach keeps what its child already proved. + phase: owner === 'acquired' ? providerChildPhase : (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 { @@ -274,7 +282,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) } @@ -292,6 +299,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-claude-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts index 88e495b5dbdf..6e047ae5773c 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,23 @@ 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 { + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4200, processStartTimeMs: NOW, spawnToken }, + acquisitionGeneration: 'generation-1', link: { linkId: `claude-native-${fence}`, handle: claudeProviderHandle(CLAUDE_SESSION, 'native-leaf'), @@ -66,11 +77,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 +93,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 +125,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-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-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..a8f493d9d551 --- /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, providerChildPhase: 'starting' as const } + }, + 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, providerChildPhase: 'starting' as const } + } + return { ...adapter, acquire } + }, + prove, + clear: () => heldGenerations.clear() + } +} 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 c2c655a406e9..9d61a02a1dce 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' @@ -73,23 +74,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) + ) ) } @@ -176,23 +179,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. @@ -215,12 +222,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..149e9474fd88 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. */ + /** A starting acquisition stays `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.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 8f9a97671a07..f05f0d6dd7b2 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), @@ -256,8 +257,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..c228180de5c0 --- /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, providerChildPhase: 'starting' as const } + } + 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..209260f1406c 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,33 @@ 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 }) => ({ + providerChildPhase: 'starting' as const, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: '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..e7a814ec0481 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}`, @@ -125,6 +129,7 @@ beforeEach(async () => { stopOwnerProcess = vi.fn() dispatch = vi.fn(async () => ({ state: 'admitted' as const })) acquire = vi.fn(async ({ fence, spawnToken }) => ({ + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, link: { linkId: `link-${fence}`, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-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..9b65150cc704 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,37 @@ 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 { + providerChildPhase: 'starting' as const, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: input.spawnToken + }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${input.fence}`, + handle: codexProviderHandle(THREAD), + origin: 'created' as const, + mintedAtFence: input.fence, + observedAt: NOW + } } - } + }, + dispatch, + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) }, - 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 04688a8c491a..305aa6766e1c 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 @@ -189,7 +189,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-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 67085dfbcf22..8eba7f5bb605 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,25 @@ 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 { + providerChildPhase: 'starting' as const, + 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.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 df029f7bd159..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' } @@ -165,7 +166,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..6811a9575227 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'), { @@ -66,6 +67,7 @@ function generation(): string { } const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, acquisitionGeneration: generation(), link: { @@ -79,27 +81,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 +117,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 +532,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..883b63dfaf7d 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,8 +28,9 @@ 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 }) => ({ + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -38,8 +39,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 +80,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 553015985e69..8e615e476431 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`). +// A starting acquisition hands the host an unproven child, 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,16 +24,29 @@ 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 { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' +import { + saveStructuredAgentSessionStartListing, + saveStructuredAgentSessionStartReadbackListing +} from './structured-agent-session-start-listing' 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 @@ -37,7 +55,8 @@ export type StructuredAgentSessionProviderStartedContext = { now: () => number publishStatus?: (sessionId: string) => void runtimeState: { - startupAttempts: Pick + startupAttempts: Pick + acquireAborts: Pick optionRevisions: Pick< StructuredAgentSessionOptionRevisions, 'admitReport' | 'advance' | 'isNewest' @@ -46,6 +65,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 @@ -60,15 +81,39 @@ 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) const delivered = context.wakeDelivery(event.sessionId) noteStructuredAgentSessionProviderStarted(context.deps, event.sessionId) if (event.catalogListing) { - context.deps.modelCatalog?.recordLiveListing(event.sessionId, event.catalogListing) + saveStructuredAgentSessionStartListing(context, event.sessionId, event.catalogListing) } context.publishStatus?.(event.sessionId) return { delivered } @@ -80,6 +125,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( @@ -100,35 +232,11 @@ export function settleStructuredAgentSessionOptionsReported( ): Promise { const admitted = admitReportedOptions(context, event) if (event.catalogListing) { - void saveStartReadbackListing(context, event, event.catalogListing) + void saveStructuredAgentSessionStartReadbackListing(context, event, event.catalogListing) } return persistReportedOptions(context, event, admitted) } -/** A start's late readback (Claude's settings) says once what the config resolved. Serialized - * behind the attach that published the child, which may still be indexing it. */ -function saveStartReadbackListing( - context: StructuredAgentSessionProviderStartedContext, - event: ReportedOptions, - listing: AgentModelCatalogLiveListing -): Promise { - return context - .serialize(event.sessionId, async () => { - const child = context.sessions.get(event.sessionId)?.child - const reporter = { generation: event.acquisitionGeneration, fence: event.fence } - if (child && sameProviderChild(child, reporter)) { - context.deps.modelCatalog?.recordLiveListing(event.sessionId, listing) - } - }) - .catch((error: unknown) => { - context.deps.logger.warn('saving what a started provider resolved failed', { - scope: 'provider-started-catalog', - sessionId: event.sessionId, - error - }) - }) -} - function admitReportedOptions( context: StructuredAgentSessionProviderStartedContext, event: ReportedOptions 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..10a720c8ce52 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 @@ -2,6 +2,8 @@ // the events it writes back as the provider would. import { vi, type Mock } from 'vitest' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' 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' @@ -29,7 +31,8 @@ export type QueuedRigProviderOptions = { 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 +56,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 @@ -72,6 +75,7 @@ export function createQueuedRigProvider( ? `${THREAD}-context-${record!.providerContextBoundary!.operationId}` : (context?.head?.handle.nativeId ?? THREAD) return { + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, @@ -104,6 +108,8 @@ export function createQueuedRigProvider( ...(options.recoverRewind ? { recoverRewind: options.recoverRewind } : {}) } + 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-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 01639485bd25..744f933a10f2 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,36 @@ 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 }) => ({ + providerChildPhase: 'starting' as const, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW - 1_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: async () => ({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => undefined, + // A failed acquisition is proven gone, as the real adapters prove it. + releaseAcquisition: async () => true, + setOption + }, + () => host + ) const host = new StructuredAgentSessionHost({ agents: 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..426e04c63936 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,31 @@ 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 }) => ({ + providerChildPhase: 'starting' as const, + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: 'generation-1', + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: fence > 1 ? ('resumed' as const) : ('created' as const), + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: vi.fn(async () => ({ state: 'admitted' as const })), + closeSession: vi.fn(async () => true), + releaseAcquisition: vi.fn(async () => true), + cancelTurn, + answerPrompt: vi.fn(async () => undefined), + setOption, + changeThreadGoal, + stopBackgroundTasks + }, + () => host + ) host = new StructuredAgentSessionHost({ agents: claudeAndCodexAgents(adapter), logger: createStructuredAgentSessionLogger(), 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.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..c0e8ee04a552 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' } @@ -39,6 +40,7 @@ let root = '' function adapter(): StructuredAgentSessionAdapter { return { acquire: async ({ fence, spawnToken }) => ({ + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -59,11 +61,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-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index 6776a2f9e898..b36a7bcb7854 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,43 @@ 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 { + providerChildPhase: 'starting' as const, + 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..88474c453736 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,43 @@ 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 { + providerChildPhase: 'starting' as const, + 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..f83e5ef34848 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,46 @@ 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 { + providerChildPhase: 'starting' as const, + 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 +125,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 +238,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 +255,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 +412,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 +426,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 +444,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 +726,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 +754,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 +784,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 +816,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 c1edc4766772..a97271a580a9 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' } @@ -49,6 +50,7 @@ beforeEach(async () => { hostErrors = [] let generation = 0 spawnChild = async ({ fence, spawnToken }) => ({ + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, acquisitionGeneration: `generation-${++generation}`, link: { @@ -79,15 +81,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 +693,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..48af8306b102 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 @@ -142,6 +142,7 @@ beforeEach(async () => { resetHostTestOperationIds() generation = 0 acquire = vi.fn(async ({ fence, spawnToken }) => ({ + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -151,8 +152,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..4377cf7b4840 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 { @@ -93,6 +97,7 @@ beforeEach(async () => { publishFault.publishCount = 0 resetHostTestOperationIds() acquire = vi.fn(async ({ fence }) => ({ + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, @@ -182,6 +187,7 @@ describe('settled attach retry', () => { throw new Error('reply lost') } return { + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, @@ -227,6 +233,7 @@ describe('settled attach retry', () => { throw new Error('reply lost') } return { + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, 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..3bfa2112f3f8 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 @@ -120,6 +120,7 @@ beforeEach(async () => { adapter: { acquire: vi.fn(async ({ fence, spawnToken }) => { const child = { + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, @@ -133,8 +134,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-start-listing.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-listing.ts new file mode 100644 index 000000000000..4b1029308383 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-listing.ts @@ -0,0 +1,58 @@ +// What a start listed reaches the account's model catalog once, with the configured default its +// start resolved: on `started`, or on Claude's settings readback that follows it. Bookkeeping: a +// failure is logged, never the start's. + +import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import { sameProviderChild } from './structured-agent-session-provider-child' + +export type StructuredAgentSessionStartListingContext = { + deps: Pick + sessions: Map + serialize: (sessionId: string, task: () => Promise) => Promise +} + +/** A proven start's listing, saved inside its serialized step. */ +export function saveStructuredAgentSessionStartListing( + context: Pick, + sessionId: string, + listing: AgentModelCatalogLiveListing +): void { + try { + context.deps.modelCatalog?.recordLiveListing(sessionId, listing) + } catch (error) { + context.deps.logger.warn('saving what a started provider listed failed', { + scope: 'provider-started-catalog', + sessionId, + error + }) + } +} + +/** A start's late readback (Claude's settings) says once what the config resolved. Serialized + * behind the attach that published the child, which may still be indexing it. */ +export function saveStructuredAgentSessionStartReadbackListing( + context: StructuredAgentSessionStartListingContext, + reporter: { sessionId: string; acquisitionGeneration: string; fence: number }, + listing: AgentModelCatalogLiveListing +): Promise { + const { sessionId } = reporter + return context + .serialize(sessionId, async () => { + const child = context.sessions.get(sessionId)?.child + const identity = { generation: reporter.acquisitionGeneration, fence: reporter.fence } + if (child && sameProviderChild(child, identity)) { + context.deps.modelCatalog?.recordLiveListing(sessionId, listing) + } + }) + .catch((error: unknown) => { + context.deps.logger.warn('saving what a started provider resolved failed', { + scope: 'provider-started-catalog', + sessionId, + error + }) + }) +} 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..c453ac96d861 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,12 +46,12 @@ let acquire: Mock let dispatch: Mock let closeSession: Mock> let setOption: Mock -let readAcquisitionOptions: StructuredAgentSessionAdapter['readAcquisitionOptions'] let startupLimits: Partial | undefined const providerStarted = vi.fn() function acquisition(input: StructuredAgentSessionAcquireInput): AgentSessionAcquisition { return { + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, @@ -69,11 +69,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 +84,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 +128,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 +366,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 +399,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.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts index efecd7f77376..4ed090ecd51b 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,7 +155,7 @@ 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, @@ -181,6 +181,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..1b3b2e1a5beb --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts @@ -0,0 +1,237 @@ +// 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 { + providerChildPhase: 'starting' as const, + 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-stop-never-waits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts index 4cbc39073c17..6ee43bd38de3 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() 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..3dd914f647fa 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( @@ -220,6 +224,7 @@ beforeEach(async () => { acquire = vi.fn(async ({ fence, spawnToken, events }) => { sink = events ?? null return { + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, acquisitionGeneration: `generation-${++generation}`, link: { 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.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index cb3d87e72e95..004a16a7cb8b 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 @@ -152,7 +152,7 @@ export async function performSend( if (existing) { return { ok: true, - value: { clientMessageId: input.clientMessageId, submission: existing } + value: { clientMessageId: input.clientMessageId, submission: { ...existing } } } } try { @@ -285,5 +285,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..fe724185de34 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 @@ -164,7 +164,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: { fence: number; phase?: 'starting' | 'ready' } | null } /** The offer one session is owed, taken right before teardown stops its provider child; null when @@ -190,7 +190,8 @@ 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' || input.startAnswered?.(sessionId) === false 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..fca0340c41ca 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,22 @@ 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 { + providerChildPhase: 'starting' as const, + 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.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts index 519a22bd3b5b..bae65871c531 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/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-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 5601d2a2e00c..008b9af2bec2 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -207,7 +207,7 @@ export class AgentSessionRecordStore { async proveOwner(args: { sessionId: string fence: number - link: AgentSessionProviderHandleLink + link?: AgentSessionProviderHandleLink now: number leaseTtlMs?: number options?: Readonly> @@ -216,7 +216,7 @@ export class AgentSessionRecordStore { const proved = proveAgentSessionOwner({ record, fence: args.fence, - link: args.link, + ...(args.link ? { link: args.link } : {}), now: args.now, leaseTtlMs: args.leaseTtlMs ?? AGENT_SESSION_LEASE_TTL_MS }) 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-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 64e44ab74c58..a07c5379f3ba 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[] /** Required, and asserted at install time — an absent policy must not degrade to a guess. */ @@ -318,6 +310,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..6bfd1e1d3271 --- /dev/null +++ b/src/main/runtime/structured-agent-session-transport-overrides.ts @@ -0,0 +1,15 @@ +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' + +/** Provider transports are overridden only to drive the runtime against scripted children. */ +export type StructuredAgentSessionTransportOverrides = { + openPiConnection?: PiRpcSessionDeps['openConnection'] + openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] + 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 5aafd5711ad9..27359034f7e5 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -125,7 +125,8 @@ beforeEach(async () => { }), 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 b3efd9fa4c65..d9bd8126b54f 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -87,7 +87,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-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( 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 f0ba077a3c64..2cec7099359c 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 @@ -115,10 +116,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() + }) +} 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 608bf683791a..32cdc137b695 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 @@ -53,7 +53,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..df6cc3b580d5 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,81 @@ 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 { + providerChildPhase: 'starting' as const, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + // 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 +818,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 +840,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 +919,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 +934,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/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index dc297fcac679..a24e11d902f3 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' @@ -165,3 +169,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..bc02eefb2cba 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,34 @@ 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 }) => ({ + providerChildPhase: 'starting' as const, + 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 +108,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 1232065947be02cd2294a74813b216a4a6386800 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:55:19 -0700 Subject: [PATCH 02/55] Publish the Codex process before protocol startup --- .../scripts/vitest-sqlite-runtime-files.mjs | 2 + .../codex-connection-app-server.ts | 30 ++ .../codex/codex-app-server-connection.test.ts | 31 +- src/main/codex/codex-app-server-connection.ts | 34 ++- src/main/codex/codex-app-server-handshake.ts | 7 +- ...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 +- .../codex-scripted-child.test-fixture.ts | 177 +++++++++++ ...dex-structured-child-work-producer.test.ts | 9 +- .../codex-structured-dispatch-test-support.ts | 3 +- .../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-structured-service-tier.test.ts | 16 +- .../codex/codex-structured-session-acquire.ts | 119 +++----- ...odex-structured-session-adapter-fixture.ts | 71 ++++- ...ructured-session-adapter-lifecycle.test.ts | 27 +- .../codex-structured-session-adapter.test.ts | 137 +++++---- .../codex/codex-structured-session-adapter.ts | 34 +-- ...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 | 42 +++ .../codex-structured-session-prompts.test.ts | 15 +- .../codex-structured-session-shutdown.test.ts | 7 +- .../codex/codex-structured-session-start.ts | 190 ++++++++++++ .../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 | 13 +- .../structured-agent-session-attach-flow.ts | 6 +- ...ured-agent-session-attach-orchestration.ts | 21 +- ...-agent-session-codex-stop-note-end.test.ts | 6 +- ...tured-agent-session-codex-stop-row.test.ts | 6 +- ...ured-agent-session-crash-mid-start.test.ts | 90 +++++- ...native-chat-inline-visuals-setting.test.ts | 4 +- .../structured-agent-runtime-registrations.ts | 7 +- ...tured-agent-scripted-child.test-fixture.ts | 33 ++ ...nt-session-codex-opening-send-hold.test.ts | 1 + ...agent-session-codex-scripted-child.test.ts | 129 ++++++++ ...agent-session-codex-start-deadline.test.ts | 117 +++++++ ...ructured-agent-session-integration.test.ts | 10 +- 49 files changed, 1680 insertions(+), 321 deletions(-) create mode 100644 src/main/codex/__fixtures__/codex-connection-app-server.ts create mode 100644 src/main/codex/codex-app-server-spawn-publication.test.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/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-codex-start-deadline.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 7d12b6cf1d8f..7d831feb6bc1 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -239,6 +239,8 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts', 'src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts', 'src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts', + 'src/main/runtime/structured-agent-session-codex-scripted-child.test.ts', + 'src/main/runtime/structured-agent-session-codex-start-deadline.test.ts', 'src/main/runtime/structured-agent-session-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/__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/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index 3f90182acd41..3ad7f9a32636 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -1,3 +1,4 @@ +import { FAKE_APP_SERVER } from './__fixtures__/codex-connection-app-server' import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' import type { EventEmitter } from 'node:events' import { realpathSync } from 'node:fs' @@ -37,36 +38,6 @@ afterEach(() => { * a stub, but it is the only thing that proves the spawn, the environment, and * both traffic directions actually work end to end. */ -const FAKE_APP_SERVER = String.raw` - const readline = require('node:readline') - const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n') - readline.createInterface({ input: process.stdin }).on('line', (line) => { - const message = JSON.parse(line) - if (message.method === 'initialize') return send({ id: message.id, result: {} }) - if (message.method === 'test/env') { - return send({ id: message.id, result: { codexHome: process.env.CODEX_HOME ?? null } }) - } - if (message.method === 'test/cwd') { - return send({ id: message.id, result: { cwd: process.cwd() } }) - } - if (message.method === 'test/notify') { - send({ method: 'turn/started', params: { threadId: 'thread-1', turn: { id: 'turn-7' } } }) - return send({ id: message.id, result: {} }) - } - if (message.method === 'test/ask') { - return send({ id: 99, method: 'item/fileChange/requestApproval', params: { itemId: 'i1' } }) - } - if (message.method === 'test/refuse') { - return send({ id: message.id, error: { code: -32602, message: 'bad params' } }) - } - if (message.method === 'test/missing') { - return send({ id: message.id, error: { code: -32601, message: 'method not found' } }) - } - if (message.id === 99) { - return send({ method: 'test/answered', params: message }) - } - }) -` async function openFakeServer( handlers: CodexAppServerConnectionHandlers = {}, diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 60ce06db743a..fcabeea5f6b1 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -36,15 +36,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: PipedProcessSpawner = spawnProcess + spawnImpl: PipedProcessSpawner, + initialize: boolean ): Promise { const managed = spawnManagedProviderProcess(launch, { spawnImpl, @@ -234,8 +231,10 @@ export async function openCodexAppServerConnection( if (child.pid !== undefined) { await handlers.onSpawned?.(child.pid) } - handshaking = true - await initializeCodexAppServerConnection(connection) + if (initialize) { + handshaking = true + await initializeCodexAppServerConnection(connection) + } } catch (error) { if ((await close()) !== true) { throw new CodexAppServerHandshakeExitUnprovenError(connection, error) @@ -248,3 +247,20 @@ export async function openCodexAppServerConnection( } return connection } + +/** Publishes the transport after its process identity is recorded, before initialization. */ +export function spawnCodexAppServerConnection( + launch: CodexAppServerLaunch, + handlers: CodexAppServerConnectionHandlers = {}, + spawnImpl: PipedProcessSpawner = spawnProcess +): Promise { + return connectCodexAppServer(launch, handlers, spawnImpl, false) +} + +export function openCodexAppServerConnection( + launch: CodexAppServerLaunch, + handlers: CodexAppServerConnectionHandlers = {}, + spawnImpl: PipedProcessSpawner = spawnProcess +): Promise { + return connectCodexAppServer(launch, handlers, spawnImpl, true) +} diff --git a/src/main/codex/codex-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-app-server-spawn-publication.test.ts b/src/main/codex/codex-app-server-spawn-publication.test.ts new file mode 100644 index 000000000000..639692cad2c9 --- /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 { PipedProcessSpawner } from '@orca/process-host/process-spec' +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 PipedProcessSpawner + 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 61c99be2f19b..07407d7c1b70 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-scripted-child.test-fixture.ts b/src/main/codex/codex-scripted-child.test-fixture.ts new file mode 100644 index 000000000000..c8f4fd5d8a21 --- /dev/null +++ b/src/main/codex/codex-scripted-child.test-fixture.ts @@ -0,0 +1,177 @@ +import type { ScriptedAgentChild } from '../runtime/structured-agent-scripted-child.test-fixture' +import type { + CodexAppServerConnection, + openCodexAppServerConnection +} from './codex-app-server-connection' +import { CodexAppServerRequestError } from './codex-app-server-connection' +import { CodexAppServerTimeoutError } from './codex-app-server-session' +import { codexTurnLifecycleFake } from './codex-turn-lifecycle-fake' + +export const CODEX_SCRIPTED_THREAD = 'scripted-codex-thread' +const THREAD = CODEX_SCRIPTED_THREAD + +type ScriptedCodexRoute = (params: Record | undefined) => unknown + +export type ScriptedCodexChild = ScriptedAgentChild & { + /** Every request any spawn received, in order. */ + requests(): readonly { method: string; params?: Record }[] +} + +/** A held answer gives up on the request's own deadline, as the real connection's does (30 s when + * the caller names none). */ +function heldAnswer( + answer: Promise, + method: string, + timeoutMs = 30_000 +): Promise { + let timer: ReturnType | undefined + const timedOut = new Promise((_resolve, reject) => { + timer = setTimeout( + () => + reject( + new CodexAppServerTimeoutError(`codex app-server ${method} exceeded ${timeoutMs}ms`) + ), + timeoutMs + ) + }) + return Promise.race([answer, timedOut]).finally(() => clearTimeout(timer)) +} + +/** `routes` answers methods the script does not, such as the history reads a rewind makes. */ +export const codexScriptedChild = ( + routes: Readonly> = {} +): ScriptedCodexChild => { + let holdHandshakes = true + let completeTurns = true + let spawned = 0 + let resumed = 0 + let closed = 0 + const prompts: string[] = [] + const requests: { method: string; params?: Record }[] = [] + let newest: ReturnType> | undefined + const open: typeof openCodexAppServerConnection = async (_launch, handlers = {}) => { + spawned += 1 + const gate = Promise.withResolvers() + newest = gate + let ended = false + const turns = codexTurnLifecycleFake(THREAD, () => (method, params) => { + if (!ended) { + handlers.onNotification?.(method, params) + } + }) + const connection: CodexAppServerConnection = { + pid: 10_000 + spawned, + get closed() { + return ended + }, + request: async (method, params, options) => { + if (ended) { + throw new Error('scripted Codex child closed') + } + requests.push({ method, ...(params ? { params } : {}) }) + if (method === 'initialize') { + return holdHandshakes ? heldAnswer(gate.promise, method, options?.timeoutMs) : {} + } + const route = routes[method] + if (method === 'thread/resume') { + resumed += 1 + } + if (route) { + return route(params) + } + if (method === 'thread/start' || method === 'thread/resume') { + return { thread: { id: THREAD }, model: 'scripted-model' } + } + if (method === 'model/list') { + return { + data: [ + { model: 'scripted-model', isDefault: true, supportedReasoningEfforts: [] }, + { + model: 'scripted-alt-model', + supportedReasoningEfforts: [{ reasoningEffort: 'high' }] + } + ], + nextCursor: null + } + } + if (method === 'turn/start' || method === 'turn/steer') { + const input = params?.input + if (Array.isArray(input)) { + for (const block of input) { + if ( + typeof block === 'object' && + block !== null && + 'text' in block && + typeof block.text === 'string' + ) { + prompts.push(block.text) + } + } + } + const answer = + method === 'turn/start' + ? await turns.routes['turn/start']() + : turns.routes['turn/steer'](params) + if (method === 'turn/start') { + turns.start() + } + if (typeof params?.clientUserMessageId === 'string') { + turns.echo(params.clientUserMessageId) + } + if (completeTurns) { + turns.end('completed') + } + return answer + } + if (method === 'turn/interrupt') { + return turns.routes['turn/interrupt'](params) + } + return {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => { + if (!ended) { + ended = true + closed += 1 + gate.reject(new Error('scripted Codex child closed')) + handlers.onExit?.(new Error('scripted Codex child closed'), { expected: true }) + } + return true + } + } + // A gate can be closed before initialization starts or after it has already answered. + void gate.promise.catch(() => {}) + await handlers.onSpawned?.(connection.pid ?? 0) + return connection + } + return { + deps: { + openCodexConnection: open, + readProcessStartTime: async () => 1_700_000_000_000, + resolveCodexCommand: () => '/nonexistent/scripted-codex', + resolveEnvironment: async () => ({}) + }, + get holdHandshakes() { + return holdHandshakes + }, + set holdHandshakes(value) { + holdHandshakes = value + }, + get completeTurns() { + return completeTurns + }, + set completeTurns(value) { + completeTurns = value + }, + releaseHandshake: () => newest?.resolve({}), + failHandshake: (message) => + newest?.reject(new CodexAppServerRequestError('initialize', -32603, message, message)), + prompts: () => prompts, + requests: () => requests, + spawns: () => spawned, + resumes: () => resumed, + closes: () => closed + } +} diff --git a/src/main/codex/codex-structured-child-work-producer.test.ts b/src/main/codex/codex-structured-child-work-producer.test.ts index 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-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..30a9170ec060 --- /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', serviceTier: 'priority' }, + 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', serviceTier: 'priority' }, + restoreSkippedOptions: [], + optionRevision: 2 + }) + ) + ) + catalog.resolve({ + data: [{ model: 'gpt-saved', supportedReasoningEfforts: [] }], + nextCursor: null + }) + await vi.waitFor(() => + expect(codex.connections[0].calls.some((call) => call.method === 'model/list')).toBe(true) + ) + // The listing changes no option the start reported, so it reports nothing more. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(events.map((event) => event.type)).toEqual(['started']) + 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-service-tier.test.ts b/src/main/codex/codex-structured-service-tier.test.ts index aa85f16d06c9..df7d8c4aae19 100644 --- a/src/main/codex/codex-structured-service-tier.test.ts +++ b/src/main/codex/codex-structured-service-tier.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi, type Mock } from 'vitest' import { + acquireReadyCodexForTest, + codexStartReport, THREAD_ID, USER_MESSAGE, adapterFor, @@ -31,7 +33,7 @@ function listing(tier = 'priority') { } async function acquire(adapter: CodexStructuredSessionAdapter, sessionId = 'session-1') { - return adapter.acquire({ + return acquireReadyCodexForTest(adapter, { identity: identityFor(sessionId), fence: 7, spawnToken: `spawn-${sessionId}`, @@ -61,7 +63,7 @@ describe('Codex structured service tier without send-path catalog waits', () => const adapter = adapterFor(codex) await acquire(adapter) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 })).toMatchObject({ + expect(codexStartReport(adapter).reportedOptions).toMatchObject({ serviceTier: 'priority' }) await send(adapter, 'first') @@ -190,7 +192,7 @@ describe('Codex structured service tier without send-path catalog waits', () => const codex = fakeCodex() codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-tier') const adapter = adapterFor(codex, { codexHome: '/codex/home', resumeThreadId: THREAD_ID }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-resume', @@ -198,7 +200,7 @@ describe('Codex structured service tier without send-path catalog waits', () => }) expect(codex.connections[0].calls.some((call) => call.method === 'thread/resume')).toBe(true) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 })).toEqual({ + expect(codexStartReport(adapter).reportedOptions).toEqual({ model: 'gpt-next', serviceTier: 'ultrafast' }) @@ -237,7 +239,11 @@ describe('Codex service tier across clients and rest', () => { 'discovery' ) const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog }) - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-1' + }) await expect( adapter.setOption({ sessionId: 'session-1', key: 'fastMode', value: 'true', fence: 7 }) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 3b0d3b2e1afa..b5f5ef16d645 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,28 +12,19 @@ import { closeFailedCodexAcquisition, stopSupersededCodexAcquisition } from './codex-structured-acquisition-lifecycle' -import { CodexBackgroundTaskTracker, codexChildWorkSink } from './codex-background-task-tracker' import { CodexSubagentExecutions } from './codex-subagent-executions' import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' import { createCodexSessionJournalTranslator } from './codex-structured-session-journal' -import { openCodexAppServerConnection } from './codex-app-server-connection' -import { - codexProviderHandleLink, - codexSpawnedProcessIdentity -} from './codex-structured-owner-identity' -import { openCodexThread } from './codex-structured-thread-open' -import { withCodexVisualsThreadConfig } from './codex-structured-visuals' +import { spawnCodexAppServerConnection } from './codex-app-server-connection' +import { codexSpawnedProcessIdentity } from './codex-structured-owner-identity' import { closeCodexPublishedSession, handleCodexSessionExit } from './codex-structured-session-close' -import { restoredCodexSessionOptions } from './codex-structured-session-options' -import { startBackgroundCodexCatalogRefresh } from './codex-structured-background-catalog' -import { codexAcquireCatalogAccess } from './codex-structured-acquire-catalog' -import { reportedCodexThreadOptions } from './codex-structured-service-tier' +import { startCodexStructuredSession } from './codex-structured-session-start' +import { CodexStartingChild } from './codex-structured-starting-child' import { assertCodexConnectionOpen, - codexSessionLifecycle, mintCodexAcquisitionGeneration, type CodexAcquisitionRegistry, type CodexAcquisitionAttempt, @@ -92,7 +82,7 @@ export async function acquireCodexStructuredSession(input: { subagentExecutions, prompts: acquisition.prompts }) - const open = deps.openConnection ?? openCodexAppServerConnection + const open = deps.openConnection ?? spawnCodexAppServerConnection const spawnIdentity = codexSpawnedProcessIdentity(acquireInput, deps.readProcessStartTime) try { await stopSupersededCodexAcquisition({ @@ -155,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, @@ -183,80 +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) - 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 = { + attempt.startingChild = new CodexStartingChild(acquisitionGeneration, { + sessionId, + fence: acquireInput.fence, + onEvent: deps.onEvent, + dispose: () => { + unbindReadingControl?.() + acquisition.prompts.clear() + acquisition.drain() + translator?.dispose() + } + }) + 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 } : {}) - } - 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, providerChildPhase: 'starting' } } 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 98b55cbdc94a..597d5c6f68d2 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 { createCodexStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support' import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture' @@ -29,19 +32,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', serviceTier: 'priority' } }) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 })).toEqual({ + expect(codexStartReport(adapter).reportedOptions).toEqual({ model: 'gpt-saved', effort: 'low', serviceTier: 'priority' }) expect(codex.connections[0].calls.map((call) => call.method)).toEqual([ + 'initialize', 'thread/start', 'model/list' ]) @@ -50,34 +54,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', @@ -92,13 +90,16 @@ describe('CodexStructuredSessionAdapter.acquire', () => { effort: 'low' }) await adapter.closeSession('session-1') - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10', - options: saved + options: { + ...(saved.model ? { model: saved.model } : {}), + ...(saved.effort ? { effort: saved.effort } : {}) + } }) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 8 })).toMatchObject({ + expect(codexStartReport(adapter).reportedOptions).toMatchObject({ model: 'gpt-next', effort: 'low' }) @@ -112,9 +113,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' }) @@ -141,7 +146,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { }) }) - const acquisition = await adapter.acquire({ + const acquisition = await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' @@ -161,7 +166,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' } }) @@ -171,7 +176,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', @@ -187,9 +192,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' } }) @@ -202,13 +211,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', @@ -216,8 +225,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 () => { @@ -235,17 +244,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', @@ -258,21 +267,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) }) @@ -280,7 +307,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) @@ -336,7 +367,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { }) } const adapter = adapterFor(codex, {}, events) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', @@ -628,7 +659,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 f515e426153e..61b696f35d6e 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -17,7 +17,8 @@ import { CodexStructuredSessionTeardown } from './codex-structured-session-teard import { applyCodexStructuredSessionOption, prepareLiveCodexSessionOptions, - readLiveCodexSessionOptions + readLiveCodexSessionOptions, + readCodexAcquisitionOptions } from './codex-structured-session-options' import { CodexAcquisitionRegistry, @@ -177,6 +178,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 && @@ -302,28 +311,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap 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 - } + }) => readCodexAcquisitionOptions(this.session(input.sessionId), input.priorOptions) closeSession = (sessionId: string): Promise => this.teardown.close(sessionId) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(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 be214a25d594..a27a981d29e7 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -1,3 +1,4 @@ +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 '@orca/process-host/close-process-registry' @@ -35,13 +36,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,6 +67,9 @@ export function handleCodexSessionExit(input: { reason: admission.reason }) } + if (session.startingChild) { + session.startingChild.ended = true + } session.ended = true // Nothing can echo for this child any more; the journal's pending-submission // recovery is what settles the sends these were armed for. @@ -93,7 +109,7 @@ 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') } @@ -124,16 +140,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 9898029e14da..693bd1fa9ccc 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-service-tier' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexSession } from './codex-structured-session-state' import { startCodexTurn } from './codex-structured-turn-start' -import { - USER_MESSAGE, - adapterFor, - answerWithOpenedTurn, - fakeCodex, - identityFor -} from './codex-structured-session-adapter-fixture' import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' function optionSession(request: CodexAppServerConnection['request']): CodexSession { @@ -688,7 +689,11 @@ describe('Codex option picks before the model list arrives', () => { const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog: new AgentModelCatalogStore() }) - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-1' + }) await vi.waitFor(() => expect(codex.connections[0].calls.some((call) => call.method === 'model/list')).toBe(true) ) @@ -702,11 +707,6 @@ describe('Codex option picks before the model list arrives', () => { effort: 'low', serviceTier: 'priority' }) - expect(adapter.readAcquisitionOptions({ sessionId: 'session-1', fence: 7 })).toEqual({ - model: 'gpt-next', - effort: 'low', - serviceTier: 'priority' - }) await adapter.dispatch({ sessionId: 'session-1', clientMessageId: 'first', diff --git a/src/main/codex/codex-structured-session-options.ts b/src/main/codex/codex-structured-session-options.ts index 9e876bbe1183..e9118f111ac1 100644 --- a/src/main/codex/codex-structured-session-options.ts +++ b/src/main/codex/codex-structured-session-options.ts @@ -26,6 +26,48 @@ export function restoredCodexSessionOptions( return new Map(Object.entries(options ?? {}).filter(([key]) => isCodexTurnOptionKey(key))) } +export function readCodexAcquisitionOptions( + session: CodexSession, + priorOptions: Readonly> | undefined +): Readonly> | undefined { + const options = { + ...Object.fromEntries( + Object.entries(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 +} + +/** Startup reports the thread's defaults together with the saved next-turn intent. */ +export function reportedCodexSessionOptions( + session: CodexSession +): AgentSessionOptionsResult['current'] { + const model = session.options.get('model') ?? session.reportedOptions.model ?? '' + const effort = + session.options.get('effort') ?? + (model === session.reportedOptions.model ? session.reportedOptions.effort : undefined) + // The saved tier, which the next turn sends as-is. + const serviceTier = session.options.get('serviceTier') + return { + model, + ...(effort ? { effort } : {}), + ...(serviceTier ? { serviceTier } : {}) + } +} + export type { CodexSessionOptionCatalog } from './codex-structured-model-catalog' export { readCodexStructuredSessionOptionCatalog } from './codex-structured-model-catalog' diff --git a/src/main/codex/codex-structured-session-prompts.test.ts b/src/main/codex/codex-structured-session-prompts.test.ts index 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..a4a3085cb928 --- /dev/null +++ b/src/main/codex/codex-structured-session-start.ts @@ -0,0 +1,190 @@ +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 } from './codex-structured-acquire-catalog' +import { reportedCodexThreadOptions } from './codex-structured-service-tier' +import { + assertCodexConnectionOpen, + codexSessionLifecycle, + type CodexAcquisitionRegistry, + type CodexAcquisitionAttempt, + type CodexSession, + type CodexStructuredLaunch, + type CodexStructuredSessionAdapterDeps +} from './codex-structured-session-state' +import type { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' +import { STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' + +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 { + // 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, { + sessionId, + ...(deps.logger ? { logger: deps.logger } : {}) + }) + current() + const optionRevision = input.optionRevision?.() ?? 0 + 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 ?? {}) + 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 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 } : {}) + } + 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 + }) + } 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 70536db781dc..924b0b58f181 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 @@ -84,7 +85,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 } @@ -126,6 +127,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 @@ -204,7 +206,9 @@ export function requireLiveCodexSession( export type CodexAcquisitionAttempt = { window: CodexAcquisitionWindow + startingChild?: CodexStartingChild cancelled: boolean + requestedClose?: boolean exitProven: boolean finished: Promise finish: () => void @@ -290,17 +294,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 9be5ae455b30..8784946ed878 100644 --- a/src/main/codex/codex-structured-visuals.test.ts +++ b/src/main/codex/codex-structured-visuals.test.ts @@ -7,6 +7,7 @@ import { CodexAppServerRequestError } from './codex-app-server-request-error' import { CodexAppServerTimeoutError } from './codex-app-server-session' import { adapterFor, + acquireReadyCodexForTest, fakeCodex, identityFor, THREAD_ID @@ -192,7 +193,11 @@ async function acquired( prepareVisuals: async () => launch.visuals ?? null }) }) - await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) } describe('a Codex chat acquisition with visuals', () => { @@ -203,6 +208,7 @@ describe('a Codex chat acquisition with visuals', () => { const connection = codex.connections[0]! const methods = connection.calls.map((call) => call.method) const open = resumeThreadId ? 'thread/resume' : 'thread/start' + expect(methods[0]).toBe('initialize') expect(methods.indexOf('skills/extraRoots/set')).toBeLessThan(methods.indexOf(open)) expect(methods.indexOf('config/read')).toBeLessThan(methods.indexOf(open)) expect(connection.calls.find((call) => call.method === open)?.params).toMatchObject({ @@ -218,9 +224,10 @@ describe('a Codex chat acquisition with visuals', () => { await acquired(codex, { env: { [NATIVE_CHAT_VISUALS_DIR_ENV]: '/other' } }) const connection = codex.connections[0]! const methods = connection.calls.map((call) => call.method) - expect(methods[0]).toBe('thread/start') + // The adapter sends `initialize` itself now; nothing comes between it and the thread. + expect(methods.slice(0, 2)).toEqual(['initialize', 'thread/start']) expect(methods).not.toContain('skills/extraRoots/set') - 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/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 bf9cb09be6a5..66f1f3907569 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 @@ -54,7 +54,7 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' -export type AttachedOwner = 'acquired' | 'kept' +export type AttachedOwner = 'acquired' | 'kept' | 'none' export type AttachFlowInput = { store: AgentSessionRecordStore @@ -200,6 +200,10 @@ export async function performAttach( }) 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) 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 bcbd787231b8..62bdabffe6b3 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 @@ -244,15 +244,18 @@ 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, - // An acquire reports its phase; a re-attach keeps what its child already proved. - phase: owner === 'acquired' ? providerChildPhase : (current?.phase ?? 'ready'), - ...(startedFor === undefined ? {} : { startedFor }), - ...launchedOptionsOf(owner, attempt.startup, current) + // 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, + // An acquire reports its phase; a re-attach keeps what its child already proved. + phase: owner === 'acquired' ? providerChildPhase : (current?.phase ?? 'ready'), + ...(startedFor === undefined ? {} : { startedFor }), + ...launchedOptionsOf(owner, attempt.startup, current) + } } } // Only a proven child has the protocol session recovery asks; a starting one recovers in 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..af0a8848b13f 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) } } 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..5b4c829392e1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -65,7 +65,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) } } 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/native-chat-inline-visuals-setting.test.ts b/src/main/native-chat/native-chat-inline-visuals-setting.test.ts index e3cdffb56d60..f6ae998b7817 100644 --- a/src/main/native-chat/native-chat-inline-visuals-setting.test.ts +++ b/src/main/native-chat/native-chat-inline-visuals-setting.test.ts @@ -9,6 +9,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { CLAUDE_PLUGIN_DIR_FLAG, type ClaudeCliFlag } from '../claude/claude-cli-flag-support' import { + acquireReadyCodexForTest, adapterFor, fakeCodex, identityFor @@ -101,8 +102,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/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 59afa69a92cd..300d292d180a 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -151,8 +151,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) } } 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-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..33cfed22b7a8 --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts @@ -0,0 +1,129 @@ +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('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-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([]) +}) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 7232960bfa9e..380b4a17ebd7 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -522,10 +522,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') From 699a59a8bbb379a24c89e1bc3af2a4e574eceac9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:55:39 -0700 Subject: [PATCH 03/55] Publish ACP agent processes before session startup --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + src/main/acp/acp-prompt-content.ts | 2 +- .../acp/acp-scripted-child-runtime.test.ts | 88 +++++++ .../acp/acp-scripted-child.test-fixture.ts | 151 +++++++++++ .../acp-scripted-connection.test-fixture.ts | 93 +++++++ src/main/acp/acp-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 | 6 +- src/main/acp/acp-structured-child.ts | 97 +++++++ src/main/acp/acp-structured-handshake.ts | 177 +++++++++++++ ...structured-host-configured-default.test.ts | 4 + .../acp/acp-structured-host-lifecycle.test.ts | 45 ++-- .../acp-structured-host-live-listing.test.ts | 6 +- ...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 | 205 ++++++--------- src/main/acp/acp-structured-session.ts | 2 +- src/main/acp/acp-structured-sign-in.test.ts | 39 ++- src/main/acp/acp-structured-steer.test.ts | 4 +- src/main/acp/acp-turn-failures.ts | 18 +- .../structured-agent-runtime-registrations.ts | 10 +- ...tured-agent-session-transport-overrides.ts | 6 + 34 files changed, 1420 insertions(+), 551 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 diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 7d831feb6bc1..4c2e4878a847 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', diff --git a/src/main/acp/acp-prompt-content.ts b/src/main/acp/acp-prompt-content.ts index 1a461602587e..a30aadb27878 100644 --- a/src/main/acp/acp-prompt-content.ts +++ b/src/main/acp/acp-prompt-content.ts @@ -21,7 +21,7 @@ import { } from '../../shared/node-bounded-file-reader' import type { AcpLaunchSpec } from './acp-launch-specs' import { resolveAcpPeerOptions } from './acp-peer-limits' -import { acpAgentName } from './acp-structured-acquire' +import { acpAgentName } from './acp-structured-agent-definitions' import type { AcpStructuredConnection } from './acp-structured-connection' import { acpSessionAcceptsImages } from './acp-structured-options' import type { ContentBlock } from './generated/acp-protocol.generated' diff --git a/src/main/acp/acp-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..c81d0ce4709e --- /dev/null +++ b/src/main/acp/acp-scripted-child.test-fixture.ts @@ -0,0 +1,151 @@ +import { z } from 'zod' +import type { + ScriptedAgentChild, + ScriptedAgentChildFactory +} from '../runtime/structured-agent-scripted-child.test-fixture' +import { FakeAcpChild, GROK_CONFIG_OPTIONS } from './acp-structured-adapter.test-support' +import type { FakeFrame } from './acp-scripted-agent.test-support' +import type { AcpLaunchSpec } from './acp-launch-specs' + +const promptParams = z.object({ + sessionId: z.string(), + prompt: z.array(z.object({ type: z.string(), text: z.string().optional() })), + _meta: z.object({ promptId: z.string() }).optional() +}) +const loadParams = z.object({ sessionId: z.string() }) +const configParams = z.object({ configId: z.string(), value: z.string() }) + +export function acpScriptedChild(agent: AcpLaunchSpec['agent']): ScriptedAgentChildFactory { + return () => { + let current: FakeAcpChild | undefined + let handshake: (() => void) | undefined + let released = false + let opened = 0 + let reopened = 0 + let closed = 0 + const texts: string[] = [] + const fixture: ScriptedAgentChild = { + holdHandshakes: true, + completeTurns: true, + deps: { + resolveEnvironment: async () => ({ PATH: '' }), + acpLaunchCommand: { + resolveCommand: () => 'scripted-acp-agent', + probeVersion: async () => true + }, + readProcessStartTime: async () => 1_700_000_000_000 + opened, + openAcpConnection: (launch, options) => { + opened += 1 + handshake = undefined + released = false + const child = new FakeAcpChild(launch, options) + current = child + let sessionId = `${agent}-scripted-session` + let running: FakeFrame | undefined + let configOptions = GROK_CONFIG_OPTIONS.map((option) => ({ ...option })) + const script = child.agent + script.on('initialize', (frame) => + script.reply(frame, { + protocolVersion: 1, + agentCapabilities: { loadSession: true } + }) + ) + const start = (frame: FakeFrame, resumes: boolean): void => { + if (resumes) { + reopened += 1 + sessionId = loadParams.parse(frame.params).sessionId + } + const answer = () => { + if (child.closed || child.exited) { + return + } + script.reply(frame, { ...(!resumes ? { sessionId } : {}), configOptions }) + } + if (fixture.holdHandshakes && !released) { + handshake = answer + } else { + answer() + } + } + script.on('session/new', (frame) => start(frame, false)) + script.on('session/load', (frame) => start(frame, true)) + script.on('session/set_config_option', (frame) => { + const { configId, value } = configParams.parse(frame.params) + configOptions = configOptions.map((option) => + option.id === configId ? { ...option, currentValue: value } : option + ) + script.reply(frame, { configOptions }) + }) + script.on('session/set_model', (frame) => script.reply(frame, {})) + // Grok's subagent-stop probe: the handler refuses the missing id, so stops are offered. + script.on('_x.ai/subagent/cancel', (frame) => + script.fail( + frame, + -32602, + 'Invalid params', + 'invalid params: missing field `subagentId`' + ) + ) + // Its background-task stop probe, answered the same way. + script.on('_x.ai/task/kill', (frame) => + script.fail( + frame, + -32602, + 'Invalid params', + 'invalid params: missing field `sessionId`' + ) + ) + script.on('session/prompt', (frame) => { + const params = promptParams.parse(frame.params) + texts.push(params.prompt.map((block) => block.text ?? '').join('')) + running = frame + script.notify('session/update', { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Scripted response' } + }, + ...(params._meta ? { _meta: params._meta } : {}) + }) + if (fixture.completeTurns) { + script.reply(frame, { stopReason: 'end_turn' }) + running = undefined + } + }) + script.on('session/cancel', () => { + if (running) { + script.reply(running, { stopReason: 'cancelled' }) + } + running = undefined + }) + const prove = child.proveClose + child.proveClose = async () => { + if (!child.exited) { + closed += 1 + } + return prove() + } + return child + } + }, + releaseHandshake: () => { + released = true + handshake?.() + handshake = undefined + }, + failHandshake: (message) => { + if (!current) { + throw new Error('no scripted ACP child') + } + current.stderr = message + current.exit() + handshake = undefined + }, + prompts: () => texts, + spawns: () => opened, + resumes: () => reopened, + closes: () => closed + } + return fixture + } +} diff --git a/src/main/acp/acp-scripted-connection.test-fixture.ts b/src/main/acp/acp-scripted-connection.test-fixture.ts new file mode 100644 index 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 926a83623c92..a72dc4670467 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -1,34 +1,31 @@ // 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 { providerSpawnedProcessIdentity } from '../provider-process/provider-spawned-process-identity' -import { AcpAgentError } from './acp-errors' -import { acpAuthenticationRequired, acpSignInRequiredRefusal } from './acp-turn-failures' import { - ACP_REOPEN_FAILED, - acpReopenTakeover, - acpSessionNotRestoredRow -} from './acp-session-reopen-failure' + assertAcpStartingSession, + type AcpStructuredChild, + type AcpStartingSession +} from './acp-structured-child' import type { AcpSessionEvent } from './acp-session-runtime' import type { AcpStructuredConnection } from './acp-structured-connection' -import { ACP_HANDLE_TRANSPORT, acpAgentName } from './acp-structured-agent-definitions' +import { acpAgentName } from './acp-structured-agent-definitions' import { AcpStructuredLane, acpLaneChildWorkDelivery } from './acp-structured-lane' -import { NO_ACP_CHILD_STOPS, probeAcpChildStops } from './acp-structured-child-stop' +import { NO_ACP_CHILD_STOPS } from './acp-structured-child-stop' import 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, @@ -36,13 +33,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 @@ -53,13 +49,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 @@ -83,7 +84,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, @@ -94,9 +95,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)) ) @@ -120,7 +121,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' } } @@ -165,11 +166,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, @@ -177,7 +195,6 @@ export async function acquireAcpStructuredSession(input: { deps.readProcessStartTime ) /** `attaching`: the lane opens inside the attach window, before any frame queued so far. */ - let childStops = NO_ACP_CHILD_STOPS const makeLane = (providerSessionId: string, attaching = false): AcpStructuredLane => { const lane = new AcpStructuredLane({ sink, @@ -188,10 +205,15 @@ export async function acquireAcpStructuredSession(input: { providerSessionId, dialect: spec.dialect, now, - childStops: () => childStops, + childStops: () => + (session?.phase === 'ready' ? session.childStops : undefined) ?? NO_ACP_CHILD_STOPS, ...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 @@ -212,114 +234,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. - } - } - if (!started || !liveLane) { - liveLane?.dispose() - slot.lane = null - started = await connection.start({ cwd: launch.cwd, mcpServers: [], ...auth }) - liveLane = makeLane(started.sessionId) + 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 } - childStops = await probeAcpChildStops(connection, spec.dialect) - options.adoptSession(started.response, started.kind === 'new' ? 'new' : 'loaded') - liveLane.apply(liveLane.translator.contextModels(started.response.models, now())) - const restoreSkipped = await restoreAcpSessionOptions(connection, options, acquire.options) - const process = await identity.read(pid) - const link: AgentSessionProviderHandleLink = { - linkId: - deps.mintLinkId?.() ?? `${spec.agent}-${acquire.fence}-${started.sessionId}`.slice(0, 128), - handle: { transport: ACP_HANDLE_TRANSPORT, agent: spec.agent, nativeId: started.sessionId }, - origin: started.kind === 'new' ? 'created' : 'resumed', - mintedAtFence: acquire.fence, - observedAt: now(), - ...takeover - } - session = { - sessionId, - fence: acquire.fence, - acquisitionGeneration: generation, - spec, - childStops, + } + 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, providerChildPhase: 'starting' }, + 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 fe80d84ddd1c..e273dce91135 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 resolveLaunch: AcpStructuredSessionAdapterDeps['resolveLaunch'] = options.deps?.resolveLaunch ?? @@ -216,11 +135,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 { @@ -234,9 +159,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', @@ -247,12 +183,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 acabe6ba6b65..826a65b5541a 100644 --- a/src/main/acp/acp-structured-child-stop.ts +++ b/src/main/acp/acp-structured-child-stop.ts @@ -1,4 +1,5 @@ import type { AcpStructuredSession } from './acp-structured-session' +import type { AcpStructuredChild } from './acp-structured-child' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpChildStops, AcpDialect } from './acp-dialects/acp-dialect' import { AcpAgentError } from './acp-errors' @@ -12,12 +13,13 @@ type BackgroundTaskStop = NonNullable export const NO_ACP_CHILD_STOPS: AcpChildStops = { subagents: false, backgroundTasks: false } export function acpChildStopCapabilities( - session?: AcpStructuredSession + session?: AcpStructuredChild ): AgentSessionBackgroundTaskStops | undefined { return session?.journalClosed === null ? { supportsTaskStop: - session.childStops?.subagents === true || session.childStops?.backgroundTasks === true, + session.phase === 'ready' && + (session.childStops?.subagents === true || session.childStops?.backgroundTasks === true), supportsStopAll: false } : undefined diff --git a/src/main/acp/acp-structured-child.ts b/src/main/acp/acp-structured-child.ts new file mode 100644 index 000000000000..66c1414bc1fa --- /dev/null +++ b/src/main/acp/acp-structured-child.ts @@ -0,0 +1,97 @@ +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, acpSignInRequiredFailure } 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 { + 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 ? error.message : '') + const detail = providerDiagnostic(text, 'person') + return agentSessionFailureFact('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.kind !== 'notSignedIn' + ? { ...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..6aee71147adc --- /dev/null +++ b/src/main/acp/acp-structured-handshake.ts @@ -0,0 +1,177 @@ +// 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 { probeAcpChildStops } from './acp-structured-child-stop' +import type { AcpStructuredConnection } from './acp-structured-connection' +import type { AcpStructuredLane } from './acp-structured-lane' +import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' +import { 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 childStops = await probeAcpChildStops(connection, spec.dialect) + assertStarting() + options.adoptSession(started.response, started.kind === 'new' ? 'new' : 'loaded') + liveLane.apply(liveLane.translator.contextModels(started.response.models, now())) + const 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, + childStops, + connection, + lane: liveLane, + prompts, + options, + turns: new AcpStructuredTurns({ + connection, + withdrawRequests: () => prompts.withdrawAll(), + lane: liveLane, + agentName, + now, + settle: onSettled + }), + restoreSkipped, + 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, + // Its saved picks restored: what it runs now is what its start resolved. + catalogListing: options.startListing() + }) +} diff --git a/src/main/acp/acp-structured-host-configured-default.test.ts b/src/main/acp/acp-structured-host-configured-default.test.ts index f37dbafac6df..b0e19d5f14a0 100644 --- a/src/main/acp/acp-structured-host-configured-default.test.ts +++ b/src/main/acp/acp-structured-host-configured-default.test.ts @@ -148,6 +148,10 @@ async function openGrokHost(runs: string, create?: StructuredAgentSessionFirstMe ? rig.host.create(CALLER, createTestParams(create, attachParams()), { firstMessage: create }) : rig.host.attach(CALLER, attachParams()) expect(await opening).toMatchObject({ ok: true }) + if (!create) { + // The attach answers at spawn; what the chat runs is known once its start is proven. + await rig.ready() + } return { ...rig, catalog, listed } } diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index 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-live-listing.test.ts b/src/main/acp/acp-structured-host-live-listing.test.ts index f077684b1a41..57cca6e00aaf 100644 --- a/src/main/acp/acp-structured-host-live-listing.test.ts +++ b/src/main/acp/acp-structured-host-live-listing.test.ts @@ -45,9 +45,13 @@ describe('a live Grok listing', () => { stop: vi.fn(), providerStarted: vi.fn() } - // The rig's attach must succeed: bookkeeping never fails a proven start. + // The rig waits for the child to turn ready: bookkeeping never fails a proven start. const { store } = await openAttachedHostRig({}, undefined, modelCatalog) expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(1) expect(store.getRecord(SESSION)?.lease.ownerProcess).not.toBeNull() + // The rest of the start still lands: what it reported is saved. + await vi.waitFor(() => + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'grok-4.7', effort: 'high' }) + ) }) }) 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 f4024117ec9d..bae22410c7d9 100644 --- a/src/main/acp/acp-structured-host-restore-failed.test.ts +++ b/src/main/acp/acp-structured-host-restore-failed.test.ts @@ -120,14 +120,16 @@ async function openRestoreRig(code: number, message: string) { describe('a saved Grok session Grok cannot reopen', () => { it('continues the chat in a fresh session that the chain records and the next reopen loads', async () => { - const { host, store, fence, loads, warnings, exchange } = await openRestoreRig( + const { host, store, fence, loads, warnings, ready, exchange } = await openRestoreRig( -32603, 'session file is corrupt' ) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await ready() await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION]) const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] @@ -160,6 +162,7 @@ describe('a saved Grok session Grok cannot reopen', () => { // The next reopen loads the fresh session, and says nothing new. await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION, FRESH]) expect(store.getRecord(SESSION)?.providerHandleChain).toHaveLength(3) expect(await warnings()).toHaveLength(1) @@ -169,14 +172,16 @@ describe('a saved Grok session Grok cannot reopen', () => { describe('a created Grok session Grok reports missing on the first reopen', () => { it('replaces it and says so once when the chat exchanged a turn on it', async () => { - const { host, store, fence, loads, warnings, exchange, messages } = await openRestoreRig( + const { host, store, fence, loads, warnings, ready, exchange, messages } = await openRestoreRig( -32002, 'Resource not found' ) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await ready() await exchange('remember 42', 'noted', PROVIDER_SESSION) await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION]) const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] @@ -195,19 +200,22 @@ describe('a created Grok session Grok reports missing on the first reopen', () = // The next reopen loads the fresh session, and says nothing new. await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION, FRESH]) expect(await warnings()).toHaveLength(1) await host.close(SESSION, 'user-close') }) it('supersedes it silently when nothing was exchanged on it', async () => { - const { host, store, fence, loads, warnings } = await openRestoreRig( + const { host, store, fence, loads, warnings, ready } = await openRestoreRig( -32002, 'Resource not found' ) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await ready() await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION]) const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] @@ -224,13 +232,14 @@ describe('a created Grok session Grok reports missing on the first reopen', () = }) }) -describe('the warning row of a replacement whose attach failed', () => { - async function replaceThenFailAttach() { +describe('a replacement after its earlier attach failed to open the journal', () => { + async function failAttachBeforeReplacement() { const opened = await openRestoreRig(-32603, 'session file is corrupt') - const { host, store, fence, warnings } = opened + const { host, store, fence, warnings, ready } = opened expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await ready() await host.close(SESSION, 'user-close') - // The journal's open fails after the fresh session's link is durable, before its row is written. + // The journal must open before a spawned child's background handshake proves a new link. vi.spyOn(AgentSessionJournal.prototype, 'open').mockRejectedValueOnce( new Error('journal path unavailable') ) @@ -240,32 +249,37 @@ describe('the warning row of a replacement whose attach failed', () => { expect( agentSessionProviderHandleChainHead(store.getRecord(SESSION)?.providerHandleChain ?? []) ).toMatchObject({ - handle: { nativeId: FRESH }, - replaces: { key: keyOf(PROVIDER_SESSION), reason: 'restore-failed' } + handle: { nativeId: PROVIDER_SESSION } }) expect(await warnings()).toEqual([]) return opened } - it('is written by the next start that loads the fresh session, and only once', async () => { - const { host, fence, loads, warnings } = await replaceThenFailAttach() + it('writes the warning when the next start replaces the old session, and only once', async () => { + const { host, fence, loads, warnings, ready } = await failAttachBeforeReplacement() expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) - expect(loads).toEqual([PROVIDER_SESSION, FRESH]) + await ready() + expect(loads).toEqual([PROVIDER_SESSION]) expect(await warnings()).toHaveLength(1) await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) - expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH]) + await ready() + expect(loads).toEqual([PROVIDER_SESSION, FRESH]) expect(await warnings()).toHaveLength(1) await host.close(SESSION, 'user-close') }) it('is written when Grok never saved the fresh session and a newer one supersedes it', async () => { - const { host, store, fence, loads, lost, warnings } = await replaceThenFailAttach() + const { host, store, fence, loads, lost, warnings, ready } = await failAttachBeforeReplacement() + expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() + await host.close(SESSION, 'user-close') lost.set(FRESH, { code: -32002, message: 'Resource not found' }) expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION, FRESH]) const chain = store.getRecord(SESSION)?.providerHandleChain ?? [] expect(chain).toHaveLength(2) @@ -277,6 +291,7 @@ describe('the warning row of a replacement whose attach failed', () => { expect(await warnings()).toHaveLength(1) await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH_2]) expect(await warnings()).toHaveLength(1) await host.close(SESSION, 'user-close') @@ -285,23 +300,27 @@ describe('the warning row of a replacement whose attach failed', () => { describe('a replacement Grok never saved, superseded after its row was written', () => { it('keeps the one row: the conversation lost is still the first one', async () => { - const { host, fence, loads, lost, warnings } = await openRestoreRig( + const { host, fence, loads, lost, warnings, ready } = await openRestoreRig( -32603, 'session file is corrupt' ) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) + await ready() await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(await warnings()).toHaveLength(1) await host.close(SESSION, 'user-close') lost.set(FRESH, { code: -32002, message: 'Resource not found' }) expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION, FRESH]) expect(await warnings()).toHaveLength(1) // The newer session carries the same loss, so reopening it says nothing new. await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) + await ready() expect(loads).toEqual([PROVIDER_SESSION, FRESH, FRESH_2]) expect(await warnings()).toHaveLength(1) await host.close(SESSION, 'user-close') diff --git a/src/main/acp/acp-structured-host-start-aborts.test.ts b/src/main/acp/acp-structured-host-start-aborts.test.ts index 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 443ff17fdfb2..ad17dd78cf4d 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -276,8 +276,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 ce73409c363f..b0cb56c964c6 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -5,7 +5,6 @@ import { providerTurnId, SESSION } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { StructuredAgentSessionTaskQueue } from '../native-chat/agent-session-wire/structured-agent-session-task-queue' import { agentSessionLaunchFolderMissing } from '../runtime/agent-session-launch-directory' import { tick } from './acp-scripted-agent.test-support' import { @@ -133,7 +132,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 }) @@ -168,7 +166,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' } ]) ) @@ -190,10 +188,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', @@ -209,7 +207,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({ @@ -228,8 +226,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' } } @@ -274,7 +272,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 () => { @@ -288,9 +286,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' }]) }) }) @@ -334,77 +332,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) }) @@ -459,7 +438,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 () => { @@ -476,7 +455,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 05165c362123..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 291dfe000d61..c2a22021eede 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -4,11 +4,7 @@ import { closeProviderTimelineRigs, SESSION } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { - AgentSessionAcquisitionExitProvenError, - AgentSessionAcquisitionRefusal, - AgentSessionAcquisitionRootExitObservedError -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { GENERIC_ACP_DIALECT } from './acp-dialects/acp-dialect' import { STRUCTURED_CHILD_ENV_TO_DELETE } from '../runtime/structured-session-child-env' import { AGENT_HOOK_RUNTIME_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' @@ -52,7 +48,7 @@ describe('ACP structured session adapter: acquire', () => { const acquired = await rig.acquire() expect(rig.spawned).toEqual(['spawn', 'onSpawned', 'initialize']) expect(acquired.process).toMatchObject({ pid: PID, spawnToken: 'spawn-1', hostId: 'local' }) - expect(acquired.link).toMatchObject({ + expect(rig.started().link).toMatchObject({ handle: { transport: 'acp', agent: 'grok', nativeId: PROVIDER_SESSION }, origin: 'created', mintedAtFence: 1 @@ -113,8 +109,8 @@ describe('ACP structured session adapter: acquire', () => { script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Session not found')) }) - const acquired = await rig.acquire({ fence: 2 }) - expect(acquired.link).toMatchObject({ origin: 'created', supersedesKey: 'old-key' }) + await rig.acquire({ fence: 2 }) + expect(rig.started().link).toMatchObject({ origin: 'created', supersedesKey: 'old-key' }) }) it('refuses a signed-out agent through the existing start-failure surface and stops its child', async () => { @@ -122,9 +118,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) }) }) @@ -138,10 +133,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 () => { @@ -155,9 +150,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', () => { @@ -434,18 +430,18 @@ describe('ACP structured session adapter: close and exit', () => { await expect(rig.adapter.closeSession(SESSION)).rejects.toBeInstanceOf( AgentSessionAcquisitionRootExitObservedError ) - expect(rig.lifecycle).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) + expect(rig.ended).toMatchObject([{ type: 'ended', cause: 'requested-close' }]) }) it('closes with proof and reports a requested close', async () => { const rig = await openAcpAdapterRig() await rig.acquire() await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) - expect(rig.lifecycle).toMatchObject([ + expect(rig.ended).toMatchObject([ { type: 'ended', sessionId: SESSION, cause: 'requested-close', fence: 1 } ]) await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) - expect(rig.lifecycle).toHaveLength(1) + expect(rig.ended).toHaveLength(1) }) it('ends the session on an unexpected exit: held sends rejected, the running one unknown', async () => { @@ -457,7 +453,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 766d4b9ed73e..831a47ef44cf 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, @@ -43,13 +36,14 @@ import { import { readAcpLiveOptions, writeAcpSessionOption } from './acp-structured-options' import { readAcpRecoveryHistory } from './acp-recovery-history' 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 = ({ @@ -62,9 +56,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` @@ -73,7 +67,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } return await this.start(input, attempt) } finally { - this.starts.end(attempt) + this.children.starts.end(attempt) } } @@ -84,28 +78,41 @@ 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) - // Its saved picks restored: what it runs now is what its start resolved. - return { ...acquisition, catalogListing: session.options.startListing() } + this.children.sessions.set(sessionId, session) + void initialize().catch((error: unknown) => this.children.startupFailed(session, error)) + return acquisition } catch (error) { const { connection } = attempt if (connection && error instanceof AcpConnectionClosedError) { @@ -117,7 +124,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte const exitedOnItsOwn = connection?.exited === true && !attempt.signal.aborted if (connection && !(await connection.close().catch(() => false))) { // Kept, so the next start or quit closes this same process again; no second one spawns. - this.starts.retainFailed(sessionId, connection) + this.children.starts.retainFailed(sessionId, connection) throw new AgentSessionAcquisitionExitUnprovenError(error) } if (attempt.signal.aborted) { @@ -147,7 +154,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 +164,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 +199,11 @@ 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()) } } @@ -228,10 +232,32 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte readOptions = async (input: { sessionId: string; fence: number }) => readAcpLiveOptions(this.live(input.sessionId)) - readOptionRestoreFailures = (sessionId: string): readonly string[] => - this.sessions.get(sessionId)?.restoreSkipped ?? [] + readOptionRestoreFailures = (sessionId: string): readonly string[] => { + const session = this.children.sessions.get(sessionId) + return session?.phase === 'ready' ? session.restoreSkipped : [] + } - readCommands = (sessionId: string) => this.sessions.get(sessionId)?.options.readCommands() + readCommands = (sessionId: string) => { + const session = this.children.sessions.get(sessionId) + return session?.phase === 'ready' ? session.options.readCommands() : undefined + } + + 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 @@ -239,99 +265,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 { @@ -344,8 +301,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 88242d5a7c0b..19457d09d5d8 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -17,6 +17,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 @@ -28,7 +29,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 diff --git a/src/main/acp/acp-structured-sign-in.test.ts b/src/main/acp/acp-structured-sign-in.test.ts index f7349b78470c..9e02e4f7aac3 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,16 +81,40 @@ 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) } ) +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-structured-steer.test.ts b/src/main/acp/acp-structured-steer.test.ts index 421ecd31f641..bcd27f99c0c3 100644 --- a/src/main/acp/acp-structured-steer.test.ts +++ b/src/main/acp/acp-structured-steer.test.ts @@ -166,7 +166,7 @@ describe('a send while a Grok prompt runs', () => { await new Promise((resolve) => setTimeout(resolve, 120)) await rig.settle() expect(rig.sent('session/cancel')).toHaveLength(1) - expect(rig.lifecycle).toEqual([]) + expect(rig.ended).toEqual([]) expect(rig.child().closes).toBe(0) expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['first']) // A Stop sends its own cancel; the host's grace, not the cancel, ends Grok. @@ -223,7 +223,7 @@ describe('a send while a turn Grok began itself runs', () => { // `a` runs a whole turn: a steer's cancel never ends Grok. await new Promise((resolve) => setTimeout(resolve, 120)) await rig.settle() - expect(rig.lifecycle).toEqual([]) + expect(rig.ended).toEqual([]) expect(rig.child().closes).toBe(0) agent.reply(a, { stopReason: 'end_turn' }) const b = await rig.frame('session/prompt', 1) diff --git a/src/main/acp/acp-turn-failures.ts b/src/main/acp/acp-turn-failures.ts index 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/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 300d292d180a..f0c6ee975fe4 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -231,6 +231,7 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio const sources = structuredAgentLaunchSources(context) const part = acpStructuredLaunchPart(spec, { readJournal, + ...deps.acpLaunchCommand, ...(deps.resolveAgentFullAccess ? { resolveFullAccess: deps.resolveAgentFullAccess } : {}), logger: deps.logger }) @@ -239,18 +240,13 @@ function acpRegistration(spec: AcpLaunchSpec): StructuredAgentRuntimeRegistratio readJournal, resolveLaunch: composeStructuredLaunch(definition, sources, part), readLaunch: composeStructuredLaunchRead(definition, sources, part), - 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-session-transport-overrides.ts b/src/main/runtime/structured-agent-session-transport-overrides.ts index 6bfd1e1d3271..550760f812c8 100644 --- a/src/main/runtime/structured-agent-session-transport-overrides.ts +++ b/src/main/runtime/structured-agent-session-transport-overrides.ts @@ -1,3 +1,5 @@ +import type { AcpStructuredSessionAdapterDeps } from '../acp/acp-structured-session-adapter-deps' +import type { AcpStructuredLaunchPartDeps } from '../acp/acp-structured-launch-resolution' import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' import type { CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' import type { StructuredAgentSessionStartupLimits } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' @@ -7,6 +9,10 @@ import type { PiRpcSessionDeps } from '../pi/rpc-session' export type StructuredAgentSessionTransportOverrides = { openPiConnection?: PiRpcSessionDeps['openConnection'] 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'] From 765a4ee8cace7937a569d3c21fd8bc3be4e4cbff Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:52:52 -0700 Subject: [PATCH 04/55] Adapt permission fixtures to startup lifecycle --- ...uctured-agent-session-options-read.test.ts | 6 +- ...nt-session-startup-permission-mode.test.ts | 160 +++++++++--------- ...claude-auto-mode-host-catalog.unit.test.ts | 1 + 3 files changed, 88 insertions(+), 79 deletions(-) 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 db5337a9aa24..30ae0f475cb7 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 @@ -56,7 +56,7 @@ describe('options at rest', () => { const result = await readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) - expect(result.current).toEqual({ model: 'gpt-folder', effort: 'high' }) + expect(result.current).toEqual({ model: 'gpt-folder', effort: 'high', permissionMode: 'ask' }) expect(read).toHaveBeenCalledWith({ agent: 'codex', sessionId: SESSION, @@ -101,12 +101,12 @@ describe('options at rest', () => { const current = await readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) - expect(current.current).toEqual({}) + expect(current.current).toEqual({ permissionMode: 'ask' }) expect(read).toHaveBeenCalledTimes(1) const older = await readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: false }) - expect(older.current).toEqual({ model: 'gpt-account' }) + expect(older.current).toEqual({ model: 'gpt-account', permissionMode: 'ask' }) expect(read).toHaveBeenLastCalledWith({ agent: 'codex', sessionId: SESSION, savedOnly: true }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts index 00603321925c..c90c850fa69b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts @@ -11,84 +11,92 @@ import { hostTestOperationId } from './structured-agent-session-host-test-data' -it('keeps startup permission, model and effort picks through delivery and runtime replacement', async () => { - const rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) - try { - const acquire = rig.adapter.acquire.getMockImplementation() - if (!acquire) { - throw new Error('missing test acquisition') - } - rig.adapter.acquire.mockImplementationOnce(rig.heldStart(acquire)) - const writes = vi.spyOn(rig.host.deps.adapter, 'setOption') - const attached = await rig.host.attach(CALLER, { - ...hostTestAttachParams(null), - options: { model: 'launched-model', effort: 'low' } +it.each([false, true])( + 'keeps startup picks through replacement with dispatch ownership: %s', + async (hasOpenDispatch) => { + const rig = await createRestTestRig({ + idleSweep: { intervalMs: 3_600_000 }, + hasOpenDispatch: () => hasOpenDispatch }) - if (!attached.ok) { - throw new Error('test attach refused') - } - const firstChild = await rig.adapter.acquire.mock.results[0].value - const sent = await rig.host.send(CALLER, restTestSend('held while starting', attached.fence)) - expect(sent.ok).toBe(true) - for (const [key, value] of [ - ['effort', 'high'], - ['permissionMode', 'full-access'], - ['model', 'picked-model'] - ]) { - const fields = { key, value } - expect( - await rig.host.setOption(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: attached.fence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.setOption', + try { + const acquire = rig.adapter.acquire.getMockImplementation() + if (!acquire) { + throw new Error('missing test acquisition') + } + rig.adapter.acquire.mockImplementationOnce(rig.heldStart(acquire)) + const writes = vi.spyOn(rig.host.deps.adapter, 'setOption') + const attached = await rig.host.attach(CALLER, { + ...hostTestAttachParams(null), + options: { model: 'launched-model', effort: 'low' } + }) + if (!attached.ok) { + throw new Error('test attach refused') + } + const firstChild = await rig.adapter.acquire.mock.results[0].value + const sent = await rig.host.send(CALLER, restTestSend('held while starting', attached.fence)) + expect(sent.ok).toBe(true) + for (const [key, value] of [ + ['effort', 'high'], + ['permissionMode', 'full-access'], + ['model', 'picked-model'] + ]) { + const fields = { key, value } + expect( + await rig.host.setOption(CALLER, { + envelope: { sessionId: SESSION, - fields - }) - }, - ...fields - }) - ).toMatchObject({ ok: true }) - } - expect(writes).not.toHaveBeenCalled() - expect(rig.adapter.dispatch).not.toHaveBeenCalled() + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: attached.fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.setOption', + sessionId: SESSION, + fields + }) + }, + ...fields + }) + ).toMatchObject({ ok: true }) + } + expect(writes).not.toHaveBeenCalled() + expect(rig.adapter.dispatch).not.toHaveBeenCalled() - await rig.host.handleAdapterEvent({ - type: 'started', - sessionId: SESSION, - fence: attached.fence, - acquisitionGeneration: firstChild.acquisitionGeneration!, - reportedOptions: { model: 'launched-model', effort: 'low' }, - restoreSkippedOptions: [], - optionRevision: -1 - }) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) - expect(writes.mock.calls.map(([input]) => [input.key, input.value])).toEqual([ - ['model', 'picked-model'], - ['effort', 'high'] - ]) - const options = { model: 'picked-model', effort: 'high', permissionMode: 'full-access' } - expect(rig.adapter.closeSession).toHaveBeenCalledOnce() - expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) - expect(rig.adapter.acquire).toHaveBeenLastCalledWith( - expect.objectContaining({ permissionMode: 'full-access', options }) - ) - expect(rig.adapter.acquire.mock.invocationCallOrder[1]).toBeLessThan( - rig.adapter.dispatch.mock.invocationCallOrder[0] - ) - expect(rig.store.getRecord(SESSION)?.options).toEqual(options) + await rig.host.handleAdapterEvent({ + type: 'started', + sessionId: SESSION, + fence: attached.fence, + acquisitionGeneration: firstChild.acquisitionGeneration!, + reportedOptions: { model: 'launched-model', effort: 'low' }, + restoreSkippedOptions: [], + optionRevision: -1 + }) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) + expect(writes.mock.calls.map(([input]) => [input.key, input.value])).toEqual([ + ['model', 'picked-model'], + ['effort', 'high'] + ]) + const options = { model: 'picked-model', effort: 'high', permissionMode: 'full-access' } + expect(rig.adapter.closeSession).toHaveBeenCalledTimes(hasOpenDispatch ? 0 : 1) + expect(rig.adapter.acquire).toHaveBeenCalledTimes(hasOpenDispatch ? 1 : 2) + expect(rig.adapter.acquire).toHaveBeenLastCalledWith( + expect.objectContaining( + hasOpenDispatch ? { permissionMode: 'ask' } : { permissionMode: 'full-access', options } + ) + ) + expect(rig.adapter.acquire.mock.invocationCallOrder[hasOpenDispatch ? 0 : 1]).toBeLessThan( + rig.adapter.dispatch.mock.invocationCallOrder[0] + ) + expect(rig.store.getRecord(SESSION)?.options).toEqual(options) - await rig.restart() - expect((await rig.host.readOptions(SESSION)).current.permissionMode).toBe('full-access') - const nextFence = rig.store.getRecord(SESSION)!.lease.runtimeFence - expect((await rig.host.send(CALLER, restTestSend('after restart', nextFence))).ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) - expect(rig.adapter.acquire).toHaveBeenLastCalledWith( - expect.objectContaining({ permissionMode: 'full-access', options }) - ) - } finally { - await rig.dispose() + await rig.restart() + expect((await rig.host.readOptions(SESSION)).current.permissionMode).toBe('full-access') + const nextFence = rig.store.getRecord(SESSION)!.lease.runtimeFence + expect((await rig.host.send(CALLER, restTestSend('after restart', nextFence))).ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + expect(rig.adapter.acquire).toHaveBeenLastCalledWith( + expect.objectContaining({ permissionMode: 'full-access', options }) + ) + } finally { + await rig.dispose() + } } -}) +) diff --git a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts index 3c45b200dd5d..b02317e0cf35 100644 --- a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts +++ b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts @@ -107,6 +107,7 @@ async function hostCatalog(listingLands: Promise) { accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(dir, 'claude-home') }, options: { permissionMode: 'ask' } }) + record.lease.claimStatus = 'reserved' const modelCatalog = createAgentModelCatalogService({ store, getRecord: () => record, From 0737f637c51cf1f0b7fd17be2adcc4bcf728f0d1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:12:17 -0700 Subject: [PATCH 05/55] Keep model discovery for chats before their first start --- ...gent-model-catalog-saved-only-read.test.ts | 45 +++++++++++++++- .../agent-model-catalog-service.ts | 9 ++-- .../structured-agent-session-attach-flow.ts | 7 +-- ...structured-agent-session-host-mutations.ts | 6 ++- ...nt-session-startup-permission-mode.test.ts | 51 +++++++++++++++++++ ...claude-auto-mode-host-catalog.unit.test.ts | 1 - 6 files changed, 107 insertions(+), 12 deletions(-) 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 7b9079ff8893..43d9148b037a 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 @@ -14,6 +14,7 @@ import { } from './agent-model-catalog-store' import { AgentModelCatalogUnavailableError } from './agent-model-catalog-unavailable' import { agentReadsProjectModelConfig } from './agent-project-model-override' +import { foundAgentSessionRecord } from '../../runtime/agent-session-record-founding' // A client preloads every agent's saved list through `savedOnly`: the answer comes from what the // host saved, and no listing, probe or re-check of a held reason starts. @@ -52,11 +53,53 @@ function rig(agent = 'grok') { } describe('a saved-only catalog read', () => { + it('probes for a brand-new released chat before its first send', async () => { + const record = foundAgentSessionRecord( + { + sessionId: 'new-chat', + provider: 'grok', + accountHome: HOME, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-1', + workspaceKind: 'folder' + } + }, + { claimKeyId: 'key-1', now: 1_000 } + ) + const probe = vi.fn(async () => listing('grok-4.7')) + const service = createAgentModelCatalogService({ + store: new AgentModelCatalogStore(), + getRecord: () => record, + drivesRecord: () => true, + resolveAccountHome: async () => HOME, + probes: { grok: probe } + }) + + expect(record.lease.claimStatus).toBe('released') + expect(record.lease.deathEvidence).toBeNull() + expect(record.providerHandleChain).toEqual([]) + expect( + await service.read({ agent: 'grok', sessionId: record.sessionId, waitForListing: true }) + ).toMatchObject({ origin: 'probe', models: [{ id: 'grok-4.7' }] }) + expect(probe).toHaveBeenCalledExactlyOnceWith(HOME, { signal: expect.any(AbortSignal) }) + }) + 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 }) + agentSessionLeaseFixture({ + claimStatus: 'released', + ownerProcess: null, + deathEvidence: { + kind: 'pid-absent', + detail: 'reservation failed before spawn', + observedAt: now, + ownerFence: 6 + } + }) ), provider: 'grok', accountHome: HOME 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 d4316b146d63..90ea2d072120 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,9 +274,12 @@ export function createAgentModelCatalogService( ...(unavailable ? { unavailable } : {}) } } - // 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') { + // Death evidence keeps a read after a failed start from spawning another agent; a new chat + // also starts released, but still needs its first listing. + if ( + params.savedOnly || + (scoped?.lease?.claimStatus === 'released' && scoped.lease.deathEvidence) + ) { 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-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index d466f2dbdaae..36ec08717e6c 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 @@ -52,7 +52,6 @@ import type { StructuredAgentSessionStartupAttempt } from './structured-agent-se import type { StructuredAgentSessionStartupProgress } from './structured-agent-session-startup-attempt' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' - import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' export type AttachedOwner = 'acquired' | 'kept' @@ -84,7 +83,7 @@ export type AttachFlowInput = { record: AgentSessionRecord ) => Promise /** Publishes the journal before clients can send against the new owner. `owner` says whether - * this attach spawned the child, kept a live one, or left the chat at rest. */ + * this attach spawned the child or kept a live one. */ onAttached: ( attached: AttachedJournal, acquisitionGeneration: string | null, @@ -203,9 +202,7 @@ export async function performAttach( accountHome: record.accountHome, ownerAlreadyAdmitted: agentSessionLeaseAdmitsWriter(record.lease) }) - if (agentSessionLeaseAdmitsWriter(record.lease)) { - // Kept: re-attached to the live child. - } else { + if (!agentSessionLeaseAdmitsWriter(record.lease)) { const acquired = await withAgentSessionCreatePhase('acquire_owner', input.recordPhase, () => acquireOwner(input, record) ) 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 87481f8fcf52..17f9091162ba 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 @@ -201,8 +201,10 @@ export async function setStructuredAgentSessionOption( }, run: async (ctx) => { if (recordsIntent()) { - // A report the starting child read before this pick is out of date. - context.optionRevisions.advance(params.envelope.sessionId) + if (params.key !== AGENT_CHAT_PERMISSION_MODE_OPTION_ID) { + // 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. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts index c90c850fa69b..bd8f9aeb6412 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts @@ -11,6 +11,57 @@ import { hostTestOperationId } from './structured-agent-session-host-test-data' +it('keeps a permission pick while accepting the startup model report', async () => { + const rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) + try { + const acquire = rig.adapter.acquire.getMockImplementation() + if (!acquire) { + throw new Error('missing test acquisition') + } + rig.adapter.acquire.mockImplementationOnce(rig.heldStart(acquire)) + const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) + if (!attached.ok) { + throw new Error('test attach refused') + } + const child = await rig.adapter.acquire.mock.results[0].value + const fields = { key: 'permissionMode', value: 'full-access' } + expect( + await rig.host.setOption(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: attached.fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.setOption', + sessionId: SESSION, + fields + }) + }, + ...fields + }) + ).toMatchObject({ ok: true }) + + await rig.host.handleAdapterEvent({ + type: 'started', + sessionId: SESSION, + fence: attached.fence, + acquisitionGeneration: child.acquisitionGeneration!, + reportedOptions: { model: 'provider-model', effort: 'medium' }, + restoreSkippedOptions: [], + optionRevision: 0 + }) + await vi.waitFor(() => + expect(rig.store.getRecord(SESSION)?.options).toEqual({ + model: 'provider-model', + effort: 'medium', + permissionMode: 'full-access' + }) + ) + } finally { + await rig.dispose() + } +}) + it.each([false, true])( 'keeps startup picks through replacement with dispatch ownership: %s', async (hasOpenDispatch) => { diff --git a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts index b02317e0cf35..3c45b200dd5d 100644 --- a/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts +++ b/tests/e2e/claude-auto-mode-host-catalog.unit.test.ts @@ -107,7 +107,6 @@ async function hostCatalog(listingLands: Promise) { accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(dir, 'claude-home') }, options: { permissionMode: 'ask' } }) - record.lease.claimStatus = 'reserved' const modelCatalog = createAgentModelCatalogService({ store, getRecord: () => record, From 86d082998358e6d3104940d7017e5ab32d525a68 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:26:57 -0700 Subject: [PATCH 06/55] Use one host readiness promise for provider operations --- .../structured-agent-session-adapter.ts | 3 + ...ent-session-claude-hung-start-stop.test.ts | 16 +- .../structured-agent-session-delivery-loop.ts | 21 +- .../structured-agent-session-host-delivery.ts | 9 +- ...structured-agent-session-host-mutations.ts | 43 ++-- .../structured-agent-session-host.ts | 3 + ...red-agent-session-mutation-context.test.ts | 63 +++-- ...ructured-agent-session-mutation-context.ts | 41 +-- ...ed-agent-session-owed-work-release.test.ts | 13 +- ...gent-session-provider-child-record.test.ts | 5 +- ...-session-provider-child-start-wait.test.ts | 51 ---- ...structured-agent-session-provider-child.ts | 69 ----- ...ed-agent-session-provider-open-contract.ts | 24 ++ ...tured-agent-session-provider-start-hold.ts | 90 ------- ...gent-session-ready-operation-order.test.ts | 113 +++++++++ ...tructured-agent-session-ready-operation.ts | 237 ++++++++++++++++++ .../structured-agent-session-rewind.ts | 50 ++-- ...ructured-agent-session-send-preparation.ts | 71 ++---- ...session-send-restarts-failed-start.test.ts | 9 +- ...ured-agent-session-startup-attempt.test.ts | 49 ++++ ...tructured-agent-session-startup-attempt.ts | 39 ++- .../structured-conversation-command.ts | 191 +++++++------- .../structured-conversation-compaction.ts | 18 +- 23 files changed, 766 insertions(+), 462 deletions(-) delete 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-open-contract.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-start-hold.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts 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 c7530996d129..5bbb022cfa9f 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 @@ -53,6 +53,7 @@ import type { ProviderHistoryWindow } from '../agent-session-journal/journal-sub import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' +import type { StructuredAgentSessionProviderOpen } from './structured-agent-session-provider-open-contract' /** A live options read, plus what the child listed for its account's saved catalog. The host * saves that listing and strips it before answering; an adapter whose listing reaches the @@ -283,6 +284,8 @@ export type StructuredAgentSessionSetOptionInput = { } export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & { + /** Opens the published process under the host's startup signal. */ + open?: StructuredAgentSessionProviderOpen /** Provider-aware capability check for hosts that route more than one adapter. */ supportsCreate?(location: AgentSessionExecutionLocation, agent: string): boolean /** Provider/runtime support, kept here so remote enablement changes adapter data, not UI logic. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts index 347de6cf6643..db87e619b933 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts @@ -131,12 +131,10 @@ async function readsWorking(): Promise { ) } -/** Whether the CLI was written `hello`, once the delivery loop has settled what it does with it. */ -async function helloWritten(): Promise { - await vi.waitFor(() => - expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) - ) - return claude.connections[0]!.sent.some((message) => JSON.stringify(message).includes('hello')) +async function expectHeld(id: string): Promise { + expect(await submission(id)).toMatchObject({ dispatchState: 'pending' }) + expect((await submission(id))?.handedOverAt).toBeUndefined() + expect(claude.connections[0]!.sent).toEqual([]) } it('holds the message from a start that never answers, and a Stop ends it: stopped, nothing working', async () => { @@ -150,7 +148,7 @@ it('holds the message from a start that never answers, and a Stop ends it: stopp const [connection] = claude.connections // Accepted, and held while initialize is still unanswered. - expect(await helloWritten()).toBe(false) + await expectHeld(id) expect(connection!.calls).toEqual([{ subtype: 'initialize' }]) expect(host['sessions'].get(SESSION)?.child?.phase).toBe('starting') expect(await readsWorking()).toBe(true) @@ -182,7 +180,9 @@ async function sendHello(written: boolean): Promise { if (!sent.ok) { throw new Error('send refused') } - await vi.waitFor(async () => expect(await helloWritten()).toBe(written)) + await (written + ? vi.waitFor(() => expect(claude.connections[0]!.sent).toHaveLength(1)) + : expectHeld(sent.value.clientMessageId)) return sent.value.clientMessageId } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 1858eaeddf8d..dcfc8b4de1d5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -37,6 +37,7 @@ import { handOverSubmission } from './structured-agent-session-turns' import { structuredAgentSessionNextHandover } from './structured-agent-session-opening-send' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import type { StructuredAgentSessionStartReadiness } from './structured-agent-session-startup-attempt' import { holdRestartedStructuredAgentSessionSends } from './structured-agent-session-host-lifetime' import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' @@ -53,6 +54,10 @@ export type StructuredAgentSessionDeliveryLoopDeps = { sessionId: string, startedFor: string ) => Promise + readiness: ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity + ) => StructuredAgentSessionStartReadiness | null /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ conversationFence: (sessionId: string) => number /** Settles queued messages as a completed close of the chat does; false when that failed. */ @@ -77,7 +82,11 @@ type RefusedStart = Extract type Prepared = | 'stop' | RefusedStart - | { ok: true; awaited: StructuredAgentSessionProviderChildIdentity | null } + | { + ok: true + awaited: StructuredAgentSessionProviderChildIdentity | null + readiness: StructuredAgentSessionStartReadiness | null + } /** A failed start before it is worded; `fail` words it once, through the one wording point. */ type StartFailure = { startKey: string | null; cause: StructuredAgentSessionStartFailureCause } @@ -138,6 +147,9 @@ export class StructuredAgentSessionDeliveryLoop { ) return } + if (prepared.readiness) { + await prepared.readiness.wait() + } const handed = await this.deps.serialize(sessionId, () => this.handOver(sessionId, prepared.awaited) ) @@ -209,7 +221,8 @@ export class StructuredAgentSessionDeliveryLoop { // The child this run waits on; handover checks it is still the one there. return { ok: true, - awaited: child ? { generation: child.generation, fence: child.fence } : null + awaited: child ? { generation: child.generation, fence: child.fence } : null, + readiness: child?.phase === 'starting' ? this.deps.readiness(sessionId, child) : null } } @@ -247,9 +260,7 @@ export class StructuredAgentSessionDeliveryLoop { // Never steer into a turn a person's Stop is ending: the message runs after it, as its own // turn, and the turn's end is a commit that wakes the loop again. Read here, at the handover, // because a Stop can take the lane between the step that judged the send and this one. - // Nor hand anything to a child still proving its start: nothing is written until it is - // `ready`, whose settlement wakes the loop, so a start that fails leaves the message unsent. - if (this.deps.stopping(sessionId) || awaitedChild.phase === 'starting') { + if (this.deps.stopping(sessionId) || awaitedChild.phase !== 'ready') { return this.stop(sessionId) } // None while a turn is still opening: joining it would record the message ahead of it. 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 9791d4afda22..5e987b4b126a 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 @@ -15,9 +15,11 @@ import type { StructuredAgentSessionClientDelivery } from './structured-agent-se import { StructuredAgentSessionDeliveryLoop } from './structured-agent-session-delivery-loop' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' +import type { StructuredAgentSessionStartReadiness } from './structured-agent-session-startup-attempt' import type { StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' @@ -58,6 +60,10 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessionId: string, startedFor: string ) => Promise + readiness: ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity + ) => StructuredAgentSessionStartReadiness | null /** Stops a child the chat outgrew, so the start after it reads a new login or permission mode. */ stopOutgrownAgent: (sessionId: string) => Promise clientDelivery: Pick< @@ -96,6 +102,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { }) return input.ensureProviderChild(sessionId, startedFor) }, + readiness: input.readiness, conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), holdClosed: async (sessionId, which) => { 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 17f9091162ba..8dc39a62d567 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 @@ -22,11 +22,11 @@ import type { AgentSessionPromptRequest } from './structured-agent-session-turns import { refuseThreadGoalUnsupported, threadGoalPlan } from './structured-agent-session-thread-goal' import { mutateStructuredAgentSession, + runStructuredAgentSessionMutation, type StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import { openForProviderWrite, - openWithAgent, sendPreparation, structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' @@ -42,7 +42,11 @@ 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 { + runWithReadyStructuredAgentSessionOperation, + openWithReadyStructuredAgentSessionChild, + runAfterRewindStart +} from './structured-agent-session-ready-operation' 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' @@ -75,8 +79,8 @@ export function sendStructuredAgentSessionTurn( arrival?: Parameters[2] ): Promise> { const plan = sendPlan(params) - return runAfterProviderStart(context, params.envelope.sessionId, () => - mutateStructuredAgentSession( + return runAfterRewindStart(context, caller.callerKey, params.envelope, plan, (selection) => + runStructuredAgentSessionMutation( context, caller, params.envelope, @@ -92,7 +96,7 @@ export function sendStructuredAgentSessionTurn( (await plan.run(ctx)) ) }, - sendPreparation(context, params.envelope, arrival) + sendPreparation(context, params.envelope, arrival, selection) ) ) } @@ -222,22 +226,29 @@ export async function setStructuredAgentSessionOption( ) } -export function changeStructuredAgentSessionThreadGoal( +export async function changeStructuredAgentSessionThreadGoal( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope; change: AgentSessionThreadGoalChange } ): Promise> { - 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) => + const plan = threadGoalPlan(params) + return runWithReadyStructuredAgentSessionOperation( + context, + caller.callerKey, + params.envelope, + plan, + (selection) => + runStructuredAgentSessionMutation( + context, + caller, + params.envelope, + plan, + openWithReadyStructuredAgentSessionChild(context, params.envelope, selection) + ), + { + beforeAgent: (record) => refuseThreadGoalUnsupported(context.deps.adapter, context.deps.agents, record?.provider) - ) - ) + } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index e127af5040dc..0e183d32a657 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 @@ -123,6 +123,8 @@ export class StructuredAgentSessionHost { trackStart: (start) => this.tasks.trackAttach(start), ensureProviderChild: (sessionId, startedFor) => agentStart.ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), + readiness: (sessionId, child) => + this.runtimeState.startupAttempts.readiness(sessionId, child), // Put to rest like an idle agent: the stop writes nothing, and the next start is new. stopOutgrownAgent: (id) => this.lifetime.stopAgent(id, { cause: 'evict', resting: true }), clientDelivery: this.clientDelivery @@ -282,6 +284,7 @@ export class StructuredAgentSessionHost { openConversation: this.conversationDelivery.open, ensureAgent: (sessionId) => agentStart.ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), + startupAttempts: this.runtimeState.startupAttempts, joinChildClose: (sessionId) => agentStart.joinClosingStructuredAgentSessionChild(this.attachContext(), sessionId), wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts index 99120dcc23b5..6422b4b8437e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts @@ -195,29 +195,48 @@ it('logs a wake error and still answers accepted', async () => { ) }) -it('logs a receipt read error and refuses unknown without accepting or waking', async () => { - const ctx = context() - const request = params() - const error = new Error('receipt read failed') - const warn = vi.spyOn(ctx.deps.logger, 'warn') - vi.spyOn(rig.store, 'readCommandReceipt').mockImplementationOnce(() => { - throw error - }) - expect(await sendStructuredAgentSessionTurn(ctx, CALLER, request)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_unknown', details: { reason: 'outcomeUnknown' } } - }) - expect(warn).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - scope: 'command-receipt-read', - operationId: request.envelope.clientOperationId, - error +it.each([false, true])( + 'logs a receipt read error and refuses unknown without accepting or waking (rewind: %s)', + async (rewinding) => { + const ctx = context() + const request = params() + if (rewinding) { + await rig.store.transitionHandoff(SESSION, (record) => ({ + ...record, + rewind: { + operationId: hostTestOperationId(), + callerKey: CALLER.callerKey, + itemId: 'prior-message', + providerItemId: 'prior-provider-item', + expectedEpoch: rig.host['sessions'].get(SESSION)!.journal.cursor().epoch, + phase: 'prepared', + retained: [] + } + })) + } + const ensureAgent = vi.spyOn(ctx, 'ensureAgent') + const error = new Error('receipt read failed') + const warn = vi.spyOn(ctx.deps.logger, 'warn') + vi.spyOn(rig.store, 'readCommandReceipt').mockImplementationOnce(() => { + throw error }) - ) - expect(rig.host['sessions'].get(SESSION)?.journal.submissions()).toHaveLength(0) - expect(ctx.wakeDelivery).not.toHaveBeenCalled() -}) + expect(await sendStructuredAgentSessionTurn(ctx, CALLER, request)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown', details: { reason: 'outcomeUnknown' } } + }) + expect(warn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + scope: 'command-receipt-read', + operationId: request.envelope.clientOperationId, + error + }) + ) + expect(rig.host['sessions'].get(SESSION)?.journal.submissions()).toHaveLength(0) + expect(ctx.wakeDelivery).not.toHaveBeenCalled() + expect(ensureAgent).not.toHaveBeenCalled() + } +) it('wakes once for /compact and never for its replay', async () => { const ctx = context() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index f9b83c3b4f3a..8a077cbfe2dc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -15,6 +15,7 @@ import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import type { StructuredAgentSessionOptionRevisions } from './structured-agent-session-option-revisions' +import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' import type { StructuredAgentSessionCaller, StructuredAgentSessionHostDeps, @@ -34,6 +35,7 @@ export type StructuredAgentSessionMutationContext = { openConversation: (sessionId: string) => Promise /** Gives the session a provider child; inside the caller's serialize. */ ensureAgent: (sessionId: string) => Promise + startupAttempts: Pick /** Joins a close a stop began on the session's child, for an operation that starts no child; * inside the caller's serialize. */ joinChildClose: (sessionId: string) => Promise @@ -63,19 +65,30 @@ export function mutateStructuredAgentSession( prepareSession?: AgentSessionMutationRequest['prepareSession'] ): Promise> { return context.serialize(envelope.sessionId, () => - admitAndRunAgentSessionMutation({ - store: context.deps.store, - adapter: context.deps.adapter, - agents: context.deps.agents, - logger: context.deps.logger, - callerKey: caller.callerKey, - envelope, - plan, - journal: () => context.sessions.get(envelope.sessionId)?.journal, - prepareSession, - publish: (journal) => context.publish(envelope.sessionId, journal), - wakeDelivery: (sessionId) => context.wakeDelivery(sessionId), - now: () => context.now() - }) + runStructuredAgentSessionMutation(context, caller, envelope, plan, prepareSession) ) } + +/** Admission for a caller already inside the session's queue. */ +export function runStructuredAgentSessionMutation( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + envelope: AgentSessionMutationEnvelope, + plan: MutationPlan, + prepareSession?: AgentSessionMutationRequest['prepareSession'] +): Promise> { + return admitAndRunAgentSessionMutation({ + store: context.deps.store, + adapter: context.deps.adapter, + agents: context.deps.agents, + logger: context.deps.logger, + callerKey: caller.callerKey, + envelope, + plan, + journal: () => context.sessions.get(envelope.sessionId)?.journal, + prepareSession, + publish: (journal) => context.publish(envelope.sessionId, journal), + wakeDelivery: (sessionId) => context.wakeDelivery(sessionId), + now: () => context.now() + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index 6d188fb3b385..1ad43f3144de 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -175,24 +175,19 @@ function waitOutSeveralSweeps(): Promise { return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) } -function deliverySettled(): Promise { - return vi.waitFor(() => - expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) - ) -} - describe('a Claude chat whose CLI is still starting', () => { it('holds a message until startup lands, and keeps the agent inside the idle window meanwhile', async () => { await attachStarting() const sent = await send('sent while starting') // Held, never written, while initialize is unanswered. - await deliverySettled() expect(claude.connections[0].sent).toEqual([]) expect(await dispatchState(sent)).toBe('pending') clock += IDLE_MS - 1 await waitOutSeveralSweeps() + expect(claude.connections[0].sent).toEqual([]) + expect(await dispatchState(sent)).toBe('pending') expect(host.hasSession(SESSION)).toBe(true) expect(claude.connections[0].closeCount).toBe(0) @@ -212,10 +207,12 @@ describe('a Claude chat whose CLI is still starting', () => { connection.sent.push(message) } const held = await send('sent while starting') - await deliverySettled() expect(connection.sent).toEqual([]) + expect(await dispatchState(held)).toBe('pending') clock += IDLE_MS - 1 await waitOutSeveralSweeps() + expect(connection.sent).toEqual([]) + expect(await dispatchState(held)).toBe('pending') expect(host.hasSession(SESSION)).toBe(true) // Startup lands just before the window closes and the message is handed over; the agent stays 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 6811a9575227..fefe23d1412f 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 @@ -595,8 +595,11 @@ describe('another child indexed after a Stop ended the starting one', () => { const replacement = currentChild() expect(replacement.acquisitionGeneration).not.toBe(stopped.acquisitionGeneration) const secondId = await accept('second') - await settleLoop() expect((await submission(secondId))?.handedOverAt).toBeUndefined() + expect(dispatch).not.toHaveBeenCalled() + await host.flushStreamedEvents(SESSION) + expect((await submission(secondId))?.handedOverAt).toBeUndefined() + expect(dispatch).not.toHaveBeenCalled() await prove() 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 deleted file mode 100644 index adc87fa88d25..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-start-wait.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -// 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 e123109b4d23..978d65d055bf 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,81 +29,14 @@ 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 @@ -111,7 +44,6 @@ export function markProviderChildStarted( const child = matchingChild(session, identity) if (child) { child.phase = 'ready' - settleStartWait(child, 'ready') } return child !== null } @@ -159,7 +91,6 @@ 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-open-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts new file mode 100644 index 000000000000..3e60c6ee17a1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts @@ -0,0 +1,24 @@ +import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' +import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' +import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' + +export type StructuredAgentSessionProviderOpenInput = { + sessionId: string + fence: number + acquisitionGeneration: string | null + signal: AbortSignal +} + +/** Protocol facts returned to the host that awaits this child's open. */ +export type StructuredAgentSessionProviderOpenResult = { + link?: AgentSessionProviderHandleLink + reportedOptions: AgentSessionOptionsResult['current'] + restoreSkippedOptions: readonly string[] + retiredOptions?: Readonly> + catalogListing?: AgentModelCatalogLiveListing + optionRevision: number +} + +export type StructuredAgentSessionProviderOpen = ( + input: StructuredAgentSessionProviderOpenInput +) => Promise 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 deleted file mode 100644 index 425d4e64766d..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-start-hold.ts +++ /dev/null @@ -1,90 +0,0 @@ -// 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-ready-operation-order.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts new file mode 100644 index 000000000000..cbf4f840191f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts @@ -0,0 +1,113 @@ +import { expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + CALLER, + attach, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' + +it.each(['send', 'compact'] as const)( + 'answers an accepted %s receipt during an unconfirmed rewind without starting an agent', + async (command) => { + const { host, store, acquire, dispatch } = hostTestState() + const compact = vi.fn(async () => ({ state: 'admitted' as const })) + host.deps.adapter.compact = compact + const providerWrites = command === 'send' ? dispatch : compact + await attach() + const body = hostTestMessage('already accepted') + const params = { + envelope: envelope( + command === 'send' ? 'agentSession.send' : 'agentSession.conversationCommand', + command === 'send' ? { body } : { command: 'compact' } + ), + body, + command: 'compact' as const + } + const run = (caller = CALLER) => + command === 'send' ? host.send(caller, params) : host.conversationCommand(caller, params) + expect(await run()).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(providerWrites).toHaveBeenCalledTimes(1)) + await host.close(SESSION, 'evict') + const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + rewind: { + operationId: envelope('agentSession.rewind', {}).clientOperationId, + callerKey: CALLER.callerKey, + itemId: agentJournalSubmissionKey(params.envelope.clientOperationId), + providerItemId: 'codex:thread-1:turn-1:1', + expectedEpoch, + phase: 'prepared', + retained: [] + } + })) + acquire.mockRejectedValueOnce(new Error('restart failed')) + + expect(await run({ callerKey: 'reconnecting-phone' })).toMatchObject({ + ok: true, + replayed: true + }) + expect(acquire).toHaveBeenCalledTimes(1) + expect(providerWrites).toHaveBeenCalledTimes(1) + } +) + +it.each(['goal', 'rewind'] as const)( + 'runs a %s on a ready child before a later send is accepted', + async (operation) => { + const { host, dispatch } = hostTestState() + await attach() + const body = hostTestMessage('first turn') + const first = { envelope: envelope('agentSession.send', { body }), body } + expect(await host.send(CALLER, first)).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) + await host.flushStreamedEvents(SESSION) + const order: string[] = [] + host.deps.adapter.changeThreadGoal = vi.fn(async () => { + order.push('goal') + return { ok: true as const } + }) + host.deps.adapter.rewindSupport = () => ({ supported: true }) + host.deps.adapter.rewind = vi.fn(async () => { + order.push('rewind') + return { ok: false as const, reason: 'history-not-paginated' as const } + }) + const itemId = agentJournalSubmissionKey(first.envelope.clientOperationId) + const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch + const change = { kind: 'set' as const, objective: 'Apply before the message' } + const blocked = Promise.withResolvers() + void host.collaboratorsForTests().serialize(SESSION, () => blocked.promise) + const requested = + operation === 'goal' + ? host.changeThreadGoal(CALLER, { + envelope: envelope('agentSession.threadGoal', { change }), + change + }) + : host.rewind(CALLER, { + envelope: envelope('agentSession.rewind', { itemId, expectedEpoch }), + itemId, + expectedEpoch + }) + const laterBody = hostTestMessage('later message') + const sent = host.send(CALLER, { + envelope: envelope('agentSession.send', { body: laterBody }), + body: laterBody, + beforeRun: () => order.push('send') + }) + blocked.resolve() + + const result = await requested + expect(result).toMatchObject( + operation === 'goal' + ? { ok: true } + : { ok: false, refusal: { rewindReason: 'history-not-paginated' } } + ) + expect(await sent).toMatchObject({ ok: true }) + expect(order).toEqual([operation, 'send']) + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts new file mode 100644 index 000000000000..77e8a994a914 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts @@ -0,0 +1,237 @@ +import { + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from '../../../shared/agent-session-mutation-envelope' +import { refuse, refuseUnclassified } from '../../../shared/agent-session-wire-refusals' +import type { + AgentSessionMutationEnvelope, + AgentSessionMutationResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' +import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' +import { sameProviderChild } from './structured-agent-session-provider-child' +import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from './structured-agent-session-send-settlement' +import { + openForWrite, + openForProviderWrite, + rewindInDoubt +} from './structured-agent-session-send-preparation' +import { agentSessionOperationOutcomeUnknown } from './structured-agent-session-replay-outcome' +import type { AgentSessionMutationSessionPreparation } from './structured-agent-session-mutation-admission' +import type { MutationPlan } from './structured-agent-session-mutation-plans' +import { commandReceiptScope } from '../agent-session-journal/command-receipt-schema' +import { parseAgentSessionOperationTimestamp } from '../../../shared/agent-session-host-authority' +import type { StructuredAgentSessionStartReadiness } from './structured-agent-session-startup-attempt' + +type ReadyOperationPlan = Pick< + MutationPlan, + 'method' | 'fields' | 'operationIdScope' | 'acceptsWithCommandReceipt' +> +type QueuedReadyOperation = + | { result: AgentSessionMutationResult } + | { + child: StructuredAgentSessionProviderChildIdentity + readiness: StructuredAgentSessionStartReadiness + } + +export type ReadyOperationChild = StructuredAgentSessionProviderChildIdentity | null +export type ReadyOperationSelection = { + ok: true + child: ReadyOperationChild + replay?: true + deadline?: number +} + +/** A send or conversation command waits only when an earlier rewind needs its provider. */ +export async function runAfterRewindStart( + context: StructuredAgentSessionMutationContext, + callerKey: string, + envelope: AgentSessionMutationEnvelope, + plan: ReadyOperationPlan, + run: (selection: ReadyOperationSelection) => Promise> +): Promise> { + return runWithReadyStructuredAgentSessionOperation(context, callerKey, envelope, plan, run, { + needsAgent: rewindInDoubt + }) +} + +export function operationDidNotRun(expired = false): { + ok: false + refusal: AgentSessionWireRefusal +} { + return { + ok: false, + refusal: expired + ? refuse( + 'agent_session_operation_expired', + { reason: 'operationExpired' }, + 'The operation did not run because its wait expired. Try again.' + ) + : refuseUnclassified( + 'agent_session_owner_restart_failed', + 'The operation did not run because the agent is not ready.' + ) + } +} + +/** A ready child runs in this queue turn; only a starting child releases it to wait. */ +export async function runWithReadyStructuredAgentSessionOperation( + context: StructuredAgentSessionMutationContext, + callerKey: string, + envelope: AgentSessionMutationEnvelope, + plan: ReadyOperationPlan, + run: (selection: ReadyOperationSelection) => Promise>, + options: { + beforeAgent?: ( + record: AgentSessionRecord | null + ) => AgentSessionMutationSessionPreparation | null + needsAgent?: (record: AgentSessionRecord | null) => boolean + } = {} +): Promise> { + const hostFingerprint = computeAgentSessionPayloadFingerprint({ + method: plan.method, + sessionId: envelope.sessionId, + fields: plan.fields + }) + const conflict = agentSessionFingerprintConflict(envelope, hostFingerprint) + if (conflict) { + return { ok: false, refusal: conflict } + } + const deadline = Date.now() + STRUCTURED_AGENT_SESSION_START_WAIT_MS + const selected = await context.serialize>( + envelope.sessionId, + async () => { + const record = context.deps.store.getRecord(envelope.sessionId) + if (options.needsAgent && !options.needsAgent(record)) { + return { result: await run({ ok: true, child: null }) } + } + if (plan.acceptsWithCommandReceipt) { + if (parseAgentSessionOperationTimestamp(envelope.clientOperationId) === null) { + return { result: await run({ ok: true, child: null }) } + } + // Receipt admission owns replay and unreadable-result answers, before any start. + let recorded: boolean + try { + recorded = + context.deps.store.readCommandReceipt( + commandReceiptScope(callerKey, plan.operationIdScope), + envelope.clientOperationId + ).verdict !== 'absent' + } catch (error) { + context.deps.logger.warn('reading a command receipt failed', { + scope: 'command-receipt-read', + sessionId: envelope.sessionId, + operationId: envelope.clientOperationId, + error + }) + return { + result: { + ok: false as const, + refusal: agentSessionOperationOutcomeUnknown(envelope.clientOperationId) + } + } + } + if (recorded) { + return { result: await run({ ok: true, child: null }) } + } + } else { + const evaluated = context.deps.store.evaluateMutationOperation({ + callerKey, + envelope, + hostFingerprint, + now: context.now(), + ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}) + }) + if (!evaluated || evaluated.decision.decision !== 'admit') { + return { + result: await run({ + ok: true, + child: null, + ...(evaluated?.decision.decision === 'replay' ? { replay: true as const } : {}) + }) + } + } + } + const select = async () => { + const opened = await openForWrite(context, envelope)() + if (!opened.ok) { + return opened + } + const checked = options.beforeAgent?.(context.deps.store.getRecord(envelope.sessionId)) + if (checked && !checked.ok) { + return checked + } + const ensured = await context.ensureAgent(envelope.sessionId) + if (!ensured.ok) { + return ensured + } + const child = context.sessions.get(envelope.sessionId)?.child + if (!child || child.close) { + return operationDidNotRun() + } + const identity = { generation: child.generation, fence: child.fence } + if (child.phase === 'ready') { + return { ok: true as const, child: identity, deadline } + } + const readiness = context.startupAttempts.readiness(envelope.sessionId, identity) + return readiness ? { ok: true as const, child: identity, readiness } : operationDidNotRun() + } + const child = await select().catch((error: unknown) => { + context.deps.logger.warn('selecting an agent for an operation failed', { + scope: 'operation-agent-start', + sessionId: envelope.sessionId, + error + }) + return operationDidNotRun() + }) + if (!child.ok) { + return { result: child } + } + if ('readiness' in child && child.readiness) { + return { child: child.child, readiness: child.readiness } + } + return { result: await run(child) } + } + ) + if ('result' in selected) { + return selected.result + } + const outcome = await selected.readiness.wait(deadline - Date.now()) + if (outcome !== 'ready') { + return operationDidNotRun(outcome === 'timeout') + } + if (Date.now() >= deadline) { + return operationDidNotRun(true) + } + return context.serialize(envelope.sessionId, () => + run({ ok: true, child: selected.child, deadline }) + ) +} + +/** Replay uses the ledger answer; a new operation uses only its selected ready child. */ +export function openWithReadyStructuredAgentSessionChild( + context: StructuredAgentSessionMutationContext, + envelope: AgentSessionMutationEnvelope, + selection: ReadyOperationSelection +): (ledger: 'admit' | 'replay') => Promise { + return async (ledger) => { + const opened = await openForProviderWrite(context, envelope)() + if (!opened.ok || ledger === 'replay') { + return opened + } + if (!selection.child) { + return selection.replay + ? { ok: false, refusal: agentSessionOperationOutcomeUnknown(envelope.clientOperationId) } + : operationDidNotRun() + } + if (selection.deadline !== undefined && Date.now() >= selection.deadline) { + return operationDidNotRun(true) + } + const child = context.sessions.get(envelope.sessionId)?.child + return child?.phase === 'ready' && !child.close && sameProviderChild(child, selection.child) + ? opened + : operationDidNotRun() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index 8c92ddd51246..18f54eba1b4a 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 @@ -15,7 +15,11 @@ import type { import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -import { openWithAgent } from './structured-agent-session-send-preparation' +import { + runWithReadyStructuredAgentSessionOperation, + openWithReadyStructuredAgentSessionChild, + type ReadyOperationSelection +} from './structured-agent-session-ready-operation' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' @@ -24,21 +28,9 @@ import { rewindRefusal, rewindRefusalBehindClear } from './structured-rewind-ref 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 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) - ) -} +import { rewindInDoubt } from './structured-agent-session-send-preparation' -function rewindUnderSerialize( +export async function rewindStructuredAgentSession( context: StructuredAgentSessionMutationContext, attachContext: StructuredAgentSessionAttachContext, caller: StructuredAgentSessionCaller, @@ -46,7 +38,9 @@ function rewindUnderSerialize( ): Promise> { const { sessionId, clientOperationId } = params.envelope const store = context.deps.store - return context.serialize(sessionId, async () => { + const fields = { itemId: params.itemId, expectedEpoch: params.expectedEpoch } + const operation = { method: 'agentSession.rewind', fields } + const run = async (selection: ReadyOperationSelection) => { let completionReceipt: JournalOperationReceipt | undefined const result = await admitAndRunAgentSessionMutation({ store, @@ -56,15 +50,12 @@ function rewindUnderSerialize( 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, () => - rewindRefusalBehindClear(context.sessions.get(sessionId)?.journal, params) - ), + prepareSession: openWithReadyStructuredAgentSessionChild(context, params.envelope, selection), journal: () => context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), now: context.now, plan: { - method: 'agentSession.rewind', - fields: { itemId: params.itemId, expectedEpoch: params.expectedEpoch }, + ...operation, recoverUnknownFromDurableState: true, settledOutcome: (rewind: AgentSessionRewindResult) => ({ status: 'succeeded' as const, @@ -99,10 +90,7 @@ function rewindUnderSerialize( if (!support?.supported) { return rewindRefusal(support?.reason ?? 'unsupported') } - if ( - record.rewind?.phase === 'prepared' || - record.rewind?.phase === 'provider-succeeded' - ) { + if (rewindInDoubt(record)) { return rewindRefusal('outcome-unknown') } if (conversationCommandBlocked(ctx, record, context.readChildWork(sessionId))) { @@ -301,5 +289,15 @@ function rewindUnderSerialize( cursor: context.sessions.get(sessionId)!.journal.cursor() } : result - }) + } + return runWithReadyStructuredAgentSessionOperation( + context, + caller.callerKey, + params.envelope, + operation, + run, + { + beforeAgent: () => rewindRefusalBehindClear(context.sessions.get(sessionId)?.journal, params) + } + ) } 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 78e430dfa7fc..3658b3df146d 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 @@ -18,6 +18,10 @@ import { type StructuredAgentSessionAwaitedCommandJournal } from './structured-agent-session-command-turn' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { + operationDidNotRun, + type ReadyOperationSelection +} from './structured-agent-session-ready-operation' import { AGENT_SESSION_NOT_ATTACHED, type AgentSessionMutationSessionPreparation @@ -25,13 +29,15 @@ 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 { + sameProviderChild, + structuredAgentSessionConversationFence +} from './structured-agent-session-provider-child' import { conversationCommandInFlight } from './structured-conversation-command-admission' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' -function rewindInDoubt(record: AgentSessionRecord | null | undefined): boolean { +export function rewindInDoubt(record: AgentSessionRecord | null | undefined): boolean { const phase = record?.rewind?.phase return phase === 'prepared' || phase === 'provider-succeeded' } @@ -97,37 +103,6 @@ export function openForProviderWrite( } } -/** 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< - StructuredAgentSessionMutationContext, - 'openConversation' | 'ensureAgent' | 'deps' | 'sessions' - >, - envelope: AgentSessionMutationEnvelope, - beforeAgent?: (record: AgentSessionRecord | null) => AgentSessionMutationSessionPreparation | null -): (ledger: 'admit' | 'replay') => Promise { - return async (ledger) => { - const opened = await openConversationForWrite( - context.openConversation, - envelope, - context.deps.logger - ) - if (!opened.ok || ledger === 'replay') { - return opened - } - 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 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( @@ -160,7 +135,7 @@ 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 - * proven start recovers it while the send waits that start out (`runAfterProviderStart`), and a + * proven start recovers it before the send is accepted, 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, @@ -171,10 +146,11 @@ async function recoverRewindOnLiveChild( export function sendPreparation( context: Pick< StructuredAgentSessionMutationContext, - 'openConversation' | 'ensureAgent' | 'deps' | 'sessions' | 'publish' | 'now' + 'openConversation' | 'deps' | 'sessions' | 'publish' | 'now' >, envelope: AgentSessionMutationEnvelope, - arrival: { clearInFlight?: boolean; refusesInRun?: boolean } = {} + arrival: { clearInFlight?: boolean; refusesInRun?: boolean } = {}, + selection?: ReadyOperationSelection ): (ledger: 'admit' | 'replay') => Promise { return async (ledger) => { if (ledger === 'admit' && arrival.clearInFlight) { @@ -190,22 +166,27 @@ export function sendPreparation( ? opened : { ok: false, refusal: agentSessionOperationOutcomeUnknown(envelope.clientOperationId) } } + if (selection?.deadline !== undefined && Date.now() >= selection.deadline) { + return operationDidNotRun(true) + } const { sessionId } = envelope if (!opened.ok || !rewindInDoubt(context.deps.store.getRecord(sessionId))) { return opened } - const ensured = await context.ensureAgent(sessionId) - if (!ensured.ok) { - return ensured - } - const starting = refuseWhileProviderStarting(context.sessions.get(sessionId)) - if (starting) { - return starting + const child = context.sessions.get(sessionId)?.child + if ( + !selection?.child || + !child || + child.phase !== 'ready' || + child.close || + !sameProviderChild(child, selection.child) + ) { + return rewindRefusal('outcome-unknown') } await recoverRewindOnLiveChild(context, sessionId) return !arrival.refusesInRun && rewindInDoubt(context.deps.store.getRecord(sessionId)) ? rewindRefusal('outcome-unknown') - : ensured + : opened } } 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 48af8306b102..a3fd1745f034 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 @@ -78,9 +78,7 @@ async function send( value: { submission: { dispatchState: 'pending', handoverRecorded: true } } }) const clientMessageId = sent.ok ? sent.value.clientMessageId : '' - await eventually(() => - expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) - ) + expect(await submission(clientMessageId)).toMatchObject({ dispatchState: 'pending' }) return clientMessageId } @@ -198,7 +196,7 @@ describe('a send into a published session whose child ended before startup', () const first = await send('hello again', releasedFence) // Accepted at the lost owner's fence, held for the child delivery started. - expect(acquire).toHaveBeenCalledTimes(2) + await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') expect(dispatch).not.toHaveBeenCalled() const current = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -226,6 +224,7 @@ describe('a send into a published session whose child ended before startup', () const rowsBefore = (await journalStatuses()).length const held = await send('still not signed in', releasedFence) + await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) await exitBeforeProof() // The child never proved its start, so it was handed nothing: the message is rejected, so @@ -261,7 +260,7 @@ describe('a send into a published session whose child ended before startup', () // Retry is a fresh action: it restarts once and is handed to the new child once it proves itself. const retry = await send('signed in now') - expect(acquire).toHaveBeenCalledTimes(3) + await eventually(() => expect(acquire).toHaveBeenCalledTimes(3)) await proveStarted() await handedOver(retry) expect(dispatch).toHaveBeenCalledOnce() 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..661e0044b8eb 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 @@ -63,6 +63,55 @@ afterEach(() => { }) describe('the startup clock', () => { + it('settles a selected caller with readiness and does not retain a completed attempt', async () => { + const attempt = mint() + attempts.track(SESSION, attempt).spawned() + attempts.published(SESSION, attempt.attemptId, CHILD) + const readiness = attempts.readiness(SESSION, CHILD) + expect(readiness).not.toBeNull() + + const waiting = readiness!.wait(120_000) + attempts.ready(SESSION, CHILD) + + await expect(waiting).resolves.toBe('ready') + expect(attempts.readiness(SESSION, CHILD)).toBeNull() + expect(vi.getTimerCount()).toBe(0) + }) + + it('ends a selected caller on child exit, expiry, or disposal', async () => { + for (const end of ['exit', 'expiry', 'dispose'] as const) { + const attempt = mint() + attempts.track(SESSION, attempt).spawned() + attempts.published(SESSION, attempt.attemptId, CHILD) + const waiting = attempts.readiness(SESSION, CHILD)!.wait(120_000) + if (end === 'exit') { + attempts.childEnded(SESSION, CHILD) + } + if (end === 'expiry') { + vi.advanceTimersByTime(SILENCE_MS) + } + if (end === 'dispose') { + attempts.dispose() + } + await expect(waiting).resolves.toBe('ended') + } + }) + + it('ends only the caller wait at its budget, leaving the attempt able to start', async () => { + const attempt = mint() + attempts.track(SESSION, attempt).spawned() + attempts.published(SESSION, attempt.attemptId, CHILD) + const waiting = attempts.readiness(SESSION, CHILD)!.wait(500) + const reattached = attempts.readiness(SESSION, { ...CHILD })!.wait(120_000) + + vi.advanceTimersByTime(500) + await expect(waiting).resolves.toBe('timeout') + expect(attempts.isOpen(SESSION)).toBe(true) + attempts.ready(SESSION, CHILD) + await expect(reattached).resolves.toBe('ready') + expect(attempts.isOpen(SESSION)).toBe(false) + }) + it('does not run before the spawn: resolving a launch is not starting', () => { attempts.track(SESSION, mint()) 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 58b9e7d700cc..571c52017fcc 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 @@ -97,6 +97,11 @@ type Tracked = { /** The limit's measure, held until an acquire it aborted settles: one that is ready anyway * reports that instead. */ expiry: StructuredAgentSessionStartupSettled | null + readiness: PromiseWithResolvers<'ready' | 'ended'> +} + +export type StructuredAgentSessionStartReadiness = { + wait: (budgetMs?: number) => Promise<'ready' | 'ended' | 'timeout'> } /** Each session's open attempt and its startup clock. A session starts one child at a time, so a @@ -134,7 +139,8 @@ export class StructuredAgentSessionStartupAttempts { lastOutputAt: 0, timer: null, expired: false, - expiry: null + expiry: null, + readiness: Promise.withResolvers() } this.open.set(sessionId, tracked) const current = (): boolean => this.open.get(sessionId) === tracked && !this.disposed @@ -158,6 +164,34 @@ export class StructuredAgentSessionStartupAttempts { } } + /** Capture the attempt while the selected child still holds the session's queue. */ + readiness( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity + ): StructuredAgentSessionStartReadiness | null { + const tracked = this.open.get(sessionId) + if (!tracked?.child || !sameProviderChild(tracked.child, child)) { + return null + } + return { + wait: async (budgetMs) => { + if (budgetMs === undefined) { + return tracked.readiness.promise + } + let timer: ReturnType | undefined + const timeout = new Promise<'timeout'>((resolve) => { + timer = setTimeout(() => resolve('timeout'), Math.max(0, budgetMs)) + timer.unref?.() + }) + try { + return await Promise.race([tracked.readiness.promise, timeout]) + } finally { + clearTimeout(timer) + } + } + } + } + /** 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( @@ -229,6 +263,7 @@ export class StructuredAgentSessionStartupAttempts { dispose(): void { this.disposed = true for (const tracked of this.open.values()) { + tracked.readiness.resolve('ended') if (tracked.timer) { clearTimeout(tracked.timer) } @@ -293,6 +328,7 @@ export class StructuredAgentSessionStartupAttempts { } private settle(sessionId: string, tracked: Tracked, outcome: 'ready' | 'ended'): void { + tracked.readiness.resolve(outcome) this.report( tracked.expired && outcome === 'ended' ? tracked.expiry : this.measure(tracked, outcome) ) @@ -325,6 +361,7 @@ export class StructuredAgentSessionStartupAttempts { private end(sessionId: string): void { const tracked = this.open.get(sessionId) + tracked?.readiness.resolve('ended') if (tracked?.timer) { clearTimeout(tracked.timer) } 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 44c5a3d97b1a..64dc2cd0f0f1 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,7 +10,10 @@ 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 { + runAfterRewindStart, + type ReadyOperationSelection +} from './structured-agent-session-ready-operation' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { conversationCommandBlocked } from './structured-conversation-command-admission' import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -51,16 +54,20 @@ export function runStructuredConversationCommand( 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) + return runAfterRewindStart( + context, + caller.callerKey, + params.envelope, + { method: 'agentSession.conversationCommand', fields: { command: params.command } }, + (selection) => clearUnderSerialize(context, caller, params, selection) ) } -function clearUnderSerialize( +async function clearUnderSerialize( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, - params: ConversationCommandParams + params: ConversationCommandParams, + selection: ReadyOperationSelection ): Promise> { const { envelope, command } = params const { sessionId, clientOperationId } = envelope @@ -71,94 +78,92 @@ function clearUnderSerialize( ? record : null } - return context.serialize(sessionId, async () => { - let clear: AgentSessionConversationClear | null = null - const result = await admitAndRunAgentSessionMutation({ - store, - adapter: context.deps.adapter, - agents: context.deps.agents, - logger: context.deps.logger, - callerKey: caller.callerKey, - envelope, - // Starts the agent only to settle a rewind in doubt, as a send does; a /clear itself starts nothing. - prepareSession: sendPreparation(context, envelope, { refusesInRun: true }), - journal: () => context.sessions.get(sessionId)?.journal, - publish: (journal) => context.publish(sessionId, journal), - now: context.now, - plan: { - method: 'agentSession.conversationCommand', - fields: { command }, - // Written to the conversation, not the agent, so whoever owns the agent does not matter. - conversationWrite: true, - recoverUnknownFromDurableState: true, - settlesWithWrite: true, - successReceipt: () => - store.conversationReceipts.clear( - () => { - if (!clear) { - throw new Error('agent_session_clear_not_completed') - } - return clear - }, - { callerKey: caller.callerKey, operationId: clientOperationId } - ), - replay: (_ctx, outcome) => { - if (outcome.status === 'succeeded' && outcome.conversationCommand) { - return outcome.conversationCommand - } - const prior = matching() - return prior?.phase === 'committed' ? prior : null - }, - // The commit is the only write, so a clear with no committed answer changed nothing. - rerunWhenReplayMissing: () => true, - run: async (ctx) => { - const record = store.getRecord(sessionId)! - const blocked = conversationCommandBlocked( - ctx, - record, - context.readChildWork(sessionId), - context.sessions.get(sessionId)?.child ? undefined : 'at-rest' - ) - if (blocked) { - return { ok: false, refusal: blocked } - } - await context.stopAgent(sessionId, { cause: 'context-clear' }) - const stopped = store.getRecord(sessionId)! - const fence = stopped.lease.runtimeFence - const rechecked = conversationCommandBlocked( - { ...ctx, fence }, - stopped, - context.readChildWork(sessionId), - 'at-rest' - ) - if (rechecked) { - return { ok: false, refusal: rechecked } - } - const completed: AgentSessionConversationClear['command'] = { - command: 'clear', - runtimeFence: fence, - operationId: clientOperationId, - callerKey: caller.callerKey, - phase: 'committed', - state: 'completed' - } - clear = { sessionId, fence, command: completed, now: context.now() } - if (!ctx.operationReceipt) { - throw new Error('agent_session_clear_receipt_missing') - } - await ctx.journal.context.clear( - providerContextBoundaryForClear(clear), - ctx.operationReceipt, - agentSessionOperationKey(caller.callerKey, clientOperationId) - ) - return { ok: true, value: completed } + let clear: AgentSessionConversationClear | null = null + const result = await admitAndRunAgentSessionMutation({ + store, + adapter: context.deps.adapter, + agents: context.deps.agents, + logger: context.deps.logger, + callerKey: caller.callerKey, + envelope, + // Starts the agent only to settle a rewind in doubt, as a send does; a /clear itself starts nothing. + prepareSession: sendPreparation(context, envelope, { refusesInRun: true }, selection), + journal: () => context.sessions.get(sessionId)?.journal, + publish: (journal) => context.publish(sessionId, journal), + now: context.now, + plan: { + method: 'agentSession.conversationCommand', + fields: { command }, + // Written to the conversation, not the agent, so whoever owns the agent does not matter. + conversationWrite: true, + recoverUnknownFromDurableState: true, + settlesWithWrite: true, + successReceipt: () => + store.conversationReceipts.clear( + () => { + if (!clear) { + throw new Error('agent_session_clear_not_completed') + } + return clear + }, + { callerKey: caller.callerKey, operationId: clientOperationId } + ), + replay: (_ctx, outcome) => { + if (outcome.status === 'succeeded' && outcome.conversationCommand) { + return outcome.conversationCommand + } + const prior = matching() + return prior?.phase === 'committed' ? prior : null + }, + // The commit is the only write, so a clear with no committed answer changed nothing. + rerunWhenReplayMissing: () => true, + run: async (ctx) => { + const record = store.getRecord(sessionId)! + const blocked = conversationCommandBlocked( + ctx, + record, + context.readChildWork(sessionId), + context.sessions.get(sessionId)?.child ? undefined : 'at-rest' + ) + if (blocked) { + return { ok: false, refusal: blocked } + } + await context.stopAgent(sessionId, { cause: 'context-clear' }) + const stopped = store.getRecord(sessionId)! + const fence = stopped.lease.runtimeFence + const rechecked = conversationCommandBlocked( + { ...ctx, fence }, + stopped, + context.readChildWork(sessionId), + 'at-rest' + ) + if (rechecked) { + return { ok: false, refusal: rechecked } + } + const completed: AgentSessionConversationClear['command'] = { + command: 'clear', + runtimeFence: fence, + operationId: clientOperationId, + callerKey: caller.callerKey, + phase: 'committed', + state: 'completed' + } + clear = { sessionId, fence, command: completed, now: context.now() } + if (!ctx.operationReceipt) { + throw new Error('agent_session_clear_receipt_missing') } + await ctx.journal.context.clear( + providerContextBoundaryForClear(clear), + ctx.operationReceipt, + agentSessionOperationKey(caller.callerKey, clientOperationId) + ) + return { ok: true, value: completed } } - }) - return result.ok && - 'runtimeFence' in result.value && - typeof result.value.runtimeFence === 'number' - ? { ...result, fence: result.value.runtimeFence } - : result + } }) + return result.ok && + 'runtimeFence' in result.value && + typeof result.value.runtimeFence === 'number' + ? { ...result, fence: result.value.runtimeFence } + : result } 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 bae65871c531..9ae63858ca30 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 @@ -13,7 +13,7 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-qu import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' import type { StructuredAgentSessionHost } from './structured-agent-session-host' import { - mutateStructuredAgentSession, + runStructuredAgentSessionMutation, type StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' @@ -26,7 +26,10 @@ import { structuredAgentSessionFailureWordsContext, structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' -import { runAfterProviderStart } from './structured-agent-session-provider-start-hold' +import { + runAfterRewindStart, + type ReadyOperationSelection +} from './structured-agent-session-ready-operation' import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from './structured-agent-session-send-settlement' import { conversationCommandFailure, @@ -147,8 +150,8 @@ function acceptStructuredConversationCommand( ...(params.delivery ? { delivery: params.delivery } : {}), priorRecord }) - return runAfterProviderStart(context, params.envelope.sessionId, () => - acceptUnderSerialize(context, caller, params, plan, arrival) + return runAfterRewindStart(context, caller.callerKey, params.envelope, plan, (selection) => + acceptUnderSerialize(context, caller, params, plan, arrival, selection) ) } @@ -157,9 +160,10 @@ function acceptUnderSerialize( caller: StructuredAgentSessionCaller, params: ConversationCommandParams, plan: ReturnType, - arrival: { clearInFlight?: boolean } + arrival: { clearInFlight?: boolean }, + selection: ReadyOperationSelection ): Promise> { - return mutateStructuredAgentSession( + return runStructuredAgentSessionMutation( context, caller, params.envelope, @@ -206,6 +210,6 @@ function acceptUnderSerialize( return plan.run(ctx) } }, - sendPreparation(context, params.envelope, arrival) + sendPreparation(context, params.envelope, arrival, selection) ) } From 226436931a83fef68c8d622adaa7c9e1663f0faf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:42:07 -0700 Subject: [PATCH 07/55] Await provider opening in the execution host --- .../scripts/vitest-sqlite-runtime-files.mjs | 2 + ...structured-agent-session-adapter-router.ts | 8 ++ ...structured-agent-session-attach-context.ts | 7 +- ...ured-agent-session-attach-orchestration.ts | 2 + .../structured-agent-session-child-close.ts | 3 +- .../structured-agent-session-child-exit.ts | 4 +- ...red-agent-session-conversation-lifetime.ts | 44 ++++++++ ...tured-agent-session-ended-child-failure.ts | 2 +- ...structured-agent-session-event-recovery.ts | 16 +++ .../structured-agent-session-host-lifetime.ts | 1 + ...tructured-agent-session-host-stop-event.ts | 2 + .../structured-agent-session-host-types.ts | 1 + .../structured-agent-session-host.ts | 8 +- ...ctured-agent-session-provider-open.test.ts | 100 ++++++++++++++++++ .../structured-agent-session-provider-open.ts | 59 +++++++++++ ...tructured-agent-session-ready-operation.ts | 12 +-- ...tructured-agent-session-startup-attempt.ts | 2 +- 17 files changed, 258 insertions(+), 15 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 375994bdada6..3ebc835c46b6 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -126,6 +126,8 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts', 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..ad17ad5e5e0f 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 @@ -68,6 +68,14 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return released } + open: NonNullable = (input) => { + const adapter = this.owner(input.sessionId) + if (!adapter.open) { + return Promise.reject(new Error('The provider cannot open a published child.')) + } + return adapter.open(input) + } + dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index 12c2f54a1793..4542a903e2b8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -10,7 +10,8 @@ import type { import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession + StructuredAgentSessionHostSession, + StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' @@ -34,6 +35,10 @@ export type StructuredAgentSessionAttachContext = { serialize: (sessionId: string, task: () => Promise) => Promise now: () => number publishStatus: (sessionId: string) => void + openProviderChild?: ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity + ) => void /** Joining a stop's close ends the child's record through the one exit handler. */ endExitedChild: StructuredAgentSessionLifetimeContext['endExitedChild'] wakeDelivery?: StructuredAgentSessionLifetimeContext['wakeDelivery'] 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 38364e7bc98b..db7063ec9321 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 @@ -291,6 +291,8 @@ async function runAttachUnderAbort( startupAttempts.published(sessionId, attempt.startup.attemptId, candidate.child) if (candidate.child.phase === 'ready') { noteStructuredAgentSessionProviderStarted(context.deps, sessionId) + } else { + context.openProviderChild?.(sessionId, candidate.child) } } context.publishStatus?.(sessionId) 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..c799ba1a161b 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 @@ -26,13 +26,14 @@ export async function joinStructuredAgentSessionChildClose( 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 + const unanswered = child.phase === 'starting' if (!(await closeProviderRoot(context, sessionId))) { return 'unverifiable' } await context.endExitedChild(sessionId, child, { expected: true, reason: 'closed by Orca', + ...(child.close?.failure ? { failure: child.close.failure } : {}), ...(unanswered ? { startupUnanswered: true } : {}) }) context.restartWitness?.stopped(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index 694268a1f8bf..f9ddbe0b58a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -126,7 +126,7 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< const startClose = expected && exitedDuringStartup ? close?.cause : undefined const startFailure = expected ? startClose === 'host-stop' - ? agentSessionFailureFact('hostStopped') + ? (exit.failure ?? close?.failure ?? agentSessionFailureFact('hostStopped')) : undefined : exit.failure // A send the child was handed and never echoed cannot have run when a person's Stop ended its @@ -153,7 +153,7 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< // A close keeps the cause of the stop that asked for it, and ends where it was asked. cause: expected ? (close?.cause ?? 'evict') : 'exit', reason: expected ? (close?.reason ?? null) : exit.reason, - ...(!expected && exit.failure ? { failure: exit.failure } : {}), + ...(startFailure ? { failure: startFailure } : {}), duringStartup: exitedDuringStartup, // The adapter publishes an exit only once it saw the root go, first-hand or proven. rootGone: true, 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 649da24b2890..ad4e098c2b0c 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 @@ -30,6 +30,12 @@ import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation- import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger' import { sameProviderChild } from './structured-agent-session-provider-child' import type { StructuredAgentSessionExpiredStartup } from './structured-agent-session-startup-attempt' +import { + providerStartupFailureFact, + structuredAgentSessionStartFailure +} from './structured-agent-session-failure-text' +import { recordStructuredAgentSessionStartFailure } from './structured-agent-session-start-failure-row' +import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' export type StructuredAgentSessionConversationLifetime = ReturnType< typeof createStructuredAgentSessionConversationLifetime @@ -135,6 +141,44 @@ export function createStructuredAgentSessionConversationLifetime(host: { stopStartingChild(sessionId, child), /** A start that cannot finish ends as one past its limit does, outside the caller's step. */ stopStartingChild, + failStartingChild: ( + sessionId: string, + failed: StructuredAgentSessionProviderChildIdentity, + error: unknown + ): Promise => + serialize(sessionId, async () => { + const session = sessions.get(sessionId) + const child = session?.child + if (!session || !child || child.close || !sameProviderChild(child, failed)) { + return + } + const failure = providerStartupFailureFact(error) + try { + await recordStructuredAgentSessionStartFailure( + { journal: session.journal, fence: child.fence }, + { + startKey: child.generation, + ...structuredAgentSessionStartFailure( + { failure }, + structuredAgentSessionFailureWordsContext(deps().store.getRecord(sessionId)) + ) + } + ).catch((writeError: unknown) => + deps().logger.warn('recording a failed provider open failed', { + scope: 'provider-open-failure', + sessionId, + error: writeError + }) + ) + await stopAgent(sessionId, { + cause: 'host-stop', + reason: error instanceof Error ? error.message : String(error), + failure + }) + } finally { + host.context().runtimeState.startupAttempts.childEnded(sessionId, child) + } + }), /** 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-ended-child-failure.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts index eb64f1b38709..227c99ad15c5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ended-child-failure.ts @@ -23,7 +23,7 @@ const ENDED_CHILD_FAILURE = { 'user-close': () => null, 'context-clear': () => null, // The host stopping the child is Orca's cause, never the provider's: a start that never finished. - 'host-stop': () => ({ failure: agentSessionFailureFact('hostStopped') }), + 'host-stop': (ended) => ({ failure: ended.failure ?? agentSessionFailureFact('hostStopped') }), exit: providerEndFailure, // The attach records its own fault as the end's failure. 'attach-failed': providerEndFailure, 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 43db82db38bd..f9b9abf53818 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 @@ -22,6 +22,7 @@ import { type StructuredAgentSessionChildExitContext } from './structured-agent-session-child-exit' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import { openStructuredAgentSessionProviderChild } from './structured-agent-session-provider-open' export class StructuredAgentSessionEventRecovery { private readonly sinkFailures = new Set() @@ -42,9 +43,24 @@ export class StructuredAgentSessionEventRecovery { sessionId: string, child: StructuredAgentSessionProviderChildIdentity ) => void + failStartingChild: ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity, + error: unknown + ) => Promise + trackOpen: (open: Promise) => Promise } ) {} + openProviderChild = ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity + ): void => { + void this.context.trackOpen( + openStructuredAgentSessionProviderChild(this.context, sessionId, child) + ) + } + private get exitContext(): StructuredAgentSessionChildExitContext { const { deps, runtimeState } = this.context return { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 8e072e714b6d..8e2633983d76 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -156,6 +156,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( cause, ...('quit' in ending && ending.quit ? { quit: true as const } : {}), reason: ('reason' in ending ? ending.reason : undefined) ?? null, + ...('failure' in ending && ending.failure ? { failure: ending.failure } : {}), recorded, requestedAt: session.journal.cursor() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts index d9b8e068c37e..16c63515f370 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts @@ -10,6 +10,7 @@ import { structuredAgentSessionConversationFence } from './structured-agent-sess import type { JournalStopSettle } from '../agent-session-journal/queued-message-pause' import { sentSinceStop } from './structured-agent-session-queued-stop' import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' +import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' /** How a stop ends the child, and why (`lastEndedChild`). A person's Stop wrote its event in its * own step (`recorded` names its reason); any other stop names the reason its event records, with @@ -19,6 +20,7 @@ export type StructuredAgentSessionStopEnding = | { cause: Exclude reason?: string + failure?: SubmissionRejectionFact /** The app is quitting, and why: a turn this stop cuts gets a row naming it. */ quit?: AgentSessionResumeTrigger /** The idle sweep judged the agent resting (`owesWork`): a send it retires unanswered is 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 e8b38b149b61..fd822f95086e 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 @@ -57,6 +57,7 @@ export type StructuredAgentSessionChildClose = { /** Asked for by quit, whose leftovers the next open settles as a crash's. */ readonly quit?: true readonly reason: string | null + readonly failure?: SubmissionRejectionFact /** The Stop event that stop wrote, folded before the work it ends is settled, with the settle a * person's close that named no turn opens; a repeated ask reopens it. */ recorded: Promise 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 0e183d32a657..13678a71a95c 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 @@ -155,7 +155,9 @@ export class StructuredAgentSessionHost { now: () => this.now(), runtimeState: this.runtimeState, wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), - stopStartingChild: (...args) => this.lifetime.stopStartingChild(...args) + stopStartingChild: (...args) => this.lifetime.stopStartingChild(...args), + failStartingChild: (...args) => this.lifetime.failStartingChild(...args), + trackOpen: (open) => this.tasks.trackAttach(open) }) this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { ...structuredAgentSessionRestartResumeSurfaces(this, this.now), @@ -178,8 +180,7 @@ export class StructuredAgentSessionHost { private now = (): number => this.deps.now?.() ?? Date.now() hasSession = (sessionId: string): boolean => this.sessions.has(sessionId) - hasLoadedProviders = (): boolean => - [...this.sessions.values()].some(({ child }) => child !== null) + hasLoadedProviders = () => [...this.sessions.values()].some(({ child }) => child !== null) sessionAgent = (sessionId: string) => this.deps.store.getRecord(sessionId)?.provider ?? null handleAdapterEvent = (event: Parameters[0]) => @@ -206,6 +207,7 @@ export class StructuredAgentSessionHost { tasks: this.tasks, reconcileLeases: (sessionId) => this.reconcileLeases(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), + openProviderChild: this.eventRecovery.openProviderChild, openConversation: this.conversationDelivery.open } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts new file mode 100644 index 000000000000..c9adced332c8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -0,0 +1,100 @@ +import { expect, it, vi } from 'vitest' +import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' +import type { + StructuredAgentSessionProviderOpen, + StructuredAgentSessionProviderOpenResult +} from './structured-agent-session-provider-open-contract' +import { + CALLER, + attach, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' + +const OPENED: StructuredAgentSessionProviderOpenResult = { + reportedOptions: { model: 'gpt-test' }, + restoreSkippedOptions: [], + optionRevision: -1 +} + +function awaitOpen() { + const state = hostTestState() + const acquire = state.acquire.getMockImplementation() + if (!acquire) { + throw new Error('The host fixture has no spawn implementation.') + } + state.host.deps.adapter.acquire = async (input) => ({ + ...(await acquire(input)), + providerChildPhase: 'starting', + acquisitionGeneration: 'awaited-open' + }) + const pending = Promise.withResolvers() + const open = vi.fn(() => pending.promise) + const close = vi.fn(async () => true) + state.host.deps.adapter.open = open + state.host.deps.adapter.closeSession = close + const send = (text: string) => { + const body = hostTestMessage(text) + return state.host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + } + return { ...state, open, close, pending, send } +} + +it('accepts messages and reads while one host-awaited open holds delivery', async () => { + const { host, open, dispatch, pending, send } = awaitOpen() + await attach() + expect(open).toHaveBeenCalledTimes(1) + expect(await send('first')).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending' } } + }) + expect(await send('second')).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending' } } + }) + expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(2) + expect(dispatch).not.toHaveBeenCalled() + + pending.resolve(OPENED) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2)) + expect(open).toHaveBeenCalledTimes(1) +}) + +it('closes a refused open once and records one provider failure for all held messages', async () => { + const { host, open, close, dispatch, pending, send } = awaitOpen() + await attach() + await send('first') + await send('second') + pending.reject(new AgentSessionAcquisitionRefusal('Sign in to the provider.', 'notSignedIn')) + + await vi.waitFor(() => expect(close).toHaveBeenCalledTimes(1)) + const snapshot = await host.journalSnapshot(SESSION) + expect(snapshot.submissions).toHaveLength(2) + expect(snapshot.submissions.every((entry) => entry.dispatchState === 'rejected')).toBe(true) + expect(snapshot.submissions.every((entry) => entry.rejection?.kind === 'notSignedIn')).toBe(true) + expect( + snapshot.items.filter( + (item) => item.body.kind === 'status' && item.body.failure?.kind === 'notSignedIn' + ) + ).toHaveLength(1) + expect(open).toHaveBeenCalledTimes(1) + expect(dispatch).not.toHaveBeenCalled() +}) + +it('closes a child while its open is pending and refuses its late readiness result', async () => { + const { host, open, close, dispatch, pending, send } = awaitOpen() + await attach() + await send('held') + await host.close(SESSION, 'user-close') + expect(close).toHaveBeenCalledTimes(1) + expect(open.mock.calls[0]?.[0].signal.aborted).toBe(true) + + pending.resolve(OPENED) + await host.flushAllStreamedEvents() + expect(dispatch).not.toHaveBeenCalled() + expect(close).toHaveBeenCalledTimes(1) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts new file mode 100644 index 000000000000..60c9d099f52c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts @@ -0,0 +1,59 @@ +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' +import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' +import { + settleStructuredAgentSessionProviderStarted, + type StructuredAgentSessionProviderStartedContext +} from './structured-agent-session-provider-started' + +/** The host awaits protocol construction without holding the conversation's ordered steps. */ +export async function openStructuredAgentSessionProviderChild( + context: StructuredAgentSessionProviderStartedContext & { + failStartingChild: ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity, + error: unknown + ) => Promise + }, + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity +): Promise { + const open = context.deps.adapter.open + if (!open || !context.runtimeState.startupAttempts.onClock(sessionId, child)) { + return + } + const attempt = context.runtimeState.acquireAborts.begin(sessionId) + try { + const result = await waitForPromiseWithSignal( + open.call(context.deps.adapter, { + sessionId, + fence: child.fence, + acquisitionGeneration: child.generation, + signal: attempt.signal + }), + attempt.signal + ) + if (attempt.signal.aborted) { + return + } + await settleStructuredAgentSessionProviderStarted(context, { + ...result, + type: 'started', + sessionId, + fence: child.fence, + acquisitionGeneration: child.generation ?? '' + }) + } catch (error) { + // Stop, close, quit and expiry already own the child's end. + if (!attempt.signal.aborted) { + await context.failStartingChild(sessionId, child, error).catch((closeError: unknown) => + context.deps.logger.warn('closing a failed provider open did not finish', { + scope: 'provider-open-close', + sessionId, + error: closeError + }) + ) + } + } finally { + attempt.end() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts index 77e8a994a914..2590ffd88392 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts @@ -25,9 +25,9 @@ import { commandReceiptScope } from '../agent-session-journal/command-receipt-sc import { parseAgentSessionOperationTimestamp } from '../../../shared/agent-session-host-authority' import type { StructuredAgentSessionStartReadiness } from './structured-agent-session-startup-attempt' -type ReadyOperationPlan = Pick< - MutationPlan, - 'method' | 'fields' | 'operationIdScope' | 'acceptsWithCommandReceipt' +type ReadyOperationPlan = Pick< + MutationPlan, + 'method' | 'fields' | 'operationIdScope' | 'commandReceipt' > type QueuedReadyOperation = | { result: AgentSessionMutationResult } @@ -49,7 +49,7 @@ export async function runAfterRewindStart( context: StructuredAgentSessionMutationContext, callerKey: string, envelope: AgentSessionMutationEnvelope, - plan: ReadyOperationPlan, + plan: ReadyOperationPlan, run: (selection: ReadyOperationSelection) => Promise> ): Promise> { return runWithReadyStructuredAgentSessionOperation(context, callerKey, envelope, plan, run, { @@ -81,7 +81,7 @@ export async function runWithReadyStructuredAgentSessionOperation( context: StructuredAgentSessionMutationContext, callerKey: string, envelope: AgentSessionMutationEnvelope, - plan: ReadyOperationPlan, + plan: ReadyOperationPlan, run: (selection: ReadyOperationSelection) => Promise>, options: { beforeAgent?: ( @@ -107,7 +107,7 @@ export async function runWithReadyStructuredAgentSessionOperation( if (options.needsAgent && !options.needsAgent(record)) { return { result: await run({ ok: true, child: null }) } } - if (plan.acceptsWithCommandReceipt) { + if (plan.commandReceipt) { if (parseAgentSessionOperationTimestamp(envelope.clientOperationId) === null) { return { result: await run({ ok: true, child: null }) } } 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 571c52017fcc..8e19074321bf 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 @@ -197,7 +197,7 @@ export class StructuredAgentSessionStartupAttempts { published( sessionId: string, attemptId: string, - child: StructuredAgentSessionProviderChildIdentity & { phase: 'starting' | 'ready' } + child: StructuredAgentSessionProviderChildIdentity & { phase?: 'starting' | 'ready' } ): void { const tracked = this.open.get(sessionId) if (tracked?.attempt.attemptId !== attemptId) { From 57e7bded10c2f5ba10489284ddaea9eeda821cff Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:48:01 -0700 Subject: [PATCH 08/55] Await Claude initialization in host-owned open --- .../claude-structured-applied-options.test.ts | 13 +- ...claude-structured-dispatch-test-support.ts | 18 +- .../claude-structured-dispatch-waiters.ts | 2 +- src/main/claude/claude-structured-dispatch.ts | 9 - ...-structured-effort-default-at-rest.test.ts | 10 +- .../claude/claude-structured-options.test.ts | 18 +- src/main/claude/claude-structured-options.ts | 9 - .../claude-structured-prompt-ownership.ts | 2 +- .../claude/claude-structured-real-cli.test.ts | 47 +- .../claude-structured-resume-point.test.ts | 9 +- .../claude-structured-session-acquisition.ts | 45 +- .../claude-structured-session-adapter.test.ts | 115 +---- .../claude-structured-session-adapter.ts | 18 +- .../claude/claude-structured-session-close.ts | 3 +- ...laude-structured-session-exit-lifecycle.ts | 27 +- .../claude/claude-structured-session-open.ts | 75 +++ .../claude-structured-session-options.ts | 18 +- .../claude-structured-session-publication.ts | 21 +- ...claude-structured-session-recovery.test.ts | 6 +- ...claude-structured-session-startup-state.ts | 71 --- .../claude-structured-session-startup.test.ts | 471 +++--------------- .../claude-structured-session-startup.ts | 135 ++--- .../claude/claude-structured-session-state.ts | 23 +- .../claude-structured-session-test-support.ts | 21 +- .../claude-structured-start-fast-mode.ts | 2 +- ...structured-startup-settled-test-support.ts | 39 +- 26 files changed, 391 insertions(+), 836 deletions(-) create mode 100644 src/main/claude/claude-structured-session-open.ts delete mode 100644 src/main/claude/claude-structured-session-startup-state.ts diff --git a/src/main/claude/claude-structured-applied-options.test.ts b/src/main/claude/claude-structured-applied-options.test.ts index f4043c1fd489..8f5d8379e657 100644 --- a/src/main/claude/claude-structured-applied-options.test.ts +++ b/src/main/claude/claude-structured-applied-options.test.ts @@ -5,7 +5,8 @@ import { acquired, adapterFor, fakeClaude, - identityFor + identityFor, + claudeOpened } from './claude-structured-session-test-support' /** Verbatim rows from Claude Code 2.1.280's list_models: `default` is the CLI's own @@ -134,12 +135,10 @@ describe('Claude model before the first turn', () => { const events: ClaudeStructuredSessionEvent[] = [] const adapter = await acquired(startedWithoutATurn(SETTINGS.noOverride), {}, events) - const started = events.find((event) => event.type === 'started') + const started = await claudeOpened(adapter, 'session-1') expect(started).toMatchObject({ reportedOptions: { model: 'opus[1m]' } }) // Saved, it would be restored on every reopen past a later settings.json change. - expect(started?.type === 'started' ? started.reportedOptions : null).not.toHaveProperty( - 'effort' - ) + expect(started.reportedOptions).not.toHaveProperty('effort') const reported = events.find((event) => event.type === 'options-reported') expect( reported?.type === 'options-reported' ? reported.reportedOptions : null @@ -174,8 +173,8 @@ describe('Claude model before the first turn', () => { }) // `started` is persisted as the session's options; opus's effort must not ride along onto haiku. - const started = events.find((event) => event.type === 'started') + const started = await claudeOpened(adapter, 'session-1') expect(started).toMatchObject({ reportedOptions: { model: 'haiku' } }) - expect(started?.type === 'started' ? started.reportedOptions.effort : null).toBeUndefined() + expect(started.reportedOptions.effort).toBeUndefined() }) }) diff --git a/src/main/claude/claude-structured-dispatch-test-support.ts b/src/main/claude/claude-structured-dispatch-test-support.ts index 80a774e648bc..afe0352e2f87 100644 --- a/src/main/claude/claude-structured-dispatch-test-support.ts +++ b/src/main/claude/claude-structured-dispatch-test-support.ts @@ -5,7 +5,8 @@ import type { ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' -import { createClaudeSessionStartup } from './claude-structured-session-startup-state' +import { createClaudeInitProof } from './claude-structured-init-proof' +import { claudeProviderHandleLink } from './claude-structured-owner-identity' export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): ClaudeSession { return { @@ -34,7 +35,20 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C capabilities: [], events: undefined, translator: null, - startup: { ...createClaudeSessionStartup(), state: 'proven' } + link: claudeProviderHandleLink({ + sessionId: 'provider-session', + leafUuid: null, + resumed: false, + fence: 1, + observedAt: 1 + }), + initialization: { + proof: createClaudeInitProof(), + account: undefined, + resumesTranscript: false, + optionRevision: () => 0, + onLateRefusal: () => {} + } } } diff --git a/src/main/claude/claude-structured-dispatch-waiters.ts b/src/main/claude/claude-structured-dispatch-waiters.ts index 2c5df3c671eb..061c8da5454a 100644 --- a/src/main/claude/claude-structured-dispatch-waiters.ts +++ b/src/main/claude/claude-structured-dispatch-waiters.ts @@ -15,7 +15,7 @@ export function forgetRetiredWaiter(session: ClaudeSession, waiter: ClaudeDispat * starts — an interval bounded only by the previous turn. Elapsed time is * therefore not evidence about delivery, and nothing here expires. Waiters end * by provider and process facts instead: an echo, a `command_lifecycle` frame, - * the CLI's idle after `started` (`claude-command-lifecycle.ts`), a failed + * the CLI's idle after open (`claude-command-lifecycle.ts`), a failed * write, or child exit. */ export function waitForReplay( diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 87e706ddbbc3..3467a474e0bf 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -28,10 +28,6 @@ import { claudeUserMessageWasProvablyUnwritten } from './claude-agent-sdk-user-message-queue' import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement' -import { - claudeStartupFailureFact, - failClaudeStartup -} from './claude-structured-session-startup-state' const MAX_ACTIVE_DISPATCH_WAITERS = 64 @@ -99,7 +95,6 @@ export function releaseClaudeDispatchWaitersInDoubt( * CLI's idle has not. Retired rather than dropped: their identities stay joinable, bounded by * `MAX_RETIRED_DISPATCH_WAITERS`. */ export function retireClaudeDispatchWaiters(session: ClaudeSession): void { - failClaudeStartup(session, new Error('claude stream-json ended before startup completed')) for (const waiter of session.dispatchWaiters.splice(0)) { retireWaiter(session, waiter) waiter.resolve(null) @@ -132,10 +127,6 @@ export async function dispatchClaudeTurn( if (session.dispatchWaiters.length >= MAX_ACTIVE_DISPATCH_WAITERS) { return { state: 'rejected', ...claudeDispatchRejection(agentSessionFailureFact('queueFull')) } } - const startupFailure = claudeStartupFailureFact(session) - if (startupFailure) { - return { state: 'rejected', ...claudeDispatchRejection(startupFailure) } - } // Read the sent content, not the journal blocks: only the mapped trailing prompt decides // whether Claude runs a command, so the two cannot disagree about which frame settles this. const acceptsResult = claudeDispatchInvokesSlashCommand(content) diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 72eb85f677c5..7cc0a917b4aa 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -27,7 +27,8 @@ import { fakeClaude, identityFor, recordingJournalSink, - claudeStartupSettled + claudeStartupSettled, + claudeOpened } from './claude-structured-session-test-support' const SESSION = 'session-1' @@ -396,14 +397,13 @@ describe('Claude effort default at rest', () => { it("never saves the applied effort as the chat's pick, yet shows it at rest", async () => { const store = new AgentModelCatalogStore() const events: ClaudeStructuredSessionEvent[] = [] - await startChild(store, undefined, events) + const adapter = await startChild(store, undefined, events) - const started = events.find((event) => event.type === 'started') - const reported = started?.type === 'started' ? started.reportedOptions : null + const reported = (await claudeOpened(adapter, SESSION)).reportedOptions expect(reported).not.toHaveProperty('effort') // The record the start persists names the listed row the catalog learned under. const record = restingRecord( - nativeSessionOptionsFromReport({ reported: reported!, restoreSkipped: [] }) + nativeSessionOptionsFromReport({ reported: reported, restoreSkipped: [] }) ) expect(record.options).toEqual({ model: 'opus[1m]' }) expect((await readAtRest(store, record)).current.effort).toBe('medium') diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index 2843d4f7ddd7..efc0ce81ad55 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -5,7 +5,8 @@ import { ClaudeControlRequestTimeoutError } from './claude-agent-sdk-control-req import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' -import { createClaudeSessionStartup } from './claude-structured-session-startup-state' +import { createClaudeInitProof } from './claude-structured-init-proof' +import { claudeProviderHandleLink } from './claude-structured-owner-identity' import { observeClaudeFastModeFacts, readClaudeStructuredSessionOptions @@ -43,7 +44,20 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe capabilities: [], events: undefined, translator: null, - startup: { ...createClaudeSessionStartup(), state: 'proven' } + link: claudeProviderHandleLink({ + sessionId: 'provider-session', + leafUuid: null, + resumed: false, + fence: 1, + observedAt: 1 + }), + initialization: { + proof: createClaudeInitProof(), + account: undefined, + resumesTranscript: false, + optionRevision: () => 0, + onLateRefusal: () => {} + } } } diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index 6aa1ae8cc0e7..042c513f5972 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -47,15 +47,6 @@ export function setClaudeStructuredSessionOption( input: { key: string; value: string }, timeoutMs: number | undefined ): Promise>> { - // Each write is a control request the CLI answers only after initialize. - if (session.startup.state !== 'proven') { - return Promise.reject( - new AgentSessionOptionRejectedError( - 'Claude is still starting; options can be changed once it is ready.', - 'providerStarting' - ) - ) - } return setClaudeStructuredOption(session, input, timeoutMs) } diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index a7d1c41c8bfe..7214ead80a8d 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -65,7 +65,7 @@ export async function cancelClaudeStructuredTurn(input: { 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. - if (request.prompt || session.startup.state === 'pending') { + if (request.prompt) { return { cancelled: false } } const requestedTurnId = request.turnId diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index a3ffb61f027c..66b996819014 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -25,7 +25,7 @@ import { type ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state' -import { claudeStartupSettled } from './claude-structured-session-test-support' +import { claudeOpened, claudeStartupSettled } from './claude-structured-session-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const command = realClaudeCommand @@ -167,18 +167,21 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { const adapter = realAdapter(providerSessionId, claudeConfigDir, events, cwd) try { - const acquisition = await adapter.acquire({ + await adapter.acquire({ identity: identity(providerSessionId), fence: 1, spawnToken: 'real-cli' }) + await claudeStartupSettled(adapter, 'real-cli-handshake') const observedSubtypes = events.flatMap((event) => event.type === 'message' ? [event.message.subtype] : [] ) // 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((await claudeOpened(adapter, 'real-cli-handshake')).link?.handle).toEqual( + claudeProviderHandle(providerSessionId, null) + ) expect(observedSubtypes).toContain('hook_started') expect(adapter.readCommands('real-cli-handshake')).toContainEqual( expect.objectContaining({ @@ -216,6 +219,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1, spawnToken: 'real-cli-effort' }) + await claudeStartupSettled(adapter, 'real-cli-handshake') const published = events.flatMap((event) => event.type === 'message' ? [event.message] : [] ) @@ -260,6 +264,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1, spawnToken: 'real-cli-transcript' }) + await claudeStartupSettled(adapter, 'real-cli-handshake') await adapter.dispatch({ sessionId: 'real-cli-handshake', clientMessageId: 'real-cli-transcript-1', @@ -303,6 +308,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1, spawnToken: 'real-cli-model' }) + await claudeStartupSettled(adapter, 'real-cli-handshake') await adapter.setOption({ sessionId: 'real-cli-handshake', key: 'model', @@ -349,11 +355,11 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { 90_000 ) - // The host hands a Claude child nothing until `started`, which now follows the initialize answer + // The host hands a Claude child nothing until open, which follows the initialize answer // alone. Measures what that wait costs against the live binary, so the startup limits are tuned // from data: spawn to `started` with nothing sent, then a send to its first output. it.skipIf(!realClaudeAuthenticated)( - 'reports `started` with nothing sent, then answers a send handed over after it', + 'opens with nothing sent, then answers a send handed over after it', async () => { const providerSessionId = randomUUID() const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') @@ -381,10 +387,9 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1, spawnToken: 'real-cli-started-timing' }) + await claudeStartupSettled(adapter, 'real-cli-handshake') const publishedAt = Date.now() - expect(await until(() => events.some((event) => event.type === 'started'), 30_000)).toBe( - true - ) + await claudeStartupSettled(adapter, 'real-cli-handshake') const startedAt = Date.now() const before = events.length await adapter.dispatch({ @@ -400,7 +405,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { // Reported for tuning; the assertions above are the contract. console.info( `[real-cli startup timing] acquire->published ${publishedAt - acquireAt} ms, ` + - `published->started ${startedAt - publishedAt} ms, ` + + `published->open ${startedAt - publishedAt} ms, ` + `dispatch->first output ${firstOutputAt - startedAt} ms` ) } finally { @@ -411,13 +416,12 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { ) // The contract a chat's first message depends on: saved options and the chat's permission mode - // ride the launch, and the message is written as soon as the child is published, before the CLI - // answers initialize. + // ride the launch, and the message is written after open answers initialize. it.skipIf(!realClaudeAuthenticated).each([ ['accept-edits', 'acceptEdits'], ['full-access', 'bypassPermissions'] ] as const)( - 'runs a message written before initialize answers, launched with saved options and %s', + 'runs a message after open with saved options and %s', async (permissionMode, cliPermissionMode) => { const providerSessionId = randomUUID() const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') @@ -442,6 +446,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { options: { model: 'sonnet', effort: 'low' }, permissionMode }) + await claudeStartupSettled(adapter, 'real-cli-handshake') const dispatched = await adapter.dispatch({ sessionId: 'real-cli-handshake', clientMessageId: 'real-cli-saved-options-1', @@ -453,11 +458,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1 }) // Read as the write resolves: the CLI had not answered initialize, so nothing held it. - const answeredAtDispatch = adapter['sessions'].get('real-cli-handshake')?.startup.answered - const startedAtDispatch = events.some((event) => event.type === 'started') expect(dispatched).toEqual({ state: 'admitted' }) - expect(answeredAtDispatch).toBe(false) - expect(startedAtDispatch).toBe(false) const deadline = Date.now() + 90_000 while (!messages().some((m) => m.type === 'result') && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 250)) @@ -522,6 +523,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1, spawnToken: 'real-cli-stop' }) + await claudeStartupSettled(adapter, 'real-cli-handshake') const first = events.length await expect( send('real-cli-stop-1', 'Count from 1 to 300, one number per line, and nothing else.') @@ -593,6 +595,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1, spawnToken: 'real-cli-queued-stop' }) + await claudeStartupSettled(adapter, 'real-cli-handshake') await send('real-cli-queued-stop-a', 'Count from 1 to 400, one number per line.') // A's reply is streaming, so the next send queues behind its turn. await expect( @@ -656,6 +659,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1, spawnToken: 'real-cli-no-hooks' }) + await claudeStartupSettled(adapter, 'real-cli-handshake') expect(frames().filter((frame) => readClaudeInit(frame) !== null)).toEqual([]) await expect( adapter.dispatch({ @@ -691,7 +695,8 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { events, process.cwd(), undefined, - {} + {}, + false ) try { @@ -700,11 +705,9 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { fence: 1, spawnToken: 'real-cli-no-auth' }) - await adapter.drainObservedExits() - expect(events.find((event) => event.type === 'ended')).toMatchObject({ - reason: expect.stringMatching(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s), - startupUnproven: true - }) + await expect(claudeStartupSettled(adapter, 'real-cli-handshake')).rejects.toThrow( + /not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s + ) } finally { await adapter.closeAll() await rm(claudeConfigDir, { recursive: true, force: true }) diff --git a/src/main/claude/claude-structured-resume-point.test.ts b/src/main/claude/claude-structured-resume-point.test.ts index 2f5b15b5edde..bc7ba2badc2c 100644 --- a/src/main/claude/claude-structured-resume-point.test.ts +++ b/src/main/claude/claude-structured-resume-point.test.ts @@ -6,7 +6,8 @@ import { identityFor, PROVIDER_SESSION_ID, recordingJournalSink, - tick + tick, + claudeOpened } from './claude-structured-session-test-support' /** A resumed owner that completed one turn (`a4`) and then saw the next turn's prompt. */ @@ -82,13 +83,15 @@ describe('Claude resume point is the last completed turn on every exit path', () [], persisted ) - const acquisition = await adapter.acquire({ + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-7', events: recordingJournalSink() }) - expect(acquisition.link?.handle).toMatchObject({ resumeCursor: 'a3' }) + expect((await claudeOpened(adapter, 'session-1')).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 1cf0ded65df2..d65c3bcfd90e 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -18,12 +18,7 @@ import { claudeProcessIdentity } from './claude-structured-owner-identity' import { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { adoptClaudeStructuredSpawnOptions } from './claude-structured-spawn-options' import { observeClaudeFastModeFacts } from './claude-structured-session-options' -import { - readClaudeStartupFacts, - settleClaudeSessionStartup -} from './claude-structured-session-startup' import { createClaudeSessionPublication } from './claude-structured-session-publication' -import { readClaudeStructuredSessionSettings } from './claude-structured-session-acquisition-options' import { mintClaudeAcquisitionGeneration, type ClaudeAcquisitionRegistry, @@ -243,6 +238,13 @@ export async function acquireClaudeSession({ process, acquisitionGeneration: mintClaudeAcquisitionGeneration(deps), options: launch.savedOptions.options, + initialization: { + proof: initProof, + account: launch.account, + resumesTranscript: launch.resumesTranscript, + optionRevision: () => input.optionRevision?.() ?? 0, + onLateRefusal: (error) => callbacks.handleExit(sessionId, attempt, error) + }, ...(deps.mintLinkId ? { linkId: deps.mintLinkId() } : {}), observedAt: deps.now?.() ?? Date.now() }) @@ -257,37 +259,6 @@ export async function acquireClaudeSession({ event() } }) - // Whichever comes first: the start landing or faulting, or the child being ended. - session.startup.settled = Promise.race([ - session.startup.settled, - settleClaudeSessionStartup({ - session, - facts: readClaudeStartupFacts({ - account: launch.account, - connection, - initProof, - sessionId, - providerSessionId: launch.providerSessionId, - startup: session.startup, - resumesTranscript: launch.resumesTranscript, - requestTimeoutMs: deps.requestTimeoutMs, - emit - }), - readSettings: () => readClaudeStructuredSessionSettings(connection, deps.requestTimeoutMs), - isCurrent: () => sessions.get(sessionId) === session, - fault: (error) => callbacks.handleExit(sessionId, attempt, error), - diagnose: (diagnostic) => emit({ type: 'auth-diagnostic', sessionId, diagnostic }), - // The host's minted attempt always carries it; only adapter tests that pass less omit it. - optionRevision: () => input.optionRevision?.() ?? 0, - report: (event) => - emit({ - ...event, - sessionId, - fence: input.fence, - acquisitionGeneration: session.acquisitionGeneration - }) - }) - ]) // A child whose exit already reached `handleExit` is not handed over as live: the create // fails with the CLI's own diagnostic, as one that died before publish does. if (sessions.get(sessionId) !== session) { @@ -295,7 +266,7 @@ export async function acquireClaudeSession({ exits.get(sessionId)?.error ?? new Error('claude session ended before acquisition returned') ) } - // The start reads its facts only after publish, so the child is `starting` until `started`. + // The host calls open after publication and holds input until it settles. return { ...publication.acquisition, providerChildPhase: 'starting' } } catch (error) { unbindReadingControl?.() diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 813ecfcaef93..8145374dd960 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -2,7 +2,6 @@ import { homedir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { - AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRootExitObservedError, AgentSessionPromptAnswerRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -19,12 +18,14 @@ import type { import { acquired, adapterFor, + adapterAtPublishFor, fakeClaude, identityFor, PROVIDER_SESSION_ID, tick, USER_MESSAGE, - type FakeConnection + type FakeConnection, + claudeOpened } from './claude-structured-session-test-support' import { invokeCanUseTool } from './claude-can-use-tool-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' @@ -59,7 +60,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { processStartTimeMs: 1_700_000_000_000, spawnToken: 'spawn-9' }) - expect(acquisition.link).toEqual({ + expect((await claudeOpened(adapter, 'session-1')).link).toEqual({ linkId: `claude-7-${PROVIDER_SESSION_ID}-empty`, handle: claudeProviderHandle(PROVIDER_SESSION_ID, null), origin: 'created', @@ -162,7 +163,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { subtype: 'apply_flag_settings', params: { settings: { fastMode: true } } }) - expect(events.find((event) => event.type === 'started')).toMatchObject({ + expect(await claudeOpened(adapter, 'session-1')).toMatchObject({ reportedOptions: { fastMode: true }, restoreSkippedOptions: [] }) @@ -194,7 +195,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { // Started, with the pick, while the apply is still unanswered: nothing waits on it. expect(claude.connections[0].calls.at(-1)?.subtype).toBe('apply_flag_settings') - expect(events.find((event) => event.type === 'started')).toMatchObject({ + expect(await claudeOpened(adapter, 'session-1')).toMatchObject({ reportedOptions: { fastMode: true }, restoreSkippedOptions: [] }) @@ -435,13 +436,15 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { const events: ClaudeStructuredSessionEvent[] = [] const adapter = adapterFor(claude, {}, events) - const acquisition = await adapter.acquire({ + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - expect(acquisition.link?.handle).toEqual(claudeProviderHandle(PROVIDER_SESSION_ID, null)) + expect((await claudeOpened(adapter, 'session-1')).link?.handle).toEqual( + claudeProviderHandle(PROVIDER_SESSION_ID, null) + ) expect(events[0]).toMatchObject({ type: 'message', message: { subtype: 'hook_started', hook_name: 'SessionStart:startup' } @@ -483,43 +486,30 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { continuesChain: true, resumeLeafUuid: 'leaf-before' }) - const acquisition = await resumed.acquire({ + await resumed.acquire({ identity: identityFor(), fence: 9, spawnToken: 'spawn-9' }) - expect(acquisition.link?.origin).toBe('resumed') - expect(acquisition.link?.handle).toEqual( + expect((await claudeOpened(resumed, 'session-1')).link?.origin).toBe('resumed') + expect((await claudeOpened(resumed, 'session-1')).link?.handle).toEqual( claudeProviderHandle(PROVIDER_SESSION_ID, 'leaf-before') ) const wrongClaude = fakeClaude({ initSessionId: 'different-session' }) - await expect(endedAtStartup(wrongClaude)).resolves.toMatchObject({ - reason: expect.stringMatching(/expected/), - startupUnproven: true - }) + const wrong = adapterAtPublishFor(wrongClaude) + await wrong.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await expect(claudeOpened(wrong, 'session-1')).rejects.toThrow('provider session expected') + await wrong.closeSession('session-1') expect(wrongClaude.connections[0].closeCount).toBe(1) }) - it('fails the acquire with the CLI diagnostic when the exit lands before the handover', async () => { - // No init delay: the child dies inside the initialize call, before acquire can return it. + it('reports the CLI diagnostic when the child exits during open', async () => { const claude = fakeClaude({ exitBeforeInit: 'Claude login required' }) - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = adapterFor(claude, {}, events) - - await expect( - adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - ).rejects.toThrow('Claude login required') + const adapter = adapterAtPublishFor(claude) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await expect(claudeOpened(adapter, 'session-1')).rejects.toThrow('Claude login required') await adapter.drainObservedExits() - - // The published-then-ended path is the slow-init case in the startup suite. The first-hand - // exit is still reported as it was seen; the host holds no session under it to end. - expect(events.find((event) => event.type === 'ended')).toMatchObject({ - reason: 'Claude login required', - cause: 'unexpected-exit', - startupUnproven: true - }) - expect(claude.connections[0].closeCount).toBeGreaterThanOrEqual(1) }) it('refuses an unauthenticated initialize response even when SessionStart runs', async () => { @@ -527,70 +517,15 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { initProof: 'session-start', initAccount: { apiProvider: 'firstParty', tokenSource: 'none' } }) - - await expect(endedAtStartup(claude)).resolves.toMatchObject({ - reason: expect.stringMatching(/not signed in.*Claude CLI.*CLAUDE_CONFIG_DIR/s), - startupUnproven: true - }) + const adapter = adapterAtPublishFor(claude) + await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await expect(claudeOpened(adapter, 'session-1')).rejects.toThrow('not signed in') + await adapter.closeSession('session-1') expect(claude.connections[0].closeCount).toBe(1) }) }) -/** Acquires, then returns the `ended` event the startup failure published. */ -async function endedAtStartup( - claude: ReturnType, - input: Parameters[0] = { - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9' - }, - launch: Parameters[1] = {} -): Promise { - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = adapterFor(claude, launch, events) - await adapter.acquire(input) - await adapter.drainObservedExits() - return events.find((event) => event.type === 'ended') -} - describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { - /** A start that fails after the child self-exited, with its close verdict scripted. */ - function failedStart( - unprovenCloseVerdict: ClaudeStreamJsonConnection['exitVerdict'] - ): Promise { - const claude = fakeClaude({ - exitBeforeInit: 'claude stream-json exited (code 1): not logged in', - unprovenCloseVerdict - }) - return adapterFor(claude, { - resumesTranscript: true, - continuesChain: true, - resumeLeafUuid: 'tip' - }) - .acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) - .catch((error: unknown) => error) - } - - // The root's pid and start time are the lease's identity, and they are provably dead: latching - // the session would strand a signed-out user. The lease follows the root, so a descendant seen - // alive does not hold the session either. - it.each(['unverifiable', 'live'] as const)( - 'releases on a first-hand root exit with its tree %s while still carrying the CLI diagnostic', - async (tree) => { - const error = await failedStart({ root: 'exited', tree }) - - expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) - expect((error as Error).message).toBe('claude stream-json exited (code 1): not logged in') - } - ) - - it('never releases for a root Orca never saw leave', async () => { - const error = await failedStart({ root: 'live', tree: 'unverifiable' }) - - expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) - expect(error).not.toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) - }) - /** A published session whose CLI then exits first-hand, with the verdict its ladder holds. */ async function exitedAfterPublish( exitVerdict: ClaudeStreamJsonConnection['exitVerdict'] diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 6f8bc901989e..db7f06c5fbb5 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, @@ -46,6 +44,7 @@ import { settleClaudePromptFreeingChild } from './claude-structured-prompt-ownership' import { claudePromptCancelRoute } from './claude-structured-prompt-replies' +import { openClaudeStructuredSession } from './claude-structured-session-open' export type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' export type { @@ -234,13 +233,14 @@ 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 - ) + open: NonNullable = (request) => + openClaudeStructuredSession({ + request, + session: this.session(request.sessionId), + sessions: this.sessions, + deps: this.deps, + emit: (session, event) => this.emit(session, event) + }) readOptions = (input: { sessionId: string; fence: number }) => readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) readOptionRestoreFailures = (sessionId: string): readonly string[] => [ diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 93a607c13f30..7a03ee3288e3 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -151,8 +151,7 @@ async function finalizeClaudePublishedSession( cause: 'requested-close', fence: session.fence, acquisitionGeneration: session.acquisitionGeneration, - observedAt: Date.now(), - ...(session.startup.answered ? {} : { startupUnanswered: true as const }) + observedAt: Date.now() } try { try { diff --git a/src/main/claude/claude-structured-session-exit-lifecycle.ts b/src/main/claude/claude-structured-session-exit-lifecycle.ts index 80bd6f5f6d62..3936ed89cb19 100644 --- a/src/main/claude/claude-structured-session-exit-lifecycle.ts +++ b/src/main/claude/claude-structured-session-exit-lifecycle.ts @@ -1,17 +1,10 @@ import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' -import { - providerExitObserved, - providerStartupFailureFact -} from '../native-chat/agent-session-wire/structured-agent-session-failure-text' +import { providerExitObserved } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { settledClaudeTurnEndLeaf } from './claude-structured-resume-point' import { claudeRootExitObserved, settleClaudeExitedSession } from './claude-structured-session-close' -import { - claudeStartupFailureCause, - failClaudeStartup -} from './claude-structured-session-startup-state' import type { ClaudeAcquisitionAttempt, ClaudeSession, @@ -40,9 +33,6 @@ export function observeClaudeSessionExit( return } lifecycle.sessions.delete(sessionId) - // Not marked here: startup and journal faults end the session this way too. The child's own - // exit arrives already marked by the connection's exit callback. - failClaudeStartup(session, error) // Re-enter the provider's close ladder before publishing lifecycle recovery. // An exit callback is root evidence only; the retained tree proof must run // before the host releases and reacquires this exact child. @@ -97,20 +87,13 @@ export function settleClaudeUnexpectedExit( reason: exit.error.message, // A start that never landed says why it failed. After it landed, only the child's own exit // blames the provider; an Orca fault that closed it is Orca's. - failure: - exit.session.startup.state !== 'proven' - ? providerStartupFailureFact(claudeStartupFailureCause(exit.session, exit.error)) - : providerExitObserved(exit.error) - ? agentSessionFailureFact('providerExited', { - detail: providerDiagnosticOf(exit.error) - }) - : agentSessionFailureFact('hostFault'), + failure: providerExitObserved(exit.error) + ? agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(exit.error) }) + : agentSessionFailureFact('hostFault'), cause: 'unexpected-exit', fence: exit.session.fence, acquisitionGeneration: exit.session.acquisitionGeneration, - observedAt: deps.now?.() ?? Date.now(), - ...(exit.session.startup.state === 'proven' ? {} : { startupUnproven: true }), - ...(exit.session.startup.answered ? {} : { startupUnanswered: true }) + observedAt: deps.now?.() ?? Date.now() } try { lifecycle.emit(exit.session, ended) diff --git a/src/main/claude/claude-structured-session-open.ts b/src/main/claude/claude-structured-session-open.ts new file mode 100644 index 000000000000..62d605f6ab52 --- /dev/null +++ b/src/main/claude/claude-structured-session-open.ts @@ -0,0 +1,75 @@ +import type { + StructuredAgentSessionProviderOpenInput, + StructuredAgentSessionProviderOpenResult +} from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' +import { readClaudeStructuredSessionSettings } from './claude-structured-session-acquisition-options' +import { openClaudeSession, readClaudeStartupFacts } from './claude-structured-session-startup' +import type { + ClaudeSession, + ClaudeStructuredSessionAdapterDeps, + ClaudeStructuredSessionEvent +} from './claude-structured-session-state' + +/** The host awaits this under its own start signal after the process is published. */ +export async function openClaudeStructuredSession(input: { + request: StructuredAgentSessionProviderOpenInput + session: ClaudeSession + sessions: Map + deps: ClaudeStructuredSessionAdapterDeps + emit: (session: ClaudeSession, event: ClaudeStructuredSessionEvent) => void +}): Promise { + const { request, session, sessions, deps, emit } = input + if ( + session.fence !== request.fence || + session.acquisitionGeneration !== request.acquisitionGeneration + ) { + throw new Error('claude session was replaced before open') + } + const { proof, account, resumesTranscript, optionRevision, onLateRefusal } = + session.initialization + const opened = openClaudeSession({ + session, + facts: readClaudeStartupFacts({ + connection: session.connection, + initProof: proof, + sessionId: request.sessionId, + providerSessionId: session.providerSessionId, + resumesTranscript, + account, + requestTimeoutMs: deps.requestTimeoutMs, + emit: (event) => emit(session, event) + }), + readSettings: () => + readClaudeStructuredSessionSettings(session.connection, deps.requestTimeoutMs), + isCurrent: () => sessions.get(request.sessionId) === session, + diagnose: (diagnostic) => + emit(session, { type: 'auth-diagnostic', sessionId: request.sessionId, diagnostic }), + optionRevision, + onLateRefusal, + report: (event) => + emit(session, { + ...event, + sessionId: request.sessionId, + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration + }) + }) + if (request.signal.aborted) { + throw request.signal.reason + } + let removeAbort = (): void => {} + const aborted = new Promise((_resolve, reject) => { + const onAbort = (): void => reject(request.signal.reason) + request.signal.addEventListener('abort', onAbort, { once: true }) + removeAbort = () => request.signal.removeEventListener('abort', onAbort) + }) + try { + const result = await Promise.race([opened, aborted]) + if (sessions.get(request.sessionId) !== session || request.signal.aborted) { + throw new Error('claude session closed before startup completed') + } + return { link: session.link, ...result } + } finally { + removeAbort() + } +} diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 7276f0eb48c3..2550b6bab18b 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -218,14 +218,10 @@ export async function readClaudeStructuredSessionOptions( timeoutMs: number | undefined ): Promise { const readMutationSequence = session.optionMutationSequence - // Before startup both requests would wait on initialize; answer from the saved options. - const [catalog, settings] = - session.startup.state === 'proven' - ? await Promise.all([ - session.connection.supportedModels({ timeoutMs }).catch(() => null), - session.connection.getSettings({ timeoutMs }).catch(() => null) - ]) - : [null, null] + const [catalog, settings] = await Promise.all([ + session.connection.supportedModels({ timeoutMs }).catch(() => null), + session.connection.getSettings({ timeoutMs }).catch(() => null) + ]) observeClaudeSettingsReadback(session, settings, readMutationSequence) return claudeStructuredSessionOptionsFrom(session, catalog, readMutationSequence) } @@ -270,10 +266,8 @@ export function claudeResolvedCatalogListing( return listing ? { catalogListing: listing } : {} } -/** The options as main already holds them, over `catalog`; asks the CLI nothing. Startup's - * settings readback is applied by the time a start proves, and the SDK answers `list_models` - * from its initialize result, so a started session's snapshot passes that result here rather - * than paying two round trips for what it already read. */ +/** The options already held in memory, over `catalog`; asks the CLI nothing. The SDK answers + * `list_models` from initialize, so open passes that result without another round trip. */ export function claudeStructuredSessionOptionsFrom( session: ClaudeSession, catalog: unknown[] | null, diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index 8c3b524086b4..5630e0f95986 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -6,7 +6,6 @@ import type { ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' -import { createClaudeSessionStartup } from './claude-structured-session-startup-state' /** The session as published at spawn: nothing the CLI reports at init is assumed yet. */ export function createClaudeSessionPublication(input: { @@ -27,18 +26,19 @@ export function createClaudeSessionPublication(input: { linkId?: string observedAt: number options?: ReadonlyMap + initialization: ClaudeSession['initialization'] }): { acquisition: AgentSessionAcquisition; session: ClaudeSession } { + const link = claudeProviderHandleLink({ + sessionId: input.providerSessionId, + leafUuid: input.leafUuid, + resumed: input.continuesChain, + fence: input.fence, + ...(input.linkId ? { linkId: input.linkId } : {}), + observedAt: input.observedAt + }) return { acquisition: { process: input.process, - link: claudeProviderHandleLink({ - sessionId: input.providerSessionId, - leafUuid: input.leafUuid, - resumed: input.continuesChain, - fence: input.fence, - ...(input.linkId ? { linkId: input.linkId } : {}), - observedAt: input.observedAt - }), acquisitionGeneration: input.acquisitionGeneration }, session: { @@ -69,7 +69,8 @@ export function createClaudeSessionPublication(input: { translator: input.translator, events: input.events, ...(input.unbindReadingControl ? { unbindReadingControl: input.unbindReadingControl } : {}), - startup: createClaudeSessionStartup() + link, + initialization: input.initialization } } } diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index fbee25044669..92c3f52ec0f7 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -11,7 +11,8 @@ import { identityFor, PROVIDER_SESSION_ID, tick, - claudeStartupSettled + claudeStartupSettled, + claudeOpened } from './claude-structured-session-test-support' import { invokeCanUseTool } from './claude-can-use-tool-test-support' import { @@ -387,7 +388,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { expect(oldPrompt.settled()).toBe(false) expect(events.filter((event) => event.type === 'ended')).toEqual([]) - const replacement = await adapter.acquire({ + await adapter.acquire({ identity: { ...identityFor(), providerHandle: claudeProviderHandle(PROVIDER_SESSION_ID, 'observed-retained-leaf') @@ -396,6 +397,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { spawnToken: 'spawn-10', events: journalSink }) + const replacement = await claudeOpened(adapter, 'session-1') expect(disposeTranslator).toHaveBeenCalledOnce() expect(settlePrompt).toHaveBeenCalledOnce() diff --git a/src/main/claude/claude-structured-session-startup-state.ts b/src/main/claude/claude-structured-session-startup-state.ts deleted file mode 100644 index 2ece1890fe82..000000000000 --- a/src/main/claude/claude-structured-session-startup-state.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Where a Claude start stands. A session is published once its child is spawned, launched with the -// chat's saved options, before the CLI has answered initialize. The host hands it no message until -// `started`; an option write, a control request initialize must answer first, waits here too. - -import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' -import { providerStartupFailureFact } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' -import type { ClaudeSession } from './claude-structured-session-state' -import { withMissingProviderExecutable } from '../provider-process/provider-executable-missing' - -export type ClaudeSessionStartup = { - state: 'pending' | 'proven' | 'failed' - /** The CLI answered initialize: it may have run what it was handed. Before that it ran nothing. */ - answered: boolean - failure: Error | null - /** Resolves once startup has landed or faulted, or the child exited or was closed; never - * rejects. A close must end it: an option write waits here. */ - settled: Promise - end: () => void -} - -export function createClaudeSessionStartup(): ClaudeSessionStartup { - let end: () => void = () => undefined - const ended = new Promise((resolve) => { - end = resolve - }) - return { state: 'pending', answered: false, failure: null, settled: ended, end } -} - -/** Why a start failed; a CLI that was never found says so, whichever error the start met first. */ -export function claudeStartupFailureCause( - session: ClaudeSession, - fallback?: Error -): Error | undefined { - const failure = session.startup.failure ?? fallback - return failure && session.connection.executableMissing - ? withMissingProviderExecutable(failure) - : failure -} - -export function claudeStartupFailureFact(session: ClaudeSession): SubmissionRejectionFact | null { - return session.startup.state === 'failed' - ? providerStartupFailureFact(claudeStartupFailureCause(session)) - : 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 - if (startup.state === 'pending') { - startup.state = 'failed' - startup.failure = error - } - startup.end() -} diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index 7eece9a4ed6d..c3f2ffbb0700 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -1,444 +1,99 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-adapter' -import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' import { adapterAtPublishFor, fakeClaude, identityFor, - USER_MESSAGE, - claudeStartupSettled + claudeOpened } 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 - -type LateSettlement = Parameters< - NonNullable ->[0] +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' -const SLOW_INIT_MS = 12_000 +const ACQUIRE = { identity: identityFor(), fence: 7, spawnToken: 'spawn-9' } -function startingAdapter(claude: ReturnType): { - adapter: ReturnType - events: ClaudeStructuredSessionEvent[] - late: LateSettlement[] -} { +function adapterFor(claude: ReturnType) { const events: ClaudeStructuredSessionEvent[] = [] - const late: LateSettlement[] = [] - const adapter = adapterAtPublishFor( - claude, - {}, - events, - [], - undefined, - undefined, - undefined, - (settlement) => late.push(settlement) - ) - return { adapter, events, late } + return { adapter: adapterAtPublishFor(claude, {}, events), events } } -const ACQUIRE = { identity: identityFor(), fence: 7, spawnToken: 'spawn-9' } -const PROMPT = { sessionId: 'session-1', clientMessageId: 'client-1', body: USER_MESSAGE, fence: 7 } - -describe('Claude structured session publishes before the CLI answers initialize', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - afterEach(() => { - vi.useRealTimers() - }) - - it('creates a session whose init takes longer than any old deadline, then reports its facts', async () => { - const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) - const { adapter, events } = startingAdapter(claude) - - await expect(adapter.acquire(ACQUIRE)).resolves.toBeDefined() +describe('Claude host-awaited open', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('publishes the owned process as starting before initialize answers', async () => { + const claude = fakeClaude({ initDelayMs: 12_000 }) + const { adapter, events } = adapterFor(claude) + const acquisition = await adapter.acquire(ACQUIRE) + expect(acquisition).toMatchObject({ providerChildPhase: 'starting', process: { pid: 4321 } }) + expect(acquisition.link).toBeUndefined() + expect(claude.connections[0].calls).toEqual([]) expect(events.some((event) => event.type === 'options')).toBe(false) - expect(adapter.readCommands('session-1')).toBeUndefined() - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await claudeStartupSettled(adapter, 'session-1') - expect(events.find((event) => event.type === 'options')).toMatchObject({ - models: [{ value: 'claude-sonnet' }] + const open = claudeOpened(adapter, 'session-1') + await vi.advanceTimersByTimeAsync(11_999) + expect(claude.connections[0].calls.map((call) => call.subtype)).toEqual(['initialize']) + await vi.advanceTimersByTimeAsync(1) + await expect(open).resolves.toMatchObject({ + link: expect.objectContaining({ handle: expect.objectContaining({ agent: 'claude' }) }), + reportedOptions: expect.any(Object), + optionRevision: 0 }) - expect(events.some((event) => event.type === 'ended')).toBe(false) - expect(claude.connections[0].closeCount).toBe(0) + expect(events.map((event) => event.type)).not.toContain('started') await adapter.closeAll() }) - it('reports `started` on the initialize answer, never waiting on the settings read', async () => { - const SLOW_SETTINGS_MS = 25_000 - const claude = fakeClaude({ - initDelayMs: SLOW_INIT_MS, - // The fake answers `get_settings` with whatever this resolves to, when it resolves. - settings: new Promise((resolve) => - setTimeout( - () => resolve({ effective: { fastMode: true }, applied: { model: 'claude-haiku-4-5' } }), - SLOW_INIT_MS + SLOW_SETTINGS_MS - ) - ) + it('returns from open without waiting for optional settings', async () => { + let answerSettings: (value: unknown) => void = () => {} + const settings = new Promise((resolve) => { + answerSettings = resolve }) - const { adapter, events } = startingAdapter(claude) + const claude = fakeClaude({ settings }) + const { adapter, events } = adapterFor(claude) await adapter.acquire(ACQUIRE) - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - + const opened = await claudeOpened(adapter, 'session-1') + expect(opened.reportedOptions).toBeDefined() expect(claude.connections[0].calls.map((call) => call.subtype)).toContain('get_settings') - expect(events.filter((event) => event.type === 'started')).toHaveLength(1) expect(events.some((event) => event.type === 'options-reported')).toBe(false) - expect(events.some((event) => event.type === 'auth-diagnostic')).toBe(false) - - await vi.advanceTimersByTimeAsync(SLOW_SETTINGS_MS) - await claudeStartupSettled(adapter, 'session-1') - - expect(events.find((event) => event.type === 'options-reported')).toMatchObject({ - reportedOptions: { model: 'claude-haiku-4-5', fastMode: true } - }) - expect(events.some((event) => event.type === 'auth-diagnostic')).toBe(true) - await adapter.closeAll() - }) - - it('reports `started` with the options its child was launched with, having written none', async () => { - const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS, initModel: 'claude-opus-9' }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire({ ...ACQUIRE, options: { model: 'opus' } }) - expect(claude.connections[0].launch.options.model).toBe('opus') - expect(events.some((event) => event.type === 'started')).toBe(false) - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await claudeStartupSettled(adapter, 'session-1') - - const startedAt = events.findIndex((event) => event.type === 'started') - expect(events[startedAt]).toEqual({ - type: 'started', - sessionId: 'session-1', - fence: 7, - acquisitionGeneration: expect.any(String), - // What the child was launched with, carried so the host never asks the CLI again. - reportedOptions: expect.objectContaining({ model: 'opus' }), - restoreSkippedOptions: [], - // The account listing initialize answered, for the host's catalog. - catalogListing: { models: [expect.objectContaining({ id: 'claude-sonnet' })] }, - // No host revision handed in: nothing has moved it. - optionRevision: 0 - }) - expect(claude.connections[0].calls.map(({ subtype }) => subtype)).toEqual([ - 'initialize', - 'get_settings' - ]) - expect(claude.connections[0].sent).toEqual([]) - expect(events.slice(0, startedAt).some((event) => event.type === 'options')).toBe(true) + answerSettings({ applied: { model: 'claude-haiku-4-5' }, effective: {} }) + await vi.waitFor(() => + expect(events.find((event) => event.type === 'options-reported')).toMatchObject({ + reportedOptions: { model: 'claude-haiku-4-5' } + }) + ) await adapter.closeAll() }) - it('lets an option write wait for startup instead of refusing it', async () => { - const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) - const { adapter } = startingAdapter(claude) + it('rejects an advertised signed-out initialize answer for the host to close once', async () => { + const claude = fakeClaude({ initAccount: { tokenSource: 'none', apiKeySource: 'none' } }) + const { adapter } = adapterFor(claude) await adapter.acquire(ACQUIRE) - 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 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 () => { - const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire({ ...ACQUIRE, options: { model: 'opus' } }) - - await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) - expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) - expect(events.some((event) => event.type === 'started')).toBe(false) - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await claudeStartupSettled(adapter, 'session-1') - expect(events.some((event) => event.type === 'started')).toBe(true) - await adapter.closeAll() - }) - - it('confirms a launched effort only when the settings readback reports that effort', async () => { - const reportsHigh = { - applied: { model: 'claude-sonnet-5', effort: 'high' }, - effective: { model: 'claude-sonnet-5', effortLevel: 'high', env: {} }, - sources: {} - } - const agreeing = fakeClaude({ settings: reportsHigh }) - const disagreeing = fakeClaude({ settings: reportsHigh }) - const started = async (claude: ReturnType, effort: string) => { - const { adapter } = startingAdapter(claude) - await adapter.acquire({ ...ACQUIRE, options: { effort } }) - await claudeStartupSettled(adapter, 'session-1') - return adapter - } - - const kept = await started(agreeing, 'high') - await expect(kept.readOptions({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ - current: { effort: 'high', confirmed: expect.arrayContaining(['effort']) } - }) - // The saved pick stays wanted; only the readback could vouch for it, and it did not. - const unconfirmed = await started(disagreeing, 'low') - const options = await unconfirmed.readOptions({ sessionId: 'session-1', fence: 7 }) - expect(options.current.effort).toBe('low') - expect(options.current.confirmed ?? []).not.toContain('effort') - await kept.closeAll() - await unconfirmed.closeAll() - }) - - // Measured on Claude Code 2.1.280: `--effort xhigh` reads back as `applied.effort: 'xhigh'` with - // `effective` empty, since `effective` holds only the settings files. - it('confirms a launched effort the CLI reports only as applied', async () => { - const claude = fakeClaude({ - settings: { applied: { model: 'claude-opus-5', effort: 'xhigh' }, effective: {}, sources: {} } - }) - const { adapter } = startingAdapter(claude) - await adapter.acquire({ ...ACQUIRE, options: { effort: 'xhigh' } }) - await claudeStartupSettled(adapter, 'session-1') - - const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) - expect(options.current).toMatchObject({ effort: 'xhigh', confirmed: ['effort'] }) - await adapter.closeAll() - }) - - it('ends the session with the exit reason when the CLI dies before init', async () => { - const claude = fakeClaude({ - initDelayMs: SLOW_INIT_MS, - exitBeforeInit: 'claude stream-json exited (code 1): stderr says no' - }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await claudeStartupSettled(adapter, 'session-1') - await adapter.drainObservedExits() - - expect(events.find((event) => event.type === 'ended')).toMatchObject({ - reason: 'claude stream-json exited (code 1): stderr says no', - cause: 'unexpected-exit', - startupUnproven: true - }) - expect(claude.connections[0].sent).toEqual([]) - expect(claude.connections[0].closeCount).toBe(1) - }) - - it('ends a start whose root exit was seen first-hand even when its descendants are unverifiable', async () => { - const claude = fakeClaude({ - initDelayMs: SLOW_INIT_MS, - exitBeforeInit: 'claude stream-json exited (code 1): stderr says no', - unprovenCloseVerdict: { root: 'exited', tree: 'unverifiable' } - }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) - await claudeStartupSettled(adapter, 'session-1') - await adapter.drainObservedExits() - - // A failed start is released on the same evidence a failed create is. - expect(events.find((event) => event.type === 'ended')).toMatchObject({ - cause: 'unexpected-exit', - startupUnproven: true - }) - }) - - it('lands a start whose CLI answers initialize only after minutes', async () => { - const claude = fakeClaude({ initDelayMs: NEVER_MS - 1 }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - - await vi.advanceTimersByTimeAsync(NEVER_MS) - await claudeStartupSettled(adapter, 'session-1') - - expect(events.some((event) => event.type === 'started')).toBe(true) - expect(events.some((event) => event.type === 'ended')).toBe(false) + await expect(claudeOpened(adapter, 'session-1')).rejects.toThrow('not signed in') expect(claude.connections[0].closeCount).toBe(0) - await adapter.closeAll() - }) - - // No deadline ends it: a Stop or a close does, and every message is already written meanwhile. - it('keeps a start whose CLI never answers initialize, taking messages, until it is closed', async () => { - const claude = fakeClaude({ initDelayMs: 10 * NEVER_MS }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) - - await vi.advanceTimersByTimeAsync(NEVER_MS) - - expect(events.some((event) => event.type === 'ended' || event.type === 'started')).toBe(false) - expect(claude.connections[0].closeCount).toBe(0) - expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) - await expect(adapter.closeSession('session-1')).resolves.toBe(true) + await adapter.closeSession('session-1') expect(claude.connections[0].closeCount).toBe(1) }) - it('fails a start at once when a frame names another session before initialize answers', async () => { - const claude = fakeClaude({ initDelayMs: NEVER_MS, initProof: 'none' }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - - claude.connections[0].handlers.onMessage?.({ - type: 'system', - subtype: 'hook_started', - hook_name: 'SessionStart:startup', - session_id: 'foreign-session' - }) - await vi.advanceTimersByTimeAsync(0) - await claudeStartupSettled(adapter, 'session-1') - await adapter.drainObservedExits() - - expect(events.find((event) => event.type === 'ended')).toMatchObject({ - reason: 'claude provider session expected', - failure: { kind: 'startFailed' }, - startupUnproven: true - }) - }) - - // Only a SessionStart hook sends a start frame before the first turn; without one, the - // initialize answer is the whole start. - it('lands a start whose CLI sends no start frame before its first turn', async () => { - const claude = fakeClaude({ initProof: 'none' }) - const { adapter, events } = startingAdapter(claude) + it('rejects a frame that proves a different provider session', async () => { + const claude = fakeClaude({ initSessionId: 'foreign-session' }) + const { adapter } = adapterFor(claude) await adapter.acquire(ACQUIRE) - await claudeStartupSettled(adapter, 'session-1') - - expect(events.some((event) => event.type === 'started')).toBe(true) - await expect(adapter.dispatch(PROMPT)).resolves.toEqual({ state: 'admitted' }) - expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) - await adapter.closeAll() - }) - - it('ends a started session whose first frame names another provider session', async () => { - const claude = fakeClaude({ initProof: 'none' }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - await claudeStartupSettled(adapter, 'session-1') - - claude.connections[0].handlers.onMessage?.({ - type: 'system', - subtype: 'init', - session_id: 'foreign-session' - }) - await adapter.drainObservedExits() - - expect(events.find((event) => event.type === 'ended')).toMatchObject({ - reason: 'claude provider session expected', - cause: 'unexpected-exit' - }) + await expect(claudeOpened(adapter, 'session-1')).rejects.toThrow('provider session expected') + await adapter.closeSession('session-1') expect(claude.connections[0].closeCount).toBe(1) }) - it('ends an unauthenticated start with sign-in guidance', async () => { - const claude = fakeClaude({ initAccount: { apiProvider: 'firstParty', tokenSource: 'none' } }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - await claudeStartupSettled(adapter, 'session-1') - await adapter.drainObservedExits() - - expect(events.find((event) => event.type === 'ended')).toMatchObject({ - reason: expect.stringMatching(/not signed in/), - startupUnproven: true - }) - }) - - // Accounts as Claude 2.1.280 reports them at initialize; the /login key row is from its source. - it.each([ - ['an ANTHROPIC_API_KEY', { tokenSource: 'none', apiKeySource: 'ANTHROPIC_API_KEY' }], - ['a Console /login key', { tokenSource: 'none', apiKeySource: '/login managed key' }], - ['an apiKeyHelper', { tokenSource: 'apiKeyHelper', apiKeySource: 'apiKeyHelper' }], - ['an ANTHROPIC_AUTH_TOKEN', { tokenSource: 'ANTHROPIC_AUTH_TOKEN' }], - ['a third-party provider', { apiProvider: 'bedrock' }] - ])('starts a session Claude authenticates with %s', async (_label, account) => { - const claude = fakeClaude({ initAccount: { apiProvider: 'firstParty', ...account } }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - await claudeStartupSettled(adapter, 'session-1') - - expect(events.some((event) => event.type === 'started')).toBe(true) - expect(events.some((event) => event.type === 'ended')).toBe(false) - await adapter.closeAll() - }) - - it('still refuses a start whose API key source is reported as none', async () => { - const claude = fakeClaude({ - initAccount: { apiProvider: 'firstParty', tokenSource: 'none', apiKeySource: 'none' } - }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - await claudeStartupSettled(adapter, 'session-1') - await adapter.drainObservedExits() - - expect(events.find((event) => event.type === 'ended')).toMatchObject({ - reason: expect.stringMatching(/not signed in/) - }) - }) - - // A Stop that closes a child still starting must end the wait an option write is in, though - // initialize never answers. - it('ends the wait on a start closed before init, without faulting it', async () => { - const claude = fakeClaude({ initDelayMs: 10 * SLOW_INIT_MS }) - const { adapter, events } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - let ended = false - const waited = claudeStartupSettled(adapter, 'session-1').then(() => { - ended = true - }) - - await expect(adapter.closeSession('session-1')).resolves.toBe(true) - await vi.advanceTimersByTimeAsync(0) - await waited - - expect(ended).toBe(true) - const connection = claude.connections[0] - expect(connection.closeCount).toBe(1) - expect(connection.sent).toEqual([]) - expect(connection.calls.map(({ subtype }) => subtype)).not.toContain('get_settings') - expect(events.some((event) => event.type === 'ended' && event.startupUnproven)).toBe(false) - expect(events.some((event) => event.type === 'started')).toBe(false) - }) - - // The CLI answers no control request before initialize; Claude's 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) - await adapter.acquire(ACQUIRE) - await adapter.dispatch(PROMPT) - - await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toEqual({ - cancelled: false + it('lets the host abort a silent initialize wait and close the owned process', async () => { + const claude = fakeClaude({ initDelayMs: 12_000 }) + const { adapter } = adapterFor(claude) + const acquisition = await adapter.acquire(ACQUIRE) + const controller = new AbortController() + const open = adapter.open({ + sessionId: 'session-1', + fence: 7, + acquisitionGeneration: acquisition.acquisitionGeneration ?? null, + signal: controller.signal }) - - expect(claude.connections[0].calls.map(({ subtype }) => subtype)).not.toContain('interrupt') - expect(adapter.stopEndsSession()).toBe(true) - await adapter.closeAll() + controller.abort(new Error('host start expired')) + await expect(open).rejects.toThrow('host start expired') + await adapter.closeSession('session-1') + expect(claude.connections[0].closeCount).toBe(1) }) }) diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index 492b5a769fb1..b8002eff615f 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -1,14 +1,9 @@ -// What Claude reports at initialize, read once the session is published. The initialize answer is -// the start: `started` follows it, and the host hands the child nothing before. The settings read -// after it is optional and never delays that; what it reports is applied as a later options update. -// Every way the start can fail (exit, auth, a foreign session id) faults the published session -// through its exit path; one that stops making progress is ended by the host's startup limit. +// Claude initialize is awaited by the host after process publication. Settings enrichment follows +// independently; the host owns a failed open and its single close. import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' -import type { - StructuredAgentSessionOptionsSkippedEvent, - StructuredAgentSessionStartedEvent -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionOptionsSkippedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' import { @@ -31,10 +26,6 @@ import { readClaudeFastModeFacts, readClaudeSettingsEffort } from './claude-structured-session-options' -import { - failClaudeStartup, - type ClaudeSessionStartup -} from './claude-structured-session-startup-state' import type { ClaudeAuthDiagnostic, ClaudeSession, @@ -45,8 +36,8 @@ import { applyClaudeStartFastMode } from './claude-structured-start-fast-mode' -export type StructuredAgentSessionStartedOptions = Pick< - StructuredAgentSessionStartedEvent, +export type ClaudeOpenFacts = Pick< + StructuredAgentSessionProviderOpenResult, | 'reportedOptions' | 'restoreSkippedOptions' | 'retiredOptions' @@ -57,8 +48,8 @@ export type StructuredAgentSessionStartedOptions = Pick< /** A lifecycle event the start reports, before the session's identity is stamped on it. */ export type ClaudeStartupReport = | ({ - type: 'started' | 'options-reported' - } & StructuredAgentSessionStartedOptions) + type: 'options-reported' + } & ClaudeOpenFacts) | Pick /** What the initialize answer proved: enough to start. */ @@ -82,7 +73,6 @@ export async function readClaudeStartupFacts(input: { initProof: ClaudeInitProof sessionId: string providerSessionId: string - startup: Pick resumesTranscript: boolean account?: AgentSessionAccountKind requestTimeoutMs: number | undefined @@ -90,14 +80,20 @@ export async function readClaudeStartupFacts(input: { }): Promise { // The CLI's first answer has no request deadline of its own; the reads after it do. const initialization = await Promise.race([ - input.connection.initializationResult().then((result) => { - input.startup.answered = true - const authError = claudeInitializationAuthError(result, input.account) - if (authError) { - throw authError - } - return result - }), + input.connection + .initializationResult() + .catch((error: unknown) => { + // The SDK may replace the child's own exit diagnostic with a generic closed-query error. + input.initProof.seen() + throw error + }) + .then((result) => { + const authError = claudeInitializationAuthError(result, input.account) + if (authError) { + throw authError + } + return result + }), input.initProof.promise.then(() => new Promise(() => {})) ]) if (input.connection.closed) { @@ -135,7 +131,7 @@ function applyClaudeInitializeFacts(session: ClaudeSession, facts: ClaudeInitial session.events?.publish() } -/** Settings read after `started`. Values a write made since the read began own are left alone. */ +/** A later settings read cannot overwrite a newer option write. */ function applyClaudeSettingsFacts( session: ClaudeSession, facts: ClaudeStartupFacts, @@ -182,7 +178,7 @@ function claudeStartedReportedOptions( session: ClaudeSession, catalog: unknown[], readMutationSequence = session.optionMutationSequence -): Pick { +): Pick { const { current, catalogListing } = claudeStructuredSessionOptionsFrom( session, catalog, @@ -196,82 +192,49 @@ function claudeStartedReportedOptions( return { reportedOptions: persisted, ...listing } } -/** Applies the initialize answer and reports `started`, so the host hands the child what it holds; - * then reads the optional settings and reports what they add. A failure before `started` faults - * the session so the user sees why it never started; the settings read can fail nothing. */ -export async function settleClaudeSessionStartup(input: { +/** The host awaits initialize and proof while owning the published child. */ +export async function openClaudeSession(input: { session: ClaudeSession facts: Promise - /** Never rejects: an unreadable settings answer reads as null. */ readSettings: () => Promise isCurrent: () => boolean - fault: (error: Error) => void diagnose: (diagnostic: ClaudeAuthDiagnostic) => void - /** The host's option revision now, stamped on each report as its read begins. */ optionRevision: () => number - /** `started` once startup has proven, with what the child now reports, snapshotted from memory; - * then what the settings add, and any saved option the child showed it cannot run. */ + onLateRefusal: (error: Error) => void report: (event: ClaudeStartupReport) => void -}): Promise { +}): Promise { const { session } = input - const superseded = (): boolean => { + const initialized = await input.facts + if (!input.isCurrent()) { + throw new Error('claude session closed before startup completed') + } + initialized.initProof.onRefusal = (error) => { if (input.isCurrent()) { - return false + input.onLateRefusal(error) } - failClaudeStartup(session, new Error('claude session closed before startup completed')) - return true } - let initialized: ClaudeInitializeFacts - try { - initialized = await input.facts - if (superseded()) { - return - } - // From here no read of the proof is pending, so a frame naming another session ends it. - initialized.initProof.onRefusal = (error) => { - failClaudeStartup(session, error) - if (input.isCurrent()) { - input.fault(error) - } - } - applyClaudeInitializeFacts(session, initialized) - input.report({ - type: 'started', - // `list_models` is answered from this same initialize result, so nothing is re-read. A - // saved Fast the launch left out is decided once settings are read, so this read settles - // none of it (an older sequence never rewrites an option). - ...claudeStartedReportedOptions( - session, - readClaudeModels(initialized.initialization), - session.fastModeAtStart ? session.optionMutationSequence - 1 : undefined - ), - restoreSkippedOptions: [...session.restoreSkippedOptions], - ...claudeRetiredOptions(session), - optionRevision: input.optionRevision() - }) - if (session.startup.state === 'pending') { - session.startup.state = 'proven' - } - } catch (caught) { - const error = caught instanceof Error ? caught : new Error(String(caught)) - // A close or exit that already ended startup owns how the session ends. - const endedElsewhere = session.startup.state !== 'pending' - failClaudeStartup(session, error) - if (!endedElsewhere && input.isCurrent()) { - input.fault(error) - } - return + applyClaudeInitializeFacts(session, initialized) + const result: ClaudeOpenFacts = { + ...claudeStartedReportedOptions( + session, + readClaudeModels(initialized.initialization), + session.fastModeAtStart ? session.optionMutationSequence - 1 : undefined + ), + restoreSkippedOptions: [...session.restoreSkippedOptions], + ...claudeRetiredOptions(session), + optionRevision: input.optionRevision() } - await settleClaudeStartupSettings(input, initialized) + void settleClaudeStartupSettings(input, initialized) + return result } async function settleClaudeStartupSettings( - input: Parameters[0], + input: Parameters[0], initialized: ClaudeInitializeFacts ): Promise { const { session } = input const sequence = session.optionMutationSequence - // Only a pick moves it, so this stamp holds however late the host takes `started`. + // Only a pick moves this stamp; a later report cannot replace a newer choice. const optionRevision = input.optionRevision() const settings = await input.readSettings() if (!input.isCurrent()) { @@ -299,7 +262,7 @@ async function settleClaudeStartupSettings( const startFastMode = writtenSinceRead ? null : admitClaudeStartFastMode(session, facts) input.report({ type: 'options-reported', - // `started` already carried this listing to the host. + // Open already returned the initialize listing to the host. reportedOptions: claudeStartedReportedOptions(session, readClaudeModels(facts.initialization)) .reportedOptions, // The one report of what the config resolved: the readback just applied it. diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index f5d7b67777b2..f34c5db28628 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -5,8 +5,7 @@ import type { AgentJournalItemIdentity } from '../../shared/agent-session-journa import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { StructuredAgentSessionOptionsReportedEvent, - StructuredAgentSessionOptionsSkippedEvent, - StructuredAgentSessionStartedEvent + StructuredAgentSessionOptionsSkippedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection, @@ -24,7 +23,8 @@ import type { ClaudeBackgroundTaskTracker } from './claude-background-task-track import type { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import type { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' import type { ClaudeAtRestCommandCatalog } from './claude-at-rest-commands' -import type { ClaudeSessionStartup } from './claude-structured-session-startup-state' +import type { ClaudeInitProof } from './claude-structured-init-proof' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' export type ClaudeAuthDiagnostic = { apiKeySourceConfigured: boolean @@ -63,8 +63,6 @@ export type ClaudeStructuredSessionEvent = fence: number } | { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic } - /** The CLI answered initialize: the host may hand the child input from here. */ - | StructuredAgentSessionStartedEvent | StructuredAgentSessionOptionsReportedEvent | StructuredAgentSessionOptionsSkippedEvent | { @@ -78,10 +76,6 @@ export type ClaudeStructuredSessionEvent = acquisitionGeneration?: string /** Host clock when the end was observed. */ observedAt?: number - /** The child ended before proving startup, so reacquiring would repeat the same start. */ - startupUnproven?: true - /** The child ended before it answered initialize, so it ran nothing it was handed. */ - startupUnanswered?: true } export type ClaudeLateDispatchOutcome = @@ -206,8 +200,15 @@ export type ClaudeSession = { translator: ClaudeJournalTranslator | null events: StructuredAgentSessionEventSink | undefined unbindReadingControl?: () => void - /** Published at spawn with its saved options; init facts land when startup does. */ - startup: ClaudeSessionStartup + /** Provider identity and initialize proof retained for the host-awaited open. */ + link: AgentSessionProviderHandleLink + initialization: { + proof: ClaudeInitProof + account: ClaudeStructuredLaunch['account'] + resumesTranscript: boolean + optionRevision: () => number + onLateRefusal: (error: Error) => void + } } export function mintClaudeAcquisitionGeneration(deps: ClaudeStructuredSessionAdapterDeps): string { diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 9be029def0c8..f1df2d51fe4f 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -17,7 +17,11 @@ import { } from './claude-structured-session-adapter' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' -export { claudeStartupSettled } from './claude-structured-startup-settled-test-support' +import { claudeStartupSettled } from './claude-structured-startup-settled-test-support' +export { + claudeStartupSettled, + claudeOpened +} from './claude-structured-startup-settled-test-support' export const PROVIDER_SESSION_ID = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' @@ -83,10 +87,8 @@ export function fakeClaude( const connections: FakeConnection[] = [] const routes = options.routes ?? {} let replayIndex = 0 - const routed = (subtype: string, params?: Record): unknown => { - const route = routes[subtype] - return route ? route(params) : undefined - } + const routed = (subtype: string, params?: Record): unknown => + routes[subtype]?.(params) const openConnection: typeof openClaudeStreamJsonConnection = async (launch, handlers = {}) => { let cycleInitEmitted = false // Keys mirror the real system/init frame, which carries `model` but no @@ -241,8 +243,7 @@ export function fakeClaude( return { connections, openConnection, routes } } -/** Acquisition resolves only once startup has landed, as suites written before - * publish-first expect; `adapterAtPublishFor` observes the published window itself. */ +/** Direct adapter fixtures await the host-owned open after process publication. */ export function adapterFor( ...args: Parameters ): ClaudeStructuredSessionAdapter { @@ -250,7 +251,7 @@ export function adapterFor( const acquire = adapter.acquire adapter.acquire = async (input) => { const acquisition = await acquire(input) - await adapter['sessions'].get(input.identity.sessionId)?.startup.settled + await claudeStartupSettled(adapter, input.identity.sessionId) return acquisition } return adapter @@ -326,9 +327,7 @@ export function recordingJournalSink(): StructuredAgentSessionEventSink { return { appendItem: () => {}, appendTombstone: () => {}, publish: () => {} } } -export function tick(): Promise { - return new Promise((resolve) => setImmediate(resolve)) -} +export const tick = (): Promise => new Promise((resolve) => setImmediate(resolve)) /** Delivers one frame from Claude on `connection`, under the provider session it runs. */ export function claudeFrame(connection: FakeConnection, message: Record): void { diff --git a/src/main/claude/claude-structured-start-fast-mode.ts b/src/main/claude/claude-structured-start-fast-mode.ts index df997380f1a7..66cb2df04cb9 100644 --- a/src/main/claude/claude-structured-start-fast-mode.ts +++ b/src/main/claude/claude-structured-start-fast-mode.ts @@ -43,7 +43,7 @@ export function admitClaudeStartFastMode( return fastMode } -/** Applies a saved Fast the launch left out after `started`, so nothing waits on it. A refusal +/** Applies a saved Fast the launch left out after open, so nothing waits on it. A refusal * drops it as main's refused restore did, from the record too; silence keeps it wanted and * unconfirmed. A write the user made meanwhile owns the option. */ export async function applyClaudeStartFastMode( diff --git a/src/main/claude/claude-structured-startup-settled-test-support.ts b/src/main/claude/claude-structured-startup-settled-test-support.ts index 721e1acda5a1..1e41f5121cd7 100644 --- a/src/main/claude/claude-structured-startup-settled-test-support.ts +++ b/src/main/claude/claude-structured-startup-settled-test-support.ts @@ -1,10 +1,43 @@ +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import type { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' -/** Resolves once the session's background startup read has landed, faulted, or been ended. */ +const opened = new WeakMap< + ClaudeStructuredSessionAdapter, + Map> +>() + +/** Direct adapter fixtures explicitly join the host-owned open after acquire. */ +export function claudeOpened( + adapter: ClaudeStructuredSessionAdapter, + sessionId: string +): Promise { + let sessions = opened.get(adapter) + if (!sessions) { + sessions = new Map() + opened.set(adapter, sessions) + } + const session = adapter['sessions'].get(sessionId) + if (!session) { + return Promise.reject(new Error(`no Claude session for ${sessionId}`)) + } + const key = `${sessionId}:${session.acquisitionGeneration}` + const previous = sessions.get(key) + if (previous) { + return previous + } + const current = adapter.open({ + sessionId, + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration, + signal: new AbortController().signal + }) + sessions.set(key, current) + return current +} + export async function claudeStartupSettled( adapter: ClaudeStructuredSessionAdapter, sessionId: string ): Promise { - // Element access reaches the adapter's private map, which no production caller needs. - await adapter['sessions'].get(sessionId)?.startup.settled + await claudeOpened(adapter, sessionId) } From 9711350215a1d22c1ccaf2758561c04337508c38 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:53:01 -0700 Subject: [PATCH 09/55] Observe Claude readiness through the host status --- ...ctured-agent-session-claude-hung-start-stop.test.ts | 2 +- ...ured-agent-session-claude-stop-ends-session.test.ts | 2 +- src/main/runtime/structured-claude-runtime-adapter.ts | 10 ++-------- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts index db87e619b933..a74957b657fa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts @@ -215,7 +215,7 @@ it.each(['user-close', 'evict'] as const)( // doubt, as for any running child. it('leaves a message in doubt when the start answered and the chat closes before the echo', async () => { answerInitialize() - await adapter['sessions'].get(SESSION)?.startup.settled + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) const id = await sendHello(true) await host.close(SESSION, 'user-close') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index 9e55397a38f5..73c34011d53d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -133,7 +133,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await adapter['sessions'].get(SESSION)?.startup.settled + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) await Promise.all(lifecycle) }) diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index ae489b9a6013..f331d774c615 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -43,11 +43,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { export function structuredClaudeLifecycleEvent( event: ClaudeStructuredSessionEvent ): StructuredAgentSessionLifecycleEvent | null { - if ( - event.type === 'started' || - event.type === 'options-reported' || - event.type === 'options-skipped' - ) { + if (event.type === 'options-reported' || event.type === 'options-skipped') { return event } // Every exit of a child with an identity, expected or not: the host ends that child's record. @@ -66,9 +62,7 @@ export function structuredClaudeLifecycleEvent( fence: event.fence, acquisitionGeneration: event.acquisitionGeneration, // The instant the translator ended the open turn at; the host reads the exit's turn by it. - ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), - ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}), - ...(event.startupUnanswered ? { startupUnanswered: event.startupUnanswered } : {}) + ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }) } } return null From 9b0ba038cfa3aca53712b4d96a60cd497f61c925 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:07:39 -0700 Subject: [PATCH 10/55] Keep optional readback behind host readiness --- .../claude/claude-structured-session-open.ts | 23 ++++---------- ...-agent-session-claude-compact-stop.test.ts | 5 ++- ...-agent-session-claude-echo-working.test.ts | 7 ++--- ...t-session-claude-opening-send-hold.test.ts | 3 +- ...d-agent-session-claude-queued-stop.test.ts | 5 ++- ...ssion-claude-stop-exit-ends-record.test.ts | 5 ++- ...agent-session-claude-stop-note-end.test.ts | 3 +- ...agent-session-claude-stop-turn-end.test.ts | 5 ++- ...ed-agent-session-owed-work-release.test.ts | 19 ++++++++---- .../structured-agent-session-provider-open.ts | 1 + ...red-agent-session-provider-started.test.ts | 31 +++++++++++++------ ...ructured-agent-session-provider-started.ts | 14 +++++++-- 12 files changed, 67 insertions(+), 54 deletions(-) diff --git a/src/main/claude/claude-structured-session-open.ts b/src/main/claude/claude-structured-session-open.ts index 62d605f6ab52..55e4a7fbc828 100644 --- a/src/main/claude/claude-structured-session-open.ts +++ b/src/main/claude/claude-structured-session-open.ts @@ -2,6 +2,7 @@ import type { StructuredAgentSessionProviderOpenInput, StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { readClaudeStructuredSessionSettings } from './claude-structured-session-acquisition-options' import { openClaudeSession, readClaudeStartupFacts } from './claude-structured-session-startup' import type { @@ -19,6 +20,7 @@ export async function openClaudeStructuredSession(input: { emit: (session: ClaudeSession, event: ClaudeStructuredSessionEvent) => void }): Promise { const { request, session, sessions, deps, emit } = input + request.signal.throwIfAborted() if ( session.fence !== request.fence || session.acquisitionGeneration !== request.acquisitionGeneration @@ -54,22 +56,9 @@ export async function openClaudeStructuredSession(input: { acquisitionGeneration: session.acquisitionGeneration }) }) - if (request.signal.aborted) { - throw request.signal.reason - } - let removeAbort = (): void => {} - const aborted = new Promise((_resolve, reject) => { - const onAbort = (): void => reject(request.signal.reason) - request.signal.addEventListener('abort', onAbort, { once: true }) - removeAbort = () => request.signal.removeEventListener('abort', onAbort) - }) - try { - const result = await Promise.race([opened, aborted]) - if (sessions.get(request.sessionId) !== session || request.signal.aborted) { - throw new Error('claude session closed before startup completed') - } - return { link: session.link, ...result } - } finally { - removeAbort() + const result = await waitForPromiseWithSignal(opened, request.signal) + if (sessions.get(request.sessionId) !== session || request.signal.aborted) { + throw new Error('claude session closed before startup completed') } + return { link: session.link, ...result } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts index 861e7fc7b9f4..d7a47351dc95 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts @@ -12,8 +12,7 @@ import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-s import { fakeClaude, PROVIDER_SESSION_ID, - type FakeConnection, - claudeStartupSettled + type FakeConnection } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -93,7 +92,7 @@ beforeEach(async () => { }) ) expect(attached).toMatchObject({ ok: true }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) }) afterEach(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts index 33fe1fe57511..c5b4905be176 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts @@ -5,15 +5,14 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, expect, it } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID, - claudeStartupSettled + PROVIDER_SESSION_ID } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -85,7 +84,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts index 256db2f8de37..62f983e9ddd8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts @@ -15,7 +15,6 @@ import { nativeChatRowsInDrawOrder } from '../../../shared/native-chat-turn-grou import { nativeChatTurnMembership } from '../../../shared/native-chat-turn-membership' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { - claudeStartupSettled, fakeClaude, PROVIDER_SESSION_ID, type FakeConnection @@ -98,7 +97,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index d08b6fff57f2..84b22c4f535d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -14,8 +14,7 @@ import { projectStructuredAgentSessionStatus } from '../../../shared/structured- import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID, - claudeStartupSettled + PROVIDER_SESSION_ID } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -103,7 +102,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts index 00a0644136c3..91c0afd33317 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -13,8 +13,7 @@ import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-s import { fakeClaude, PROVIDER_SESSION_ID, - type FakeConnection, - claudeStartupSettled + type FakeConnection } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -103,7 +102,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts index 7b005ffb8176..d83cd52c6326 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-note-end.test.ts @@ -12,7 +12,6 @@ import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-a import { claudeUnwrittenUserMessageError } from '../../claude/claude-agent-sdk-user-message-queue' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { - claudeStartupSettled, fakeClaude, PROVIDER_SESSION_ID, type FakeConnection @@ -109,7 +108,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) await Promise.all(lifecycle) await host.subscribe({ id: 'stop-note-live', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts index 6221c2ae8858..41323c407f1a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.ts @@ -14,8 +14,7 @@ import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-s import { fakeClaude, PROVIDER_SESSION_ID, - type FakeConnection, - claudeStartupSettled + type FakeConnection } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -88,7 +87,7 @@ beforeEach(async () => { providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index 1ad43f3144de..f4ffba515285 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -17,8 +17,7 @@ import { AgentHookServer, _internals } from '../../agent-hooks/server' import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID, - claudeStartupSettled + PROVIDER_SESSION_ID } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -192,7 +191,9 @@ describe('a Claude chat whose CLI is still starting', () => { expect(claude.connections[0].closeCount).toBe(0) landInit() - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) await untilSent(claude.connections[0]) expect(claude.connections[0].sent).toEqual([expect.objectContaining({ type: 'user' })]) await vi.waitFor(async () => expect(await dispatchState(sent)).toBe('accepted')) @@ -218,7 +219,9 @@ describe('a Claude chat whose CLI is still starting', () => { // Startup lands just before the window closes and the message is handed over; the agent stays // while its message waits. landInit() - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) await Promise.all(lifecycle) await untilSent(connection) expect(connection.sent).toEqual([expect.objectContaining({ type: 'user' })]) @@ -237,7 +240,9 @@ describe('a Claude chat whose CLI is still starting', () => { it('stops the agent and closes the conversation once its turn has finished and it idled', async () => { await attachStarting() landInit() - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) const answered = await send('answered') await vi.waitFor(async () => expect(await dispatchState(answered)).toBe('accepted')) claude.connections[0].handlers.onMessage?.({ @@ -275,7 +280,9 @@ describe('a chat whose settled lead still has background work running', () => { async function settleTurnLeavingTask(taskType: string): Promise { await attachStarting() landInit() - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) const fanOut = await send('fan out') await vi.waitFor(async () => expect(await dispatchState(fanOut)).toBe('accepted')) frame({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts index 60c9d099f52c..8a91a8389aab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts @@ -23,6 +23,7 @@ export async function openStructuredAgentSessionProviderChild( } const attempt = context.runtimeState.acquireAborts.begin(sessionId) try { + attempt.signal.throwIfAborted() const result = await waitForPromiseWithSignal( open.call(context.deps.adapter, { sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index e742f23fd58e..9fbebde55158 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -11,8 +11,7 @@ import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' import { fakeClaude, - PROVIDER_SESSION_ID, - claudeStartupSettled + PROVIDER_SESSION_ID } from '../../claude/claude-structured-session-test-support' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -142,7 +141,9 @@ describe('a publish-first Claude create whose init is slow', () => { expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() expect(lastPhase()).toBe('starting') - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('claude-opus-9') @@ -156,7 +157,9 @@ describe('a publish-first Claude create whose init is slow', () => { ).resolves.toMatchObject({ ok: true }) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') @@ -166,7 +169,9 @@ describe('a publish-first Claude create whose init is slow', () => { it('keeps the picked model across a resume whose new child starts on its own default', async () => { const params = claudeParams() await host.attach(CALLER, { ...params, options: { model: 'opus' } }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) await Promise.all(lifecycle) await host.close(SESSION, 'evict') const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -180,7 +185,9 @@ describe('a publish-first Claude create whose init is slow', () => { expect(store.getRecord(SESSION)?.options?.model).toBe('opus') expect(lastPhase()).toBe('starting') - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') @@ -190,7 +197,9 @@ describe('a publish-first Claude create whose init is slow', () => { await host.attach(CALLER, claudeParams()) expect(savedListings).toEqual([]) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) await Promise.all(lifecycle) // `started` hands over the listing; the settings readback after it, what the config resolved. @@ -219,7 +228,9 @@ describe('a publish-first Claude create whose init is slow', () => { sessionId: SESSION, listing: expect.objectContaining({ models: LISTED }) }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) await Promise.all(lifecycle) expect(store.getRecord(SESSION)?.options?.model).toBe('claude-opus-9') expect(savedListings.at(-1)).toEqual({ @@ -241,7 +252,9 @@ describe('a publish-first Claude create whose init is slow', () => { return proveOwner(input) } await expect(host.attach(CALLER, claudeParams())).resolves.toMatchObject({ ok: true }) - await claudeStartupSettled(adapter, SESSION) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) await Promise.all(lifecycle) await vi.waitFor(() => 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 8e615e476431..1318096382ba 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 @@ -55,7 +55,7 @@ export type StructuredAgentSessionProviderStartedContext = { now: () => number publishStatus?: (sessionId: string) => void runtimeState: { - startupAttempts: Pick + startupAttempts: Pick acquireAborts: Pick optionRevisions: Pick< StructuredAgentSessionOptionRevisions, @@ -226,10 +226,20 @@ export function noteStructuredAgentSessionProviderStarted( /** What a ready child reports later, such as a read that came after its start: persisted as the * start's report is, never ahead of a send. */ -export function settleStructuredAgentSessionOptionsReported( +export async function settleStructuredAgentSessionOptionsReported( context: StructuredAgentSessionProviderStartedContext, event: StructuredAgentSessionOptionsReportedEvent ): Promise { + const child = context.sessions.get(event.sessionId)?.child + if (child?.phase === 'starting') { + const readiness = context.runtimeState.startupAttempts.readiness(event.sessionId, { + generation: event.acquisitionGeneration, + fence: event.fence + }) + if (!readiness || (await readiness.wait()) !== 'ready') { + return + } + } const admitted = admitReportedOptions(context, event) if (event.catalogListing) { void saveStructuredAgentSessionStartReadbackListing(context, event, event.catalogListing) From 98bf1ac8ade2878dd72afbea093e4451c34e584e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:11:10 -0700 Subject: [PATCH 11/55] Recheck launch permission after host readiness --- .../structured-agent-session-delivery-loop.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index dcfc8b4de1d5..62ec571660c3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -148,7 +148,10 @@ export class StructuredAgentSessionDeliveryLoop { return } if (prepared.readiness) { - await prepared.readiness.wait() + if ((await prepared.readiness.wait()) === 'ready') { + // Re-derive launch permission and queued work after the wait released the lane. + continue + } } const handed = await this.deps.serialize(sessionId, () => this.handOver(sessionId, prepared.awaited) From 226c2a99554663f2a8bc84276f5495aa349ad813 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:16:38 -0700 Subject: [PATCH 12/55] Preserve the awaited child during permission revalidation --- .../structured-agent-session-delivery-loop.ts | 43 ++++++++----------- ...uctured-agent-session-start-failure-row.ts | 31 ++++++++++++- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 62ec571660c3..2785e275da9d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -30,7 +30,8 @@ import type { } from './structured-agent-session-host-types' import { oldestQueuedSubmission, - recordStructuredAgentSessionStartFailure + recordStructuredAgentSessionStartFailure, + refusedStructuredAgentSessionStart } from './structured-agent-session-start-failure-row' import { failedProviderChildStart } from './structured-agent-session-provider-child' import { handOverSubmission } from './structured-agent-session-turns' @@ -148,13 +149,10 @@ export class StructuredAgentSessionDeliveryLoop { return } if (prepared.readiness) { - if ((await prepared.readiness.wait()) === 'ready') { - // Re-derive launch permission and queued work after the wait released the lane. - continue - } + await prepared.readiness.wait() } const handed = await this.deps.serialize(sessionId, () => - this.handOver(sessionId, prepared.awaited) + this.handOver(sessionId, prepared.awaited, prepared.readiness !== null) ) if (handed === 'stop') { return @@ -231,7 +229,8 @@ export class StructuredAgentSessionDeliveryLoop { private async handOver( sessionId: string, - awaited: StructuredAgentSessionProviderChildIdentity | null + awaited: StructuredAgentSessionProviderChildIdentity | null, + waited: boolean ): Promise { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { @@ -271,6 +270,16 @@ export class StructuredAgentSessionDeliveryLoop { if (!next) { return this.stop(sessionId) } + if (waited) { + // Re-check launch permission only after proving the awaited child still exists. + const ready = await this.deps.ensureProviderChild(sessionId, next.clientMessageId) + if (!ready.ok) { + return this.fail(sessionId, this.refusedStart(sessionId, ready)) + } + if (session.child !== awaitedChild) { + return 'continue' + } + } await handOverSubmission( { sessionId, @@ -289,24 +298,8 @@ export class StructuredAgentSessionDeliveryLoop { } /** A start the session refused, as the failure every queued message it was for is rejected with. */ - private refusedStart( - sessionId: string, - { refusal, diagnostic, argumentProblem, expired }: RefusedStart - ): StartFailure { - if (expired) { - return { startKey: null, cause: { failure: agentSessionFailureFact('hostStopped') } } - } - // A conversation no agent ever ran, such as a cleared chat's, failed to start, not restart. - const newSession = this.deps.record(sessionId)?.providerHandleChain.length === 0 - return { - startKey: null, - cause: { - refusal, - ...(diagnostic ? { diagnostic } : {}), - ...(argumentProblem ? { argumentProblem } : {}), - ...(newSession ? { newSession: true as const } : {}) - } - } + private refusedStart(sessionId: string, refused: RefusedStart): StartFailure { + return refusedStructuredAgentSessionStart(this.deps.record(sessionId), refused) } private async fail(sessionId: string, failure: StartFailure): Promise<'stop'> { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index 9206f95fde0b..aaeb3a11584a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -4,7 +4,36 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-qu import { structuredAgentSessionStartFailureRowIdentity } from '../../../shared/structured-agent-session-start-failure-row-key' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import type { StructuredAgentSessionStartFailureWords } from './structured-agent-session-failure-text' +import type { + StructuredAgentSessionStartFailureWords, + StructuredAgentSessionStartFailureCause +} from './structured-agent-session-failure-text' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' + +export function refusedStructuredAgentSessionStart( + record: AgentSessionRecord | null, + { + refusal, + diagnostic, + argumentProblem, + expired + }: Extract +): { startKey: null; cause: StructuredAgentSessionStartFailureCause } { + if (expired) { + return { startKey: null, cause: { failure: agentSessionFailureFact('hostStopped') } } + } + return { + startKey: null, + cause: { + refusal, + ...(diagnostic ? { diagnostic } : {}), + ...(argumentProblem ? { argumentProblem } : {}), + ...(record?.providerHandleChain.length === 0 ? { newSession: true as const } : {}) + } + } +} /** A start that failed, keyed like its row, in the words `structuredAgentSessionStartFailure` gave. */ export type StructuredAgentSessionStartFailure = StructuredAgentSessionStartFailureWords & { From cb796af1d76c4c076846f3a2dfe16047e821612e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:52:19 -0700 Subject: [PATCH 13/55] Await Codex protocol open under host ownership --- .../codex-structured-publish-at-spawn.test.ts | 303 +++++++----------- .../codex/codex-structured-session-acquire.ts | 95 ++++-- ...odex-structured-session-adapter-fixture.ts | 29 +- .../codex-structured-session-adapter.test.ts | 64 ++-- .../codex/codex-structured-session-adapter.ts | 17 +- .../codex/codex-structured-session-close.ts | 41 +-- .../codex/codex-structured-session-start.ts | 226 ++++++------- .../codex/codex-structured-session-state.ts | 10 +- .../codex/codex-structured-starting-child.ts | 55 ---- .../codex/codex-structured-thread-open.ts | 12 +- ...agent-session-codex-scripted-child.test.ts | 51 +++ ...d-agent-session-integration-replay.test.ts | 12 +- 12 files changed, 417 insertions(+), 498 deletions(-) delete mode 100644 src/main/codex/codex-structured-starting-child.ts diff --git a/src/main/codex/codex-structured-publish-at-spawn.test.ts b/src/main/codex/codex-structured-publish-at-spawn.test.ts index 30a9170ec060..1c64cca70a26 100644 --- a/src/main/codex/codex-structured-publish-at-spawn.test.ts +++ b/src/main/codex/codex-structured-publish-at-spawn.test.ts @@ -1,8 +1,7 @@ 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 { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import { adapterFor, @@ -20,171 +19,130 @@ function startup(routes: Parameters[0] = {}) { 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 }) +async function open( + adapter: ReturnType, + acquisitionGeneration: string | undefined, + signal = new AbortController().signal +) { + return adapter.open({ + sessionId: 'session-1', + fence: 7, + acquisitionGeneration: acquisitionGeneration ?? null, + signal + }) +} + +describe('Codex host-awaited open', () => { + it('publishes its process before initialize and returns protocol facts only after thread/start', async () => { + const initialized = Promise.withResolvers() + const threaded = Promise.withResolvers() + const { adapter, codex, events } = startup({ + initialize: () => initialized.promise, + 'thread/start': () => threaded.promise + }) const committed = Promise.withResolvers() const onSpawned = vi.fn(async () => committed.promise) - const acquiring = adapter.acquire({ ...INPUT, onSpawned }) + const acquiring = adapter.acquire({ ...INPUT, onSpawned, options: { model: 'gpt-saved' } }) 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() + const acquired = await acquiring + expect(acquired.process.pid).toBe(4321) + expect(acquired.link).toBeUndefined() + expect(codex.connections[0].calls).toEqual([]) + const opening = open(adapter, acquired.acquisitionGeneration) + await vi.waitFor(() => expect(codex.connections[0].calls[0]?.method).toBe('initialize')) 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', serviceTier: 'priority' }, - optionRevision: () => revision - }) + initialized.resolve({}) 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', serviceTier: 'priority' }, - restoreSkippedOptions: [], - optionRevision: 2 - }) - ) - ) - catalog.resolve({ - data: [{ model: 'gpt-saved', supportedReasoningEfforts: [] }], - nextCursor: null + threaded.resolve({ thread: { id: THREAD_ID }, model: 'gpt-live' }) + expect(await opening).toMatchObject({ + link: { handle: { nativeId: THREAD_ID } }, + reportedOptions: { model: 'gpt-saved' }, + restoreSkippedOptions: [], + optionRevision: 0 }) - await vi.waitFor(() => - expect(codex.connections[0].calls.some((call) => call.method === 'model/list')).toBe(true) - ) - // The listing changes no option the start reported, so it reports nothing more. - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(events.map((event) => event.type)).toEqual(['started']) + expect(events.some((event) => event.type === 'started')).toBe(false) await adapter.closeAll() }) it.each(['initialize', 'thread/start'])( - 'reaps a failed %s and keeps the provider diagnostic', + 'rejects a failed %s without closing or settling it inside the adapter', async (method) => { const gate = Promise.withResolvers() const { adapter, codex, events } = startup({ [method]: () => gate.promise }) - await adapter.acquire(INPUT) + const acquired = await adapter.acquire(INPUT) + const opening = open(adapter, acquired.acquisitionGeneration) 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([]) + await expect(opening).rejects.toThrow('not signed in') + expect(codex.connections[0].closeCount).toBe(0) + expect(events).toEqual([]) + expect(await adapter.closeSession('session-1')).toBe(true) expect(codex.connections[0].closeCount).toBe(1) - await adapter.closeAll() + expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) } ) - it.each(['closeSession', 'disposeSession', 'forceCloseSession', 'releaseAcquisition'] as const)( - 'lets %s stop a held handshake with no late started', + it.each(['initialize', 'thread/start'])( + 'aborts a held %s on Stop with one close and no later ready result', 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) - ) + const { adapter, codex } = startup({ [method]: () => gate.promise }) + const acquired = await adapter.acquire(INPUT) + const controller = new AbortController() + const opening = open(adapter, acquired.acquisitionGeneration, controller.signal) + await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe(method)) + controller.abort(new Error('stopped')) + await expect(opening).rejects.toThrow('stopped') + expect(await adapter.closeSession('session-1')).toBe(true) + gate.resolve(method === 'thread/start' ? { thread: { id: THREAD_ID } } : {}) 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) + expect(codex.connections[0].closeCount).toBe(1) + expect(codex.connections[0].calls.some((call) => call.method === 'turn/start')).toBe(false) } ) - it('ends a child that exits before thread open, without publishing a started event', async () => { + it('returns a resumed link only after thread/resume answers', 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')) + const codex = fakeCodex({ 'thread/resume': () => gate.promise }) + const adapter = adapterFor(codex, { resumeThreadId: THREAD_ID }) + const acquired = await adapter.acquire(INPUT) + const opening = open(adapter, acquired.acquisitionGeneration) + await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('thread/resume')) 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 })) + expect(await opening).toMatchObject({ link: { origin: 'resumed' } }) await adapter.closeAll() }) - it('publishes the resumed thread link only after thread/resume answers', async () => { + it('does not start a fallback thread after Stop during a held resume', 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 adapter = adapterFor(codex, { + resumeThreadId: THREAD_ID, + supersedeIfUnsaved: true }) const acquired = await adapter.acquire(INPUT) - expect(acquired.link).toBeUndefined() + const controller = new AbortController() + const opening = open(adapter, acquired.acquisitionGeneration, controller.signal) 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 }) - }) - }) + controller.abort(new Error('stopped')) + await expect(opening).rejects.toThrow('stopped') + gate.reject( + new CodexAppServerRequestError( + 'thread/resume', + -32600, + `no rollout found for thread id ${THREAD_ID}` ) ) - await adapter.closeAll() + await new Promise((resolve) => setImmediate(resolve)) + expect(codex.connections[0].calls.some((call) => call.method === 'thread/start')).toBe(false) + expect(await adapter.closeSession('session-1')).toBe(true) }) - it('preserves the history-too-large refusal words when restoration ends startup', async () => { - const { adapter, codex, events } = startup({ + + it('rejects oversized history without importing part of it', async () => { + const { adapter, codex } = startup({ 'thread/start': () => ({ thread: { id: THREAD_ID, @@ -206,81 +164,34 @@ describe('Codex startup publishes its child before the handshake', () => { 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." - ) + const acquired = await adapter.acquire({ ...INPUT, events: sink }) + await expect(open(adapter, acquired.acquisitionGeneration)).rejects.toMatchObject({ + name: AgentSessionAcquisitionRefusal.name, + reason: 'historyTooLarge' + }) expect(sink.appendItem).not.toHaveBeenCalled() + expect(codex.connections[0].closeCount).toBe(0) + expect(await adapter.closeSession('session-1')).toBe(true) expect(codex.connections[0].closeCount).toBe(1) }) - it('keeps the exit diagnostic when an initialization timeout has no provider words', async () => { + it('retains natural exit evidence during thread open', 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 }) - ) + const { adapter, codex, events } = startup({ 'thread/start': () => gate.promise }) + const acquired = await adapter.acquire(INPUT) + const opening = open(adapter, acquired.acquisitionGeneration) + 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 expect(opening).rejects.toThrow() + expect(events).toContainEqual( + expect.objectContaining({ + type: 'ended', + cause: 'unexpected-exit', + 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) + expect(codex.connections[0].closeCount).toBe(0) }) }) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 17a28da71563..5a905b8b56c2 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -21,8 +21,8 @@ import { closeCodexPublishedSession, handleCodexSessionExit } from './codex-structured-session-close' -import { startCodexStructuredSession } from './codex-structured-session-start' -import { CodexStartingChild } from './codex-structured-starting-child' +import { openCodexStructuredSession } from './codex-structured-session-start' +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import { assertCodexConnectionOpen, mintCodexAcquisitionGeneration, @@ -149,13 +149,33 @@ export async function acquireCodexStructuredSession(input: { ...(acquireInput.onOutput ? { onOutput: acquireInput.onOutput } : {}), onExit: (error, exit) => { try { - if (attempt.startingChild && !sessions.has(sessionId)) { + if (attempt.acquisitionGeneration && !sessions.has(sessionId)) { attempt.exitProven = true - attempt.startingChild.end( - error, - attempt.cancelled && attempt.requestedClose !== false - ) + attempt.exitError = error + attempt.dispose?.() acquisitions.deleteIfCurrent(sessionId, attempt) + deps.onEvent?.({ + type: 'ended', + sessionId, + fence: acquireInput.fence, + acquisitionGeneration, + reason: error.message, + cause: + attempt.cancelled && attempt.requestedClose !== false + ? 'requested-close' + : 'unexpected-exit', + failure: attempt.cancelled + ? agentSessionFailureFact('hostFault') + : agentSessionFailureFact('providerExited', { + detail: providerDiagnosticOf(error) + }), + observedAt: deps.now?.() ?? Date.now(), + startupUnproven: true + }) + } + if (sessions.get(sessionId)?.connection === acquisition.connection) { + attempt.exitProven = true + attempt.exitError = error } handleCodexSessionExit({ sessions, @@ -188,35 +208,40 @@ export async function acquireCodexStructuredSession(input: { const process = await spawnIdentity.read(connection.pid) acquisitions.assertCurrent(sessionId, attempt) assertCodexConnectionOpen(connection, sessionId) - attempt.startingChild = new CodexStartingChild(acquisitionGeneration, { - sessionId, - fence: acquireInput.fence, - onEvent: deps.onEvent, - dispose: () => { - unbindReadingControl?.() - acquisition.prompts.clear() - acquisition.drain() - translator?.dispose() + attempt.acquisitionGeneration = acquisitionGeneration + let disposed = false + attempt.dispose = () => { + if (disposed) { + return } - }) - void startCodexStructuredSession({ - input: acquireInput, - deps, - launch, - account, - sessions, - acquisitions, - attempt, - connection, - translator, - dispatchEchoes, - subagentExecutions, - setPrimaryThreadId: (threadId) => { - primaryThreadId = threadId - }, - forceCloseUnexpected: input.forceCloseUnexpected, - ...(unbindReadingControl ? { unbindReadingControl } : {}) - }) + disposed = true + unbindReadingControl?.() + acquisition.prompts.clear() + acquisition.drain() + translator?.dispose() + } + attempt.open = (openInput) => + openCodexStructuredSession( + { + input: acquireInput, + deps, + launch, + account, + sessions, + acquisitions, + attempt, + connection, + translator, + dispatchEchoes, + subagentExecutions, + setPrimaryThreadId: (threadId) => { + primaryThreadId = threadId + }, + forceCloseUnexpected: input.forceCloseUnexpected, + ...(unbindReadingControl ? { unbindReadingControl } : {}) + }, + openInput + ) return { process, acquisitionGeneration, providerChildPhase: 'starting' } } catch (error) { if (sessions.get(sessionId)?.connection !== acquisition.connection) { diff --git a/src/main/codex/codex-structured-session-adapter-fixture.ts b/src/main/codex/codex-structured-session-adapter-fixture.ts index 286a676ab076..c9994a1c45f3 100644 --- a/src/main/codex/codex-structured-session-adapter-fixture.ts +++ b/src/main/codex/codex-structured-session-adapter-fixture.ts @@ -1,9 +1,8 @@ -import { vi } from 'vitest' import type { StructuredAgentSessionAcquireInput, - StructuredAgentSessionStartedEvent, StructuredAgentSessionOptionsReportedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import type { AgentJournalMessageItem, AgentSessionJournalIdentity @@ -24,11 +23,11 @@ import { codexProviderHandle } from '../../shared/agent-session-provider-handle- const startLinks = new WeakMap< CodexStructuredSessionAdapter, - Map> + Map> >() const startReports = new WeakMap< CodexStructuredSessionAdapter, - Map + Map >() export function codexStartReport(adapter: CodexStructuredSessionAdapter, sessionId = 'session-1') { @@ -48,11 +47,16 @@ export async function acquireReadyCodexForTest( 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') - } + const result = await adapter.open({ + sessionId: input.identity.sessionId, + fence: input.fence, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal }) + startReports.get(adapter)?.set(input.identity.sessionId, result) + if (result.link) { + startLinks.get(adapter)?.set(input.identity.sessionId, result.link) + } return acquired } @@ -158,10 +162,10 @@ export function adapterFor( deps: Partial = {} ): CodexStructuredSessionAdapter { let acquisitionGeneration = 0 - const links = new Map>() + const links = new Map>() const reports = new Map< string, - StructuredAgentSessionStartedEvent | StructuredAgentSessionOptionsReportedEvent + StructuredAgentSessionProviderOpenResult | StructuredAgentSessionOptionsReportedEvent >() const adapter = new CodexStructuredSessionAdapter({ resolveLaunch: async () => ({ @@ -178,10 +182,7 @@ export function adapterFor( mintAcquisitionGeneration: () => `generation-${++acquisitionGeneration}`, ...deps, onEvent: (event) => { - if (event.type === 'started' && event.link) { - links.set(event.sessionId, event.link) - } - if (event.type === 'started' || event.type === 'options-reported') { + if (event.type === 'options-reported') { reports.set(event.sessionId, event) } else { events.push(event) diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index a95021a46ec4..fcac5dba95db 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -271,41 +271,45 @@ describe('CodexStructuredSessionAdapter.acquire', () => { expect(codex.connections[0].closeCount).toBe(0) }) - it('refuses a resume that lands on a different thread and reaps the child', async () => { + it('rejects a resume that lands on a different thread; the host closes once', async () => { const codex = fakeCodex({ 'thread/resume': () => ({ thread: { id: 'thread-other' } }) }) - const 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') - }) - ) - ) + const adapter = adapterFor(codex, { resumeThreadId: 'thread-proven' }) + const acquired = await adapter.acquire({ + identity: identityFor('session-1'), + fence: 9, + spawnToken: 'spawn-9' + }) + await expect( + adapter.open({ + sessionId: 'session-1', + fence: 9, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + ).rejects.toThrow('resumed thread-other instead of thread-proven') + expect(codex.connections[0].closeCount).toBe(0) + expect(await adapter.closeSession('session-1')).toBe(true) expect(codex.connections[0].closeCount).toBe(1) }) - it('refuses a thread Codex never named', async () => { + it('rejects a thread Codex never named; the host closes once', async () => { const codex = fakeCodex({ 'thread/start': () => ({}) }) - 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') - }) - ) - ) + const adapter = adapterFor(codex) + const acquired = await adapter.acquire({ + identity: identityFor('session-1'), + fence: 1, + spawnToken: 'spawn-9' + }) + await expect( + adapter.open({ + sessionId: 'session-1', + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + ).rejects.toThrow('did not name the thread') + expect(codex.connections[0].closeCount).toBe(0) + expect(await adapter.closeSession('session-1')).toBe(true) expect(codex.connections[0].closeCount).toBe(1) }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 61b696f35d6e..a8d250bb1c5f 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -9,6 +9,7 @@ import type { StructuredAgentSessionAdapter, StructuredAgentSessionSetOptionInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenInput } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' @@ -101,6 +102,14 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap this.teardown.forceCloseUnexpected(sessionId, fence, acquisitionGeneration, reason) }) + open = (input: StructuredAgentSessionProviderOpenInput) => { + const attempt = this.acquisitions.get(input.sessionId) + if (!attempt?.open) { + return Promise.reject(new Error(`no spawned codex child for session ${input.sessionId}`)) + } + return attempt.open(input) + } + /** Buffers pre-publication events and drops events from superseded children. */ private deliver( acquisition: CodexAcquisitionAttempt['window'], @@ -179,12 +188,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap 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 - ) + if (starting?.acquisitionGeneration === acquisitionGeneration) { + return !starting.exitProven && starting.window.connection?.pid !== undefined } return ( session?.acquisitionGeneration === acquisitionGeneration && diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts index a27a981d29e7..d5a0a90ec5b9 100644 --- a/src/main/codex/codex-structured-session-close.ts +++ b/src/main/codex/codex-structured-session-close.ts @@ -1,4 +1,3 @@ -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 '@orca/process-host/close-process-registry' @@ -36,25 +35,13 @@ 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: session.startingChild - ? codexStartupFailureFact( - session.startingChild.failure ?? input.error, - session.orcaClose?.requested ?? false, - input.error - ) - : input.closedByOrca - ? agentSessionFailureFact('hostFault') - : agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(input.error) }), + failure: 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, - ...(session.startingChild - ? { - startupUnproven: true, - ...(!session.startingChild.initializeAnswered ? { startupUnanswered: true } : {}) - } - : {}) + observedAt: session.exitObservedAt } 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 @@ -67,9 +54,6 @@ export function handleCodexSessionExit(input: { reason: admission.reason }) } - if (session.startingChild) { - session.startingChild.ended = true - } session.ended = true // Nothing can echo for this child any more; the journal's pending-submission // recovery is what settles the sends these were armed for. @@ -107,6 +91,10 @@ export async function closeCodexPublishedSession( ) { return false } + if (session.ended && session.connection.closed) { + sessions.delete(sessionId) + return true + } // Sink-failure recovery force-closes the child but must preserve the // observed-exit cause so host lease settlement runs as an unexpected death. session.orcaClose ??= { @@ -144,6 +132,19 @@ export async function closeCodexSession( requestedClose = true ): Promise { const attempt = acquisitions.get(sessionId) + const published = sessions.get(sessionId) + if (attempt && published?.connection === attempt.window.connection) { + attempt.cancelled = true + attempt.requestedClose = requestedClose + const closed = await closeCodexPublishedSession(sessions, sessionId, onEvent, { + requestedClose, + ...(logger ? { logger } : {}) + }) + if (closed) { + acquisitions.deleteIfCurrent(sessionId, attempt) + } + return closed + } if (!(await cancelCodexAcquisitionAttempt(attempt, requestedClose))) { return false } diff --git a/src/main/codex/codex-structured-session-start.ts b/src/main/codex/codex-structured-session-start.ts index a4a3085cb928..74975510502e 100644 --- a/src/main/codex/codex-structured-session-start.ts +++ b/src/main/codex/codex-structured-session-start.ts @@ -1,6 +1,11 @@ import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' +import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { + StructuredAgentSessionProviderOpenInput, + StructuredAgentSessionProviderOpenResult +} from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import { initializeCodexAppServerConnection } from './codex-app-server-handshake' import type { CodexAppServerConnection } from './codex-app-server-connection' import { CodexBackgroundTaskTracker, codexChildWorkSink } from './codex-background-task-tracker' @@ -10,7 +15,6 @@ import type { CodexJournalTranslator } from './codex-structured-journal-translat 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 @@ -30,7 +34,7 @@ import { 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 = { +type CodexOpenContext = { input: StructuredAgentSessionAcquireInput deps: CodexStructuredSessionAdapterDeps launch: CodexStructuredLaunch @@ -47,144 +51,112 @@ type CodexStartInput = { unbindReadingControl?: () => void } -/** Runs after acquisition publishes the child; optional discovery never holds readiness. */ -export async function startCodexStructuredSession(start: CodexStartInput): Promise { +/** The host owns the open promise and closes the spawned child if this rejects. */ +export async function openCodexStructuredSession( + start: CodexOpenContext, + openInput: StructuredAgentSessionProviderOpenInput +): Promise { const { input, deps, launch, sessions, acquisitions, attempt, connection, translator } = start const sessionId = input.identity.sessionId - const child = attempt.startingChild - if (!child) { - return + const generation = attempt.acquisitionGeneration + if ( + generation === undefined || + openInput.sessionId !== sessionId || + openInput.fence !== input.fence || + openInput.acquisitionGeneration !== generation + ) { + throw new Error('codex open does not match the spawned child') } const current = (): void => { + throwIfSignalAborted(openInput.signal) + if (attempt.exitError) { + throw attempt.exitError + } acquisitions.assertCurrent(sessionId, attempt) assertCodexConnectionOpen(connection, sessionId) - if (child.ended) { - throw new Error('codex child ended during startup') - } } - try { - // 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, { + current() + await waitForPromiseWithSignal( + initializeCodexAppServerConnection(connection, STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS), + openInput.signal + ) + current() + const threadLaunch = await waitForPromiseWithSignal( + withCodexVisualsThreadConfig(connection, launch, { sessionId, ...(deps.logger ? { logger: deps.logger } : {}) - }) - current() - const optionRevision = input.optionRevision?.() ?? 0 - const opened = await openCodexThread( + }), + openInput.signal + ) + current() + const optionRevision = input.optionRevision?.() ?? 0 + const opened = await waitForPromiseWithSignal( + openCodexThread( connection, threadLaunch, - STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS + STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS, + openInput.signal + ), + openInput.signal + ) + current() + start.setPrimaryThreadId(opened.threadId) + const restored = translator?.restoreThread(opened.threadId, opened.thread ?? {}) + if (restored && !restored.accepted) { + throw AgentSessionAcquisitionRefusal.historyTooLarge( + 'Codex thread history exceeds the bounded restore queue; history was not partially imported.' ) - 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 session: CodexSession = { - account: start.account, - connection, - startingChild: child, - ...codexSessionLifecycle(input.fence, child.acquisitionGeneration), + } + const catalogAccess = codexAcquireCatalogAccess(deps, launch) + const session: CodexSession = { + account: start.account, + connection, + ...codexSessionLifecycle(input.fence, generation), + threadId: opened.threadId, + historyMode: opened.historyMode, + activeTurnIds: new Set(), + abortedTurnIds: new Set(), + prompts: attempt.window.prompts, + options: restoredCodexSessionOptions(input.options), + reportedOptions: reportedCodexThreadOptions(opened), + ...(catalogAccess ? { catalogAccess } : {}), + dispatchEchoes: start.dispatchEchoes, + translator, + backgroundTasks: new CodexBackgroundTaskTracker( + opened.threadId, + start.subagentExecutions, + codexChildWorkSink(sessionId, deps) + ), + forceCloseUnexpected: (reason) => + start.forceCloseUnexpected(sessionId, input.fence, generation, reason), + ...(start.unbindReadingControl ? { unbindReadingControl: start.unbindReadingControl } : {}) + } + current() + sessions.set(sessionId, session) + for (const event of attempt.window.drain()) { + event() + } + current() + acquisitions.deleteIfCurrent(sessionId, attempt) + startBackgroundCodexCatalogRefresh({ + session, + sessionId, + sessions, + timeoutMs: deps.requestTimeoutMs, + logger: deps.logger + }) + return { + link: codexProviderHandleLink({ threadId: opened.threadId, - 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 } : {}) - } - current() - sessions.set(sessionId, session) - for (const event of attempt.window.drain()) { - event() - } - current() - deps.onEvent?.({ - type: 'started', - sessionId, + ...(opened.supersededThreadId + ? { resumed: false, supersedesThreadId: opened.supersededThreadId } + : { resumed: launch.resumeThreadId !== null }), 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 - }) - } 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. - } - } + linkId: deps.mintLinkId?.(), + observedAt: deps.now?.() ?? Date.now() + }), + reportedOptions: reportedCodexSessionOptions(session), + restoreSkippedOptions: [], + optionRevision } } diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 01961bb95b7e..6de26ff66ae2 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -27,7 +27,7 @@ import type { AgentModelCatalogSessionAccess, AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import type { CodexStartingChild } from './codex-structured-starting-child' +import type { StructuredAgentSessionProviderOpen } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import type { NativeChatVisualsLaunch } from '../native-chat/native-chat-visuals-delivery' export type CodexSessionCatalogAccess = AgentModelCatalogSessionAccess @@ -124,7 +124,6 @@ export type CodexStructuredSessionAdapterDeps = { export type CodexSession = { account?: AgentSessionAccountKind connection: CodexAppServerConnection - startingChild?: CodexStartingChild ended: boolean /** First observed child exit survives rejected settlement admission. */ exitObservedAt?: number @@ -203,10 +202,13 @@ export function requireLiveCodexSession( export type CodexAcquisitionAttempt = { window: CodexAcquisitionWindow - startingChild?: CodexStartingChild + acquisitionGeneration?: string + open?: StructuredAgentSessionProviderOpen + dispose?: () => void cancelled: boolean requestedClose?: boolean exitProven: boolean + exitError?: Error finished: Promise finish: () => void } @@ -308,7 +310,7 @@ export async function cancelCodexAcquisitionAttempt( }) if (closed) { attempt.exitProven = true - attempt.startingChild?.end(new Error('codex session closed'), requestedClose) + attempt.dispose?.() } return closed } diff --git a/src/main/codex/codex-structured-starting-child.ts b/src/main/codex/codex-structured-starting-child.ts deleted file mode 100644 index 9dc006d051a7..000000000000 --- a/src/main/codex/codex-structured-starting-child.ts +++ /dev/null @@ -1,55 +0,0 @@ -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-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index c6e686869b7a..e3200b07c352 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -5,6 +5,7 @@ // recording it would make the durable handle chain lie about what this session // actually proved. +import { throwIfSignalAborted } from '../../shared/abort-signal-reason' import { isCodexAppServerRequestError, type CodexAppServerConnection @@ -59,7 +60,8 @@ export async function openCodexThread( /** Config overrides for this thread, on start and resume alike. */ threadConfig?: Record }, - timeoutMs: number | undefined + timeoutMs: number | undefined, + signal?: AbortSignal ): Promise { const resumeThreadId = launch.resumeThreadId const threadSettings = { @@ -67,12 +69,14 @@ export async function openCodexThread( ...launch.permissionPolicy, ...(launch.threadConfig ? { config: launch.threadConfig } : {}) } - const startThread = (): Promise => - connection.request( + const startThread = (): Promise => { + throwIfSignalAborted(signal) + return connection.request( 'thread/start', { ...threadSettings, ...(launch.model ? { model: launch.model } : {}) }, { timeoutMs } ) + } let supersededThreadId: string | undefined let opened: unknown if (!resumeThreadId) { @@ -85,12 +89,14 @@ export async function openCodexThread( ...(launch.resumePath ? { path: launch.resumePath } : {}) } try { + throwIfSignalAborted(signal) opened = await connection.request( 'thread/resume', { ...resumeParams, excludeTurns: true }, { timeoutMs } ) } catch (error) { + throwIfSignalAborted(signal) if (!launch.supersedeIfUnsaved || !isCodexNoRolloutError(error, resumeThreadId)) { throw error } diff --git a/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts index 33cfed22b7a8..f2d87dcbe8da 100644 --- a/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts +++ b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts @@ -127,3 +127,54 @@ it('hands Codex a model and effort picked while it started before the first mess effort: 'high' }) }) + +it('rejects a held send once when Codex initialization refuses and closes the child once', async () => { + const child = codexScriptedChild() + const { host, fence } = await attachedHost(child) + const body = hostTestMessage('never delivered') + const sent = await host.send(caller, { + envelope: envelope('agentSession.send', fence, { body }), + body + }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + await vi.waitFor(() => + expect(child.requests().some((request) => request.method === 'initialize')).toBe(true) + ) + child.failHandshake('Please sign in to Codex') + await vi.waitFor(async () => { + const { submissions } = await host.journalSnapshot(HOST_TEST_SESSION) + const rejected = submissions.filter( + (entry) => entry.clientMessageId === sent.value.clientMessageId + ) + expect(rejected).toHaveLength(1) + expect(rejected[0]?.dispatchState).toBe('rejected') + }) + expect(child.closes()).toBe(1) + expect(child.spawns()).toBe(1) + expect(child.prompts()).toEqual([]) +}) + +it('stops while thread/start is held and never hands over the accepted send', async () => { + const thread = Promise.withResolvers() + const child = codexScriptedChild({ 'thread/start': () => thread.promise }) + child.holdHandshakes = false + const { host, fence } = await attachedHost(child) + const body = hostTestMessage('never delivered') + const sent = await host.send(caller, { + envelope: envelope('agentSession.send', fence, { body }), + body + }) + expect(sent.ok).toBe(true) + await vi.waitFor(() => + expect(child.requests().some((request) => request.method === 'thread/start')).toBe(true) + ) + expect( + (await host.cancel(caller, { envelope: envelope('agentSession.cancel', fence, {}) })).ok + ).toBe(true) + thread.resolve({ thread: { id: 'scripted-codex-thread' } }) + await host.collaboratorsForTests().serialize(HOST_TEST_SESSION, async () => {}) + expect(child.closes()).toBe(1) + expect(child.prompts()).toEqual([]) +}) diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index c55bdb2f9321..f33e9e2b6bad 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -122,16 +122,12 @@ describe('a structured codex session over agentSession.*', () => { delta: 'Buffered while the journal opens.' }) - let stopped = false - const stopping = stopStructuredAgentSessionRuntime().then(() => { - stopped = true - }) - await new Promise((resolve) => setImmediate(resolve)) - const waitedForJournalBind = !stopped openGate.resolve() await creating - await stopping - expect(waitedForJournalBind).toBe(true) + await vi.waitFor(() => + expect(harness.codex.live().calls.some((entry) => entry.method === 'thread/start')).toBe(true) + ) + await stopStructuredAgentSessionRuntime() const identity = { sessionId: SESSION, From 18c8215fd86d3650e358ceed5a16a7384d06d885 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:04:22 -0700 Subject: [PATCH 14/55] Await ACP protocol open in the host --- src/main/acp/acp-structured-acquire.ts | 96 +++++++++---------- .../acp-structured-adapter.test-support.ts | 50 ++++++---- src/main/acp/acp-structured-auth.test.ts | 4 +- .../acp/acp-structured-child-lifecycle.ts | 71 ++++++-------- src/main/acp/acp-structured-child.ts | 61 ++++-------- src/main/acp/acp-structured-handshake.ts | 60 +++++------- .../acp-structured-publish-at-spawn.test.ts | 61 ++++++++---- ...ructured-session-adapter-lifecycle.test.ts | 37 ++++++- ...-structured-session-adapter-resume.test.ts | 2 +- .../acp-structured-session-adapter.test.ts | 3 +- .../acp/acp-structured-session-adapter.ts | 60 +++++++----- src/main/acp/acp-structured-session.ts | 3 + src/main/acp/acp-structured-sign-in.test.ts | 20 ++-- 13 files changed, 273 insertions(+), 255 deletions(-) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index a72dc4670467..aafd04d0bc79 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -2,23 +2,18 @@ // 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 +// and the host receives protocol facts once that session answers. The handshake has no time bound of // its own: the host's startup limit, or a close, Stop or quit, stops it at any point. import { initializeAcpStructuredSession } from './acp-structured-handshake' import { AgentSessionPreSpawnError, type AgentSessionAcquisition, - type StructuredAgentSessionAcquireInput, - type StructuredAgentSessionStartedEvent + type StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' import { providerSpawnedProcessIdentity } from '../provider-process/provider-spawned-process-identity' -import { - assertAcpStartingSession, - type AcpStructuredChild, - type AcpStartingSession -} from './acp-structured-child' +import { assertAcpStartingSession, type AcpStructuredChild } from './acp-structured-child' import type { AcpSessionEvent } from './acp-session-runtime' import type { AcpStructuredConnection } from './acp-structured-connection' import { acpAgentName } from './acp-structured-agent-definitions' @@ -27,11 +22,7 @@ import { NO_ACP_CHILD_STOPS } from './acp-structured-child-stop' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' import { AcpStructuredOptions } from './acp-structured-options' import { AcpStructuredPrompts } from './acp-structured-prompts' -import { - asReattachHistory, - routeAcpSessionEvent, - type AcpStructuredSession -} from './acp-structured-session' +import { asReattachHistory, routeAcpSessionEvent } from './acp-structured-session' import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' import type { AcpStructuredTurnsDeps } from './acp-structured-turns' import { RequestPermissionResponseSchema } from './generated/acp-protocol.generated' @@ -53,13 +44,11 @@ export async function acquireAcpStructuredSession(input: { /** The protocol broke with the process perhaps still running. Null while starting: then the * start itself fails. */ onConnectionLost: (session: AcpStructuredChild | null, error: Error) => void - onReady: (session: AcpStructuredSession, event: StructuredAgentSessionStartedEvent) => void onSettled: AcpStructuredTurnsDeps['settle'] forceClose: (sessionId: string) => void }): Promise<{ acquisition: AgentSessionAcquisition - session: AcpStartingSession - initialize: () => Promise + session: AcpStructuredChild }> { const { acquire, deps, generation } = input const { spec } = deps @@ -95,9 +84,11 @@ 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?.phase === 'ready' && session.turns.acceptsRequests, + () => session?.phase === 'ready' && session.turns?.acceptsRequests === true, () => session?.phase === 'ready' && + session.turns !== null && + session.lane !== null && (session.turns.acceptsRequests || (!session.turns.running && !session.turns.stopped && session.lane.openTurnId !== null)) ) @@ -121,7 +112,7 @@ export async function acquireAcpStructuredSession(input: { clientInfo: { name: 'orca', version: '1' }, ...(acquire.onOutput ? { onOutput: acquire.onOutput } : {}), onPermission: (request, context) => { - if (session?.phase !== 'ready' || !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' } } @@ -205,13 +196,12 @@ export async function acquireAcpStructuredSession(input: { providerSessionId, dialect: spec.dialect, now, - childStops: () => - (session?.phase === 'ready' ? session.childStops : undefined) ?? NO_ACP_CHILD_STOPS, + childStops: () => session?.childStops ?? NO_ACP_CHILD_STOPS, ...acpLaneChildWorkDelivery(deps, sessionId), logger: deps.logger ?? createStructuredAgentSessionLogger(), onInputAccepted: (clientMessageId) => { if (session?.phase === 'ready') { - session.turns.accept(clientMessageId) + session.turns?.accept(clientMessageId) } }, onFailed: () => input.forceClose(sessionId) @@ -235,54 +225,54 @@ export async function acquireAcpStructuredSession(input: { } await identity.onSpawned(pid) const process = await identity.read(pid) - const starting: AcpStartingSession = { + const child: AcpStructuredChild = { phase: 'starting', sessionId, fence: acquire.fence, acquisitionGeneration: generation, spec, connection, + lane: null, + turns: null, + options, + prompts, + childStops: undefined, + restoreSkipped: [], closeRequested: false, journalClosed: null, ended: false, exitObservedAt: null, - startupAnswered: false, + open: (signal) => + initializeAcpStructuredSession({ + acquire, + deps, + generation, + launch, + connection, + options, + prompts, + child, + abandoned: () => signal.aborted, + lanes: { + create: makeLane, + clear: () => { + slot.lane = null + }, + finishLoad: () => { + slot.reattaching = false + } + }, + onSettled: input.onSettled + }), dispose: () => { slot.lane?.dispose() early.length = 0 } } - session = starting - assertAcpStartingSession(starting, input.abandoned) - const initialize = () => - initializeAcpStructuredSession({ - acquire, - deps, - generation, - launch, - connection, - options, - 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) - } - }) + session = child + assertAcpStartingSession(child, input.abandoned) return { acquisition: { process, acquisitionGeneration: generation, providerChildPhase: 'starting' }, - session: starting, - initialize + session: child } } diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index e273dce91135..7049381ae810 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -4,6 +4,7 @@ export { FakeAcpChild, PID } from './acp-scripted-connection.test-fixture' // protocol runtime, translator and assembler, and a real on-disk journal to read back. import { vi } from 'vitest' +import { z } from 'zod' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionLifecycleEvent, @@ -56,6 +57,7 @@ export type AcpAdapterRig = { lifecycle: StructuredAgentSessionLifecycleEvent[] readonly ended: Extract[] started(): StructuredAgentSessionStartedEvent + openFailure(): unknown settled: Parameters>[0][] acquire(options?: { fence?: number @@ -87,6 +89,7 @@ export async function openAcpAdapterRig( const spawned: string[] = [] const lifecycle: StructuredAgentSessionLifecycleEvent[] = [] const settled: AcpAdapterRig['settled'] = [] + let openFailure: unknown const startListeners = new Set<() => void>() const spec = options.spec ?? GROK const resolveLaunch: AcpStructuredSessionAdapterDeps['resolveLaunch'] = @@ -127,10 +130,18 @@ export async function openAcpAdapterRig( }) }) const opened = { sessionId: PROVIDER_SESSION, configOptions: GROK_CONFIG_OPTIONS } + let configOptions = GROK_CONFIG_OPTIONS agent.on('session/new', (frame) => agent.reply(frame, opened)) agent.on('session/load', (frame) => agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) ) + agent.on('session/set_config_option', (frame) => { + const parsed = z.object({ configId: z.string(), value: z.string() }).parse(frame.params) + configOptions = configOptions.map((option) => + option.id === parsed.configId ? { ...option, currentValue: parsed.value } : option + ) + agent.reply(frame, { configOptions }) + }) options.script?.(agent) return child }, @@ -169,9 +180,10 @@ export async function openAcpAdapterRig( } return event }, + openFailure: () => openFailure, settled, acquire: async (acquireOptions = {}) => { - const beginning = lifecycle.length + openFailure = undefined const acquisition = await adapter.acquire({ identity: { sessionId: SESSION, @@ -192,24 +204,24 @@ export async function openAcpAdapterRig( } }) 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() - }) + try { + const opened = await adapter.open({ + sessionId: SESSION, + fence: acquireOptions.fence ?? 1, + acquisitionGeneration: acquisition.acquisitionGeneration ?? null, + signal: acquireOptions.signal ?? new AbortController().signal + }) + lifecycle.push({ + ...opened, + type: 'started', + sessionId: SESSION, + fence: acquireOptions.fence ?? 1, + acquisitionGeneration: acquisition.acquisitionGeneration ?? '' + }) + } catch (error) { + openFailure = error + await adapter.closeSession(SESSION) + } } return acquisition }, diff --git a/src/main/acp/acp-structured-auth.test.ts b/src/main/acp/acp-structured-auth.test.ts index e47f77ef436c..2d83832ea434 100644 --- a/src/main/acp/acp-structured-auth.test.ts +++ b/src/main/acp/acp-structured-auth.test.ts @@ -61,7 +61,7 @@ async function signInWith(options: { }) await rig.acquire() const methods = rig.sent('authenticate').map((frame) => METHOD.parse(frame.params).methodId) - return { rig, acquired: rig.ended.length ? rig.ended[0] : rig.started(), methods } + return { rig, acquired: rig.openFailure() ?? rig.started(), methods } } describe('Grok sign-in on its own machine', () => { @@ -91,7 +91,7 @@ describe('Grok sign-in on its own machine', () => { authMethods: [{ id: 'browser', name: 'Browser' }] }) expect(methods).toEqual([]) - expect(acquired).toMatchObject({ type: 'ended', failure: { kind: 'notSignedIn' } }) + expect(acquired).toMatchObject({ reason: 'notSignedIn' }) }) it('signs in the same way when it reopens a saved session', async () => { diff --git a/src/main/acp/acp-structured-child-lifecycle.ts b/src/main/acp/acp-structured-child-lifecycle.ts index 47945cd70139..3a7decf5cbde 100644 --- a/src/main/acp/acp-structured-child-lifecycle.ts +++ b/src/main/acp/acp-structured-child-lifecycle.ts @@ -1,14 +1,9 @@ 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 { isAcpReadySession, type AcpStructuredChild } from './acp-structured-child' +import type { AcpStructuredConnection } from './acp-structured-connection' import { closeAcpSessionJournal, endAcpStructuredSession } from './acp-structured-session' +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' import { ACP_STOP_GRACE_MS, type AcpStructuredSessionAdapterDeps @@ -48,7 +43,7 @@ export class AcpStructuredChildren { if (!session || session.ended) { return true } - if (session.phase === 'starting') { + if (!isAcpReadySession(session)) { session.closeRequested ||= requested && session.journalClosed === null session.dispose() session.closing ??= session.connection @@ -86,10 +81,29 @@ export class AcpStructuredChildren { /** 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 { + if (isAcpReadySession(session)) { endAcpStructuredSession(session, observedAt, this.deps.onEvent) + } else if (!session.ended) { + session.ended = true + session.exitObservedAt = observedAt + session.dispose() + this.deps.onEvent?.({ + type: 'ended', + sessionId: session.sessionId, + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration, + observedAt, + cause: session.closeRequested ? 'requested-close' : 'unexpected-exit', + reason: + session.connection.stderrTail() || `${session.spec.agent} ACP agent ended while starting`, + failure: agentSessionFailureFact( + session.closeRequested ? 'hostStopped' : 'providerExited', + { + detail: providerDiagnostic(session.connection.stderrTail(), 'person') + } + ), + ...(!session.closeRequested ? { startupUnproven: true } : {}) + }) } if (this.sessions.get(session.sessionId) === session) { this.sessions.delete(session.sessionId) @@ -106,8 +120,8 @@ export class AcpStructuredChildren { ) { return } - if (session.phase === 'starting') { - void this.startupFailed(session, error) + if (!isAcpReadySession(session)) { + session.journalClosed = error.message || error.name return } closeAcpSessionJournal( @@ -117,35 +131,6 @@ export class AcpStructuredChildren { 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 } diff --git a/src/main/acp/acp-structured-child.ts b/src/main/acp/acp-structured-child.ts index 66c1414bc1fa..fbdea8eca025 100644 --- a/src/main/acp/acp-structured-child.ts +++ b/src/main/acp/acp-structured-child.ts @@ -3,12 +3,12 @@ import { providerDiagnostic, type SubmissionRejectionFact } from '../../shared/agent-session-failure' -import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import { AcpAgentError } from './acp-errors' import { acpAuthenticationRequired, acpSignInRequiredFailure } from './acp-turn-failures' import type { AcpStructuredSession } from './acp-structured-session' -export type AcpStartingSession = Pick< +export type AcpStructuredChild = Pick< AcpStructuredSession, | 'sessionId' | 'fence' @@ -20,17 +20,27 @@ export type AcpStartingSession = Pick< | 'ended' | 'exitObservedAt' > & { - phase: 'starting' - startupAnswered: boolean - failure?: SubmissionRejectionFact + phase: 'starting' | 'ready' + lane: AcpStructuredSession['lane'] | null + turns: AcpStructuredSession['turns'] | null + options: AcpStructuredSession['options'] + prompts: AcpStructuredSession['prompts'] + childStops?: AcpStructuredSession['childStops'] + restoreSkipped: readonly string[] + unbindReadingControl?: () => void + open: (signal: AbortSignal) => Promise closing?: Promise dispose: () => void } -export type AcpStructuredChild = AcpStartingSession | AcpStructuredSession +export function isAcpReadySession( + session: AcpStructuredChild +): session is AcpStructuredSession & AcpStructuredChild { + return session.phase === 'ready' && session.lane !== null && session.turns !== null +} export function acpStartupFailure( - session: AcpStartingSession, + session: AcpStructuredChild, error: unknown ): SubmissionRejectionFact { if (error instanceof AcpAgentError && acpAuthenticationRequired(session.spec.dialect, error)) { @@ -43,43 +53,8 @@ export function acpStartupFailure( return agentSessionFailureFact('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.kind !== 'notSignedIn' - ? { ...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, + session: AcpStructuredChild, abandoned: () => boolean ): void { if ( diff --git a/src/main/acp/acp-structured-handshake.ts b/src/main/acp/acp-structured-handshake.ts index 6aee71147adc..d886455d4816 100644 --- a/src/main/acp/acp-structured-handshake.ts +++ b/src/main/acp/acp-structured-handshake.ts @@ -5,10 +5,8 @@ // 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 type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import { ACP_REOPEN_FAILED, acpReopenTakeover, @@ -21,10 +19,13 @@ 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' +import { + assertAcpStartingSession, + isAcpReadySession, + type AcpStructuredChild +} from './acp-structured-child' type AcpHandshakeInput = { acquire: StructuredAgentSessionAcquireInput @@ -34,7 +35,7 @@ type AcpHandshakeInput = { connection: AcpStructuredConnection options: AcpStructuredOptions prompts: AcpStructuredPrompts - starting: AcpStartingSession + child: AcpStructuredChild abandoned: () => boolean lanes: { create: (providerSessionId: string, attaching?: boolean) => AcpStructuredLane @@ -42,21 +43,20 @@ type AcpHandshakeInput = { 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 +export async function initializeAcpStructuredSession( + input: AcpHandshakeInput +): Promise { + const { acquire, deps, launch, connection, options, prompts, child, onSettled } = input const { spec } = deps const sessionId = acquire.identity.sessionId const now = deps.now ?? Date.now - const assertStarting = () => assertAcpStartingSession(starting, input.abandoned) + const assertStarting = () => assertAcpStartingSession(child, 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?.({ @@ -84,7 +84,7 @@ export async function initializeAcpStructuredSession(input: AcpHandshakeInput): } catch (error) { assertStarting() takeover = acpReopenTakeover(error, resume, { - over: connection.closed || acquire.signal?.aborted === true, + over: connection.closed || input.abandoned(), now: now(), warn: (fields) => deps.logger?.warn(ACP_REOPEN_FAILED, { ...fields, sessionId }) }) @@ -114,17 +114,9 @@ export async function initializeAcpStructuredSession(input: AcpHandshakeInput): observedAt: now(), ...takeover } - const ready: AcpStructuredSession = { - phase: 'ready', - sessionId, - fence: acquire.fence, - acquisitionGeneration: generation, - spec, + Object.assign(child, { childStops, - connection, lane: liveLane, - prompts, - options, turns: new AcpStructuredTurns({ connection, withdrawRequests: () => prompts.withdrawAll(), @@ -133,12 +125,8 @@ export async function initializeAcpStructuredSession(input: AcpHandshakeInput): now, settle: onSettled }), - restoreSkipped, - closeRequested: false, - journalClosed: null, - ended: false, - exitObservedAt: null - } + restoreSkipped + }) const reading = liveLane const unbind = acquire.events?.bindReadingControl?.({ pauseReading: () => connection.pauseReading(), @@ -148,7 +136,7 @@ export async function initializeAcpStructuredSession(input: AcpHandshakeInput): } }) if (unbind) { - ready.unbindReadingControl = unbind + child.unbindReadingControl = unbind } if (connection.exited || connection.closed) { throw new Error(connection.stderrTail() || `${spec.command} exited while starting`) @@ -162,16 +150,16 @@ export async function initializeAcpStructuredSession(input: AcpHandshakeInput): liveLane.apply(acpSessionNotRestoredRow(key, started.sessionId, agentName)) } assertStarting() - input.onReady(ready, { - type: 'started', - sessionId, - fence: acquire.fence, - acquisitionGeneration: generation, + child.phase = 'ready' + if (!isAcpReadySession(child)) { + throw new Error('ACP session did not open') + } + return { link, reportedOptions: options.read().current, restoreSkippedOptions: restoreSkipped, optionRevision, // Its saved picks restored: what it runs now is what its start resolved. catalogListing: options.startListing() - }) + } } diff --git a/src/main/acp/acp-structured-publish-at-spawn.test.ts b/src/main/acp/acp-structured-publish-at-spawn.test.ts index da7da348e025..2205b4dfe3fd 100644 --- a/src/main/acp/acp-structured-publish-at-spawn.test.ts +++ b/src/main/acp/acp-structured-publish-at-spawn.test.ts @@ -48,24 +48,28 @@ describe('ACP child published before its handshake', () => { expect(rig.adapter.holdsLiveProviderProcess(SESSION, acquired.acquisitionGeneration)).toBe(true) expect(rig.adapter.holdsDispatch(SESSION)).toBe(false) expect(rig.adapter.readCommands(SESSION)).toBeUndefined() + const opening = rig.adapter.open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration, + signal: new AbortController().signal + }) await rig.frame('session/new') expect(rig.lifecycle).toEqual([]) answer?.() - await waitFor(() => - expect(rig.started()).toMatchObject({ - link: { handle: { nativeId: PROVIDER_SESSION }, origin: 'created' }, - reportedOptions: { model: 'grok-4.6', effort: 'low' }, - restoreSkippedOptions: [], - optionRevision: 7 - }) - ) + expect(await opening).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']) + expect(rig.lifecycle.map((event) => event.type)).toEqual(['options-reported']) ) }) @@ -80,6 +84,14 @@ describe('ACP child published before its handshake', () => { }) }) const acquired = await rig.acquire({ waitForStart: false }) + const opening = rig.adapter + .open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + .catch((error: unknown) => error) await rig.frame('session/new') if (!acquired.acquisitionGeneration) { throw new Error('missing acquisition generation') @@ -89,6 +101,7 @@ describe('ACP child published before its handshake', () => { ? rig.adapter[method]({ sessionId: SESSION }) : rig.adapter[method](SESSION) expect(await closed).toBe(true) + expect(await opening).toBeInstanceOf(Error) if (frame) { rig.child().agent.reply(frame, { sessionId: PROVIDER_SESSION }) } @@ -107,13 +120,22 @@ describe('ACP child published before its handshake', () => { script: (agent) => agent.on(answered ? 'session/new' : 'initialize', () => {}) }) await rig.acquire({ waitForStart: false }) + const opening = rig.adapter + .open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: 'gen-acp', + signal: new AbortController().signal + }) + .catch((error: unknown) => error) await rig.frame(answered ? 'session/new' : 'initialize') rig.child().stderr = 'provider config is invalid' rig.child().exit() + expect(await opening).toBeInstanceOf(Error) expect(rig.ended).toMatchObject([ { startupUnproven: true, failure: { detail: { text: 'provider config is invalid' } } } ]) - expect(rig.ended[0]?.startupUnanswered).toBe(answered ? undefined : true) + expect(rig.ended[0]?.startupUnanswered).toBeUndefined() } ) @@ -122,14 +144,17 @@ describe('ACP child published before its handshake', () => { 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' } } - } - ]) + const acquired = await rig.acquire({ waitForStart: false }) + await expect( + rig.adapter.open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + ).rejects.toMatchObject({ reason: 'notSignedIn' }) + expect(rig.ended).toEqual([]) + await rig.adapter.closeSession(SESSION) expect(rig.child().exited).toBe(true) }) 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 b0cb56c964c6..1eb12f613951 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -334,9 +334,18 @@ describe('ACP refusals', () => { describe('ACP startup that never answers', () => { it('lets a close stop the published child while initialize is unanswered', async () => { const rig = await openAcpAdapterRig({ script: (agent) => agent.on('initialize', () => {}) }) - await rig.acquire({ waitForStart: false }) + const acquired = await rig.acquire({ waitForStart: false }) + const opening = rig.adapter + .open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + .catch((error: unknown) => error) await rig.frame('initialize') expect(await rig.adapter.closeSession(SESSION)).toBe(true) + expect(await opening).toBeInstanceOf(Error) expect(rig.child().exited).toBe(true) expect(rig.ended).toMatchObject([{ cause: 'requested-close' }]) expect(rig.lifecycle.some((event) => event.type === 'started')).toBe(false) @@ -354,7 +363,15 @@ describe('ACP startup that never answers', () => { 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 }) + const acquired = await rig.acquire({ waitForStart: false }) + const opening = rig.adapter + .open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + .catch((error: unknown) => error) await rig.frame('session/new') vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) try { @@ -362,6 +379,7 @@ describe('ACP startup that never answers', () => { expect(rig.ended).toEqual([]) expect(rig.child().closes).toBe(0) expect(await rig.adapter.closeSession(SESSION)).toBe(true) + expect(await opening).toBeInstanceOf(Error) } finally { vi.useRealTimers() } @@ -369,12 +387,21 @@ describe('ACP startup that never answers', () => { 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 acquired = await rig.acquire({ waitForStart: false }) + const opening = rig.adapter + .open({ + sessionId: SESSION, + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + .catch((error: unknown) => error) const frame = await rig.frame('initialize') const child = rig.child() child.proveClose = vi.fn(async () => false) child.agent.fail(frame, -32603, 'provider failed to initialize') - await waitFor(() => expect(child.closes).toBe(1)) + expect(await opening).toBeInstanceOf(Error) + expect(child.closes).toBe(0) expect(rig.ended).toEqual([]) expect(await rig.adapter.closeSession(SESSION)).toBe(false) await expect(rig.adapter.closeAll()).rejects.toThrow('could not be proven stopped') @@ -383,7 +410,7 @@ describe('ACP startup that never answers', () => { }) expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) child.exit() - expect(rig.ended).toMatchObject([{ startupUnproven: true, startupUnanswered: true }]) + expect(rig.ended).toMatchObject([{ cause: 'requested-close' }]) expect(await rig.adapter.closeSession(SESSION)).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 046baf272b39..b263c7ce2e92 100644 --- a/src/main/acp/acp-structured-session-adapter-resume.test.ts +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -359,7 +359,7 @@ describe('reattaching a Grok chat the journal holds', () => { agent.on('session/load', (frame) => agent.fail(frame, -32000, 'Authentication required')) }) await rig.acquire() - expect(rig.ended).toMatchObject([{ failure: { kind: 'notSignedIn' }, startupUnproven: true }]) + expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' }) expect(rig.sent('session/new')).toEqual([]) }) diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index c2a22021eede..472eee34444c 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -119,7 +119,8 @@ describe('ACP structured session adapter: acquire', () => { agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required')) }) await rig.acquire() - expect(rig.ended).toMatchObject([{ startupUnproven: true, failure: { kind: 'notSignedIn' } }]) + expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' }) + expect(rig.ended).toMatchObject([{ cause: 'requested-close' }]) expect(rig.child().closes).toBe(1) }) }) diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 831a47ef44cf..0b61db21d14a 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -3,13 +3,14 @@ // them; compaction is the agent's own `/compact` prompt, for an agent whose launch spec offers it. import { randomUUID } from 'node:crypto' -import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentSessionFailureFact, withProviderDiagnostic } from '../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { AgentSessionAcquisitionExitProvenError, AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRefusal, AgentSessionPreSpawnError, isAgentSessionPreSpawnError, type AgentSessionAcquisition, @@ -36,7 +37,11 @@ import { import { readAcpLiveOptions, writeAcpSessionOption } from './acp-structured-options' import { readAcpRecoveryHistory } from './acp-recovery-history' import { stopAcpChildren, acpChildStopCapabilities } from './acp-structured-child-stop' -import type { AcpStructuredChild } from './acp-structured-child' +import { + acpStartupFailure, + isAcpReadySession, + type AcpStructuredChild +} from './acp-structured-child' import { AcpStructuredChildren } from './acp-structured-child-lifecycle' export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter { @@ -78,7 +83,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte const sessionId = input.identity.sessionId const generation = this.deps.mintGeneration?.() ?? randomUUID() try { - const { acquisition, session, initialize } = await acquireAcpStructuredSession({ + const { acquisition, session } = await acquireAcpStructuredSession({ acquire: input, deps: this.deps, generation, @@ -90,19 +95,6 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } }, 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) - }, onSettled: (settlement) => this.deps.onDispatchSettledLate?.({ sessionId, ...settlement }), forceClose: (id) => void this.forceCloseSession(id) }) @@ -111,7 +103,6 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte throw new Error('closed while starting') } this.children.sessions.set(sessionId, session) - void initialize().catch((error: unknown) => this.children.startupFailed(session, error)) return acquisition } catch (error) { const { connection } = attempt @@ -146,6 +137,31 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } } + open: NonNullable = async (input) => { + const child = this.children.sessions.get(input.sessionId) + if ( + !child || + child.fence !== input.fence || + child.acquisitionGeneration !== input.acquisitionGeneration || + child.ended + ) { + throw new Error('ACP child changed before it opened') + } + try { + return await child.open(input.signal) + } catch (error) { + const failure = acpStartupFailure(child, error) + if (failure.kind === 'notSignedIn') { + const refusal = new AgentSessionAcquisitionRefusal( + `${acpAgentName(child.spec.agent)} requires sign-in`, + 'notSignedIn' + ) + throw failure.detail ? withProviderDiagnostic(refusal, failure.detail) : refusal + } + throw error + } + } + async dispatch(input: { sessionId: string clientMessageId: string @@ -203,7 +219,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte awaitStoppedRequestEnd = async (sessionId: string, stoppedAt: number): Promise => { const session = this.children.sessions.get(sessionId) - if (session?.phase === 'ready') { + if (session && isAcpReadySession(session)) { await awaitAcpTurnEnd(session, stoppedAt, this.stopGraceMs()) } } @@ -234,17 +250,17 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte readOptionRestoreFailures = (sessionId: string): readonly string[] => { const session = this.children.sessions.get(sessionId) - return session?.phase === 'ready' ? session.restoreSkipped : [] + return session && isAcpReadySession(session) ? session.restoreSkipped : [] } readCommands = (sessionId: string) => { const session = this.children.sessions.get(sessionId) - return session?.phase === 'ready' ? session.options.readCommands() : undefined + return session && isAcpReadySession(session) ? session.options.readCommands() : undefined } holdsDispatch = (sessionId: string): boolean => { const session = this.children.sessions.get(sessionId) - return session?.phase === 'ready' && session.turns.running + return session ? isAcpReadySession(session) && session.turns.running : false } holdsLiveProviderProcess = (sessionId: string, generation: string): boolean => { @@ -302,7 +318,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte private live(sessionId: string): AcpStructuredSession { const session = this.children.sessions.get(sessionId) - if (session?.phase !== 'ready' || session.journalClosed !== null) { + if (!session || !isAcpReadySession(session) || session.journalClosed !== null) { throw new Error(`no live ${this.deps.spec.agent} child owns ${sessionId}`) } return session diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts index 19457d09d5d8..e919eec46506 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -15,6 +15,7 @@ import type { AcpStructuredLane } from './acp-structured-lane' import type { AcpStructuredOptions } from './acp-structured-options' import type { AcpStructuredPrompts } from './acp-structured-prompts' import type { AcpStructuredTurns } from './acp-structured-turns' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' export type AcpStructuredSession = { phase: 'ready' @@ -38,6 +39,8 @@ export type AcpStructuredSession = { ended: boolean exitObservedAt: number | null unbindReadingControl?: () => void + dispose: () => void + open: (signal: AbortSignal) => Promise } /** Every agent frame goes through here once the lane exists: options and commands first, so a diff --git a/src/main/acp/acp-structured-sign-in.test.ts b/src/main/acp/acp-structured-sign-in.test.ts index 9e02e4f7aac3..1be21fab0b06 100644 --- a/src/main/acp/acp-structured-sign-in.test.ts +++ b/src/main/acp/acp-structured-sign-in.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { z } from 'zod' import { agentSessionFailureSentence } from '../../shared/agent-session-failure-words' +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { ACP_LAUNCH_SPECS } from './acp-launch-specs' import { @@ -81,17 +82,13 @@ it.each(ACP_LAUNCH_SPECS)( spec, script: (agent) => agent.on('session/new', (frame) => agent.fail(frame, -32000, detail)) }) - // The chat opens at spawn, so a refused start ends the child with the reason. + // The host receives the refusal from open and then closes the child. await rig.acquire() - const [ended] = rig.ended - expect(ended).toMatchObject({ - startupUnproven: true, - failure: { kind: 'notSignedIn', detail: { text: detail, audience: 'person' } } + expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' }) + expect(providerDiagnosticOf(rig.openFailure())).toEqual({ text: detail, audience: 'person' }) + const fact = agentSessionFailureFact('notSignedIn', { + detail: providerDiagnosticOf(rig.openFailure()) }) - const fact = ended?.failure - if (!fact) { - throw new Error('a refused start reports its failure') - } expect(agentSessionFailureSentence(fact, 'row', { agentName: spec.agent })).toContain(detail) } ) @@ -109,9 +106,8 @@ it.each(ACP_LAUNCH_SPECS)( }) }) await rig.acquire() - expect(rig.ended).toMatchObject([ - { failure: { kind: 'notSignedIn', detail: { text: detail, audience: 'person' } } } - ]) + expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' }) + expect(providerDiagnosticOf(rig.openFailure())).toEqual({ text: detail, audience: 'person' }) } ) From 13a0d2dd14e826b176dd4eeab52b4c5cf5707180 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:30:50 -0700 Subject: [PATCH 15/55] Derive ACP opening from its protocol session facts --- src/main/acp/acp-structured-acquire.ts | 19 ++++++----- src/main/acp/acp-structured-child-stop.ts | 4 +-- src/main/acp/acp-structured-child.ts | 5 ++- .../acp/acp-structured-compaction.test.ts | 34 +++++++------------ src/main/acp/acp-structured-handshake.ts | 3 +- src/main/acp/acp-structured-session.ts | 1 - 6 files changed, 27 insertions(+), 39 deletions(-) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index aafd04d0bc79..308c75610068 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -13,7 +13,11 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' import { providerSpawnedProcessIdentity } from '../provider-process/provider-spawned-process-identity' -import { assertAcpStartingSession, type AcpStructuredChild } from './acp-structured-child' +import { + assertAcpStartingSession, + isAcpReadySession, + type AcpStructuredChild +} from './acp-structured-child' import type { AcpSessionEvent } from './acp-session-runtime' import type { AcpStructuredConnection } from './acp-structured-connection' import { acpAgentName } from './acp-structured-agent-definitions' @@ -84,11 +88,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?.phase === 'ready' && session.turns?.acceptsRequests === true, + () => isAcpReadySession(session) && session.turns?.acceptsRequests === true, () => - session?.phase === 'ready' && - session.turns !== null && - session.lane !== null && + isAcpReadySession(session) && (session.turns.acceptsRequests || (!session.turns.running && !session.turns.stopped && session.lane.openTurnId !== null)) ) @@ -112,7 +114,7 @@ export async function acquireAcpStructuredSession(input: { clientInfo: { name: 'orca', version: '1' }, ...(acquire.onOutput ? { onOutput: acquire.onOutput } : {}), onPermission: (request, context) => { - if (session?.phase !== 'ready' || !session.turns?.acceptsRequests) { + if (!isAcpReadySession(session) || !session.turns?.acceptsRequests) { // No prompt of Orca's runs (a turn the agent began itself included), or a Stop or steer // is cutting it short: nobody is there to ask. return { outcome: { outcome: 'cancelled' } } @@ -163,7 +165,7 @@ export async function acquireAcpStructuredSession(input: { routeAcpSessionEvent({ lane: slot.lane, options }, event, now(), slot.reattaching) } if ( - session?.phase === 'ready' && + isAcpReadySession(session) && !session.ended && event.kind === 'known' && event.notification.update.sessionUpdate === 'config_option_update' @@ -200,7 +202,7 @@ export async function acquireAcpStructuredSession(input: { ...acpLaneChildWorkDelivery(deps, sessionId), logger: deps.logger ?? createStructuredAgentSessionLogger(), onInputAccepted: (clientMessageId) => { - if (session?.phase === 'ready') { + if (isAcpReadySession(session)) { session.turns?.accept(clientMessageId) } }, @@ -226,7 +228,6 @@ export async function acquireAcpStructuredSession(input: { await identity.onSpawned(pid) const process = await identity.read(pid) const child: AcpStructuredChild = { - phase: 'starting', sessionId, fence: acquire.fence, acquisitionGeneration: generation, diff --git a/src/main/acp/acp-structured-child-stop.ts b/src/main/acp/acp-structured-child-stop.ts index 826a65b5541a..6cdd24739fca 100644 --- a/src/main/acp/acp-structured-child-stop.ts +++ b/src/main/acp/acp-structured-child-stop.ts @@ -1,5 +1,5 @@ +import { isAcpReadySession, type AcpStructuredChild } from './acp-structured-child' import type { AcpStructuredSession } from './acp-structured-session' -import type { AcpStructuredChild } from './acp-structured-child' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpChildStops, AcpDialect } from './acp-dialects/acp-dialect' import { AcpAgentError } from './acp-errors' @@ -18,7 +18,7 @@ export function acpChildStopCapabilities( return session?.journalClosed === null ? { supportsTaskStop: - session.phase === 'ready' && + isAcpReadySession(session) && (session.childStops?.subagents === true || session.childStops?.backgroundTasks === true), supportsStopAll: false } diff --git a/src/main/acp/acp-structured-child.ts b/src/main/acp/acp-structured-child.ts index fbdea8eca025..9f681ec1bd4c 100644 --- a/src/main/acp/acp-structured-child.ts +++ b/src/main/acp/acp-structured-child.ts @@ -20,7 +20,6 @@ export type AcpStructuredChild = Pick< | 'ended' | 'exitObservedAt' > & { - phase: 'starting' | 'ready' lane: AcpStructuredSession['lane'] | null turns: AcpStructuredSession['turns'] | null options: AcpStructuredSession['options'] @@ -34,9 +33,9 @@ export type AcpStructuredChild = Pick< } export function isAcpReadySession( - session: AcpStructuredChild + session: AcpStructuredChild | null | undefined ): session is AcpStructuredSession & AcpStructuredChild { - return session.phase === 'ready' && session.lane !== null && session.turns !== null + return session != null && session.lane !== null && session.turns !== null } export function acpStartupFailure( diff --git a/src/main/acp/acp-structured-compaction.test.ts b/src/main/acp/acp-structured-compaction.test.ts index 297a076816ca..e813539cc022 100644 --- a/src/main/acp/acp-structured-compaction.test.ts +++ b/src/main/acp/acp-structured-compaction.test.ts @@ -8,7 +8,6 @@ import { import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' -import { structuredAgentSessionChildReportedSignedOut } from '../native-chat/agent-session-wire/structured-agent-session-outgrown-child' import { ACP_LAUNCH_SPECS, acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' import { acpStructuredAgentDefinition } from './acp-structured-agent-definitions' import { GrokFixtureReplay } from './acp-structured-fixture-replay.test-support' @@ -276,27 +275,18 @@ describe('ACP compaction settlement', () => { it.each([ { spec: OPENCODE, code: -32000, data: undefined }, { spec: OMP, code: -32603, data: { details: 'No model selected.\n\nUse /login to sign in.' } } - ])( - 'says $spec.agent is not signed in, as a send does, so the next one starts a new agent', - async ({ spec, code, data }) => { - const { rig, command, prompt } = await compact(spec) - rig.child().agent.fail(prompt, code, 'Authentication required', data) - const { turn, inside } = await ended(rig, command) - expect(turn).toMatchObject({ state: 'completed', outcome: 'failure' }) - expect(inside).toEqual([ - expect.objectContaining({ - tone: 'error', - failure: expect.objectContaining({ kind: 'notSignedIn' }) - }) - ]) - expect( - structuredAgentSessionChildReportedSignedOut({ - child: { generation: 'generation-1', fence: 1, phase: 'ready' }, - journal: rig.rig.journal - }) - ).toBe(true) - } - ) + ])('says $spec.agent is not signed in, as a send does', async ({ spec, code, data }) => { + const { rig, command, prompt } = await compact(spec) + rig.child().agent.fail(prompt, code, 'Authentication required', data) + const { turn, inside } = await ended(rig, command) + expect(turn).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(inside).toEqual([ + expect.objectContaining({ + tone: 'error', + failure: expect.objectContaining({ kind: 'notSignedIn' }) + }) + ]) + }) it('fails a compaction the agent ended with any other stop reason', async () => { const { rig, command, prompt } = await compact(GROK) diff --git a/src/main/acp/acp-structured-handshake.ts b/src/main/acp/acp-structured-handshake.ts index d886455d4816..2679ba2f68fc 100644 --- a/src/main/acp/acp-structured-handshake.ts +++ b/src/main/acp/acp-structured-handshake.ts @@ -1,5 +1,5 @@ // 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 +// session or start a new one, then return its protocol facts to the host. A saved session the // agent cannot reopen is replaced by a new one, with a warning row that any later start writes if // this one never did; while a reopened session replays history the journal already holds, nothing // it sends is written except context usage. @@ -150,7 +150,6 @@ export async function initializeAcpStructuredSession( liveLane.apply(acpSessionNotRestoredRow(key, started.sessionId, agentName)) } assertStarting() - child.phase = 'ready' if (!isAcpReadySession(child)) { throw new Error('ACP session did not open') } diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts index e919eec46506..c78b9b7581fd 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -18,7 +18,6 @@ import type { AcpStructuredTurns } from './acp-structured-turns' import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' export type AcpStructuredSession = { - phase: 'ready' sessionId: string fence: number acquisitionGeneration: string From fe4ecbe14dc8890468d85b8f500eeae3eebb8168 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:34:18 -0700 Subject: [PATCH 16/55] Exercise unanswered ACP options with an explicit silent route --- .../acp/acp-structured-host-option-hang.test.ts | 13 ++++++++++--- src/main/acp/acp-structured-host.test-support.ts | 13 +++++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/main/acp/acp-structured-host-option-hang.test.ts b/src/main/acp/acp-structured-host-option-hang.test.ts index 32f0a6df64f6..5fe02884e7f3 100644 --- a/src/main/acp/acp-structured-host-option-hang.test.ts +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -49,7 +49,9 @@ const failed = (outcome: Awaited>) => describe('a Grok model pick Grok never answers', () => { it('does not hold a Stop behind it; the pick fails', async () => { - const { rig, host } = await openAttachedHostRig() + const { rig, host } = await openAttachedHostRig({ + script: (agent) => agent.on('session/set_config_option', () => {}) + }) const picking = pick(host, 'grok-4.6') await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) expect(await within(stop(host), 2_000)).toMatchObject({ settled: 'resolved' }) @@ -58,7 +60,9 @@ describe('a Grok model pick Grok never answers', () => { }) it('does not hold a Close behind it; the pick fails', async () => { - const { rig, host } = await openAttachedHostRig() + const { rig, host } = await openAttachedHostRig({ + script: (agent) => agent.on('session/set_config_option', () => {}) + }) const picking = pick(host, 'grok-4.6') await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) expect(await within(host.close(SESSION, 'user-close'), 2_000)).toMatchObject({ @@ -68,7 +72,10 @@ describe('a Grok model pick Grok never answers', () => { }) it('fails at its bound, frees the chat, and keeps what Grok answers late', async () => { - const { rig, host } = await openAttachedHostRig({ optionWriteTimeoutMs: 100 }) + const { rig, host } = await openAttachedHostRig({ + optionWriteTimeoutMs: 100, + script: (agent) => agent.on('session/set_config_option', () => {}) + }) const picking = pick(host, 'grok-4.6') await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) expect(failed(await within(picking, 2_000))).toBe(true) diff --git a/src/main/acp/acp-structured-host.test-support.ts b/src/main/acp/acp-structured-host.test-support.ts index 938cb0b805c2..261787f1a944 100644 --- a/src/main/acp/acp-structured-host.test-support.ts +++ b/src/main/acp/acp-structured-host.test-support.ts @@ -193,22 +193,27 @@ export const framesOf = (child: FakeAcpChild, method: string) => /** A new Grok chat the host attached; Grok loads its session on a later start, counting each. */ export async function openAttachedHostRig( - deps: Partial = {}, + deps: Partial & { + script?: (agent: AcpScriptedAgent) => void + } = {}, statusSink?: StructuredAgentSessionStatusSink, modelCatalog?: AgentModelCatalogService ) { + const { script, ...adapterDeps } = deps const count = { loads: 0 } let resumed = false const rig = await openHostRig({ statusSink, ...(modelCatalog ? { modelCatalog } : {}), initialize: RESUMES, - script: (agent) => + script: (agent) => { agent.on('session/load', (frame) => { count.loads += 1 agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) - }), - deps: { resolveLaunch: launch(() => resumed), ...deps } + }) + script?.(agent) + }, + deps: { resolveLaunch: launch(() => resumed), ...adapterDeps } }) expect(await rig.host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) await rig.ready() From e1cc15dda6ccff48cc2074a5fb0c628ed51e7a9d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:07:10 -0700 Subject: [PATCH 17/55] Record failed owned opening before the first message --- ...ructured-agent-session-provider-open.test.ts | 17 +++++++++++++++++ ...tructured-agent-session-start-failure-row.ts | 9 ++++----- ...laude-structured-send-during-startup.test.ts | 4 ++-- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts index c9adced332c8..3b8d485b2cbe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -85,6 +85,23 @@ it('closes a refused open once and records one provider failure for all held mes expect(dispatch).not.toHaveBeenCalled() }) +it('records a refused owned open even before a message is accepted', async () => { + const { host, close, pending, dispatch } = awaitOpen() + await attach() + pending.reject(new AgentSessionAcquisitionRefusal('Sign in to the provider.', 'notSignedIn')) + await vi.waitFor(async () => { + const snapshot = await host.journalSnapshot(SESSION) + expect(snapshot.submissions).toEqual([]) + expect( + snapshot.items.filter( + (item) => item.body.kind === 'status' && item.body.failure?.kind === 'notSignedIn' + ) + ).toHaveLength(1) + }) + expect(close).toHaveBeenCalledTimes(1) + expect(dispatch).not.toHaveBeenCalled() +}) + it('closes a child while its open is pending and refuses its late readiness result', async () => { const { host, open, close, dispatch, pending, send } = awaitOpen() await attach() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index aaeb3a11584a..70dcc73a3b46 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -72,24 +72,23 @@ export function hasStructuredAgentSessionStartFailureRow( /** * A start the delivery loop needed and did not get: every queued message rejected with the same - * words, then the start's row. Writes nothing when nothing is still queued: a start whose messages - * Stop withdrew did not fail anyone. + * words, then the start's row. A failed owned open also records its cause before any message. */ export async function recordStructuredAgentSessionStartFailure( session: Pick & { fence: number }, failure: StructuredAgentSessionStartFailure ): Promise { const oldest = oldestQueuedSubmission(session) - if (!oldest) { + const startKey = failure.startKey ?? oldest?.clientMessageId + if (startKey === undefined) { return } - const startKey = failure.startKey ?? oldest.clientMessageId await session.journal.appendLifecycleBatch({ settlementId: `start-failure:${startKey}`, fence: session.fence, recovered: true, mutations: [structuredAgentSessionStartFailureRow(startKey, failure)], - rejectsQueued: { reason: failure.reason, rejection: failure.rejection } + ...(oldest ? { rejectsQueued: { reason: failure.reason, rejection: failure.rejection } } : {}) }) } diff --git a/src/main/runtime/claude-structured-send-during-startup.test.ts b/src/main/runtime/claude-structured-send-during-startup.test.ts index 494d012de462..823af00c33ed 100644 --- a/src/main/runtime/claude-structured-send-during-startup.test.ts +++ b/src/main/runtime/claude-structured-send-during-startup.test.ts @@ -57,10 +57,10 @@ function fence(host: StructuredAgentSessionHost): number { return host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 } -/** Whether the CLI was written a message, once the delivery loop has settled what it does. */ +/** Whether the CLI was written a message while the host is waiting on its open. */ async function written(host: StructuredAgentSessionHost): Promise { await vi.waitFor(() => - expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(true) ) return claude.child(SESSION).calls.includes('send') } From b57b016376fc679764b482fb4f853d8d003c1399 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:17:16 -0700 Subject: [PATCH 18/55] Abort a matching provider open when its child exits --- .../agent-session-wire/structured-agent-session-child-exit.ts | 4 ++++ .../structured-agent-session-event-recovery.ts | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index f9ddbe0b58a2..ec64b076e5ba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -61,6 +61,7 @@ export type StructuredAgentSessionChildExitContext< wakeDelivery?: (sessionId: string) => void /** An ended child's start, if still open, has nothing left to time. */ startupAttempts?: Pick + abortOpen?: (sessionId: string) => void serialize: (sessionId: string, task: () => Promise) => Promise now: () => number logger: StructuredAgentSessionLogger @@ -121,6 +122,9 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< // The host's own phase decides, so a provider that omits the flag still gets a start that // failed told as one: the row says so. const exitedDuringStartup = exit.startupUnproven === true || child.phase === 'starting' + if (child.phase === 'starting') { + context.abortOpen?.(sessionId) + } // A close during startup settles what the child was handed, never echoed, by who asked: a // person's Stop stops it; the host's stop fails the start, as an exit of its own would. const startClose = expected && exitedDuringStartup ? close?.cause : undefined 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 f9b9abf53818..d0b3ca564ba6 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 @@ -81,7 +81,9 @@ export class StructuredAgentSessionEventRecovery { route: { runtimeState, acknowledgeRelease: (sessionId) => deps.adapter.acknowledgeSessionRelease?.(sessionId) - } + }, + abortOpen: (sessionId) => + runtimeState.acquireAborts.abort(sessionId, 'provider exited while opening') } } From 8e8fcd2363dcfee27acf11e1a4c0f82095160505 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:40:05 -0700 Subject: [PATCH 19/55] Cover provider exit during an unanswered host open --- ...ctured-agent-session-provider-open.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts index 3b8d485b2cbe..06489fcd7510 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -115,3 +115,26 @@ it('closes a child while its open is pending and refuses its late readiness resu expect(dispatch).not.toHaveBeenCalled() expect(close).toHaveBeenCalledTimes(1) }) + +it('ends the host open wait when the matching child exits without answering', async () => { + const { host, open, dispatch, send } = awaitOpen() + const attached = await attach() + if (!attached) { + throw new Error('The spawned child has no owner record.') + } + await send('held') + await host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence: attached.lease.runtimeFence, + acquisitionGeneration: 'awaited-open', + reason: 'The provider exited before answering.', + cause: 'unexpected-exit' + }) + expect(open.mock.calls[0]?.[0].signal.aborted).toBe(true) + await vi.waitFor(async () => { + const snapshot = await host.journalSnapshot(SESSION) + expect(snapshot.submissions[0]?.dispatchState).toBe('rejected') + }) + expect(dispatch).not.toHaveBeenCalled() +}) From bfd8e50885ac978cf797932b5cb9de5d10c06a96 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:47:22 -0700 Subject: [PATCH 20/55] Cover explicit failure facts on a host-stopped child --- .../structured-agent-session-delivery-loop.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts index 61f596359165..9bd311b3852e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.test.ts @@ -32,9 +32,17 @@ describe('what a child end means for the messages queued behind it', () => { }) it.each([false, true])( - 'is a start Orca stopped after a host stop (during startup: %s)', + 'keeps a recorded failure after a host stop (during startup: %s)', (starting) => { expect(structuredAgentSessionEndedChildFailure(ended('host-stop', starting))).toEqual({ + failure: EXIT_FAILURE + }) + expect( + structuredAgentSessionEndedChildFailure({ + ...ended('host-stop', starting), + failure: undefined + }) + ).toEqual({ failure: { kind: 'hostStopped' } }) } From 7ba38c7903550dd12932472c2d87864bf2ff0d33 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:01:26 -0700 Subject: [PATCH 21/55] Keep startup waits cancellable and preserve operation order --- .../agent-model-catalog-service.ts | 2 +- ...-agent-session-accept-then-deliver.test.ts | 7 +- ...structured-agent-session-acquire-aborts.ts | 24 +- .../structured-agent-session-adapter.ts | 1 + ...ured-agent-session-attach-orchestration.ts | 16 +- ...-agent-session-claude-option-queue.test.ts | 28 ++- ...ssion-claude-stop-exit-ends-record.test.ts | 16 +- ...ed-agent-session-conversation-stop.test.ts | 16 +- .../structured-agent-session-delivery-loop.ts | 1 + ...d-agent-session-held-start.test-support.ts | 49 ++-- ...structured-agent-session-host-mutations.ts | 37 +-- .../structured-agent-session-host-types.ts | 3 - .../structured-agent-session-host.test.ts | 20 +- ...gent-session-instant-start.test-support.ts | 28 +-- ...ructured-agent-session-mutation-context.ts | 2 +- ...ed-agent-session-option-settlement.test.ts | 46 +--- ...gent-session-provider-child-record.test.ts | 6 +- ...ctured-agent-session-provider-open.test.ts | 168 +++++++++++++ .../structured-agent-session-provider-open.ts | 18 +- ...red-agent-session-provider-started.test.ts | 6 +- ...ructured-agent-session-provider-started.ts | 208 ++------------- ...tructured-agent-session-ready-operation.ts | 47 +++- ...ctured-agent-session-refusal-retry.test.ts | 58 +---- ...uctured-agent-session-repeat-press.test.ts | 6 +- ...tructured-agent-session-start-readiness.ts | 48 ++++ ...agent-session-startup-attempt-host.test.ts | 156 ++---------- ...sion-startup-attempt-registrations.test.ts | 18 +- ...tructured-agent-session-startup-attempt.ts | 33 ++- ...structured-agent-session-startup-intent.ts | 143 ----------- ...nt-session-startup-permission-mode.test.ts | 153 ----------- ...ctured-agent-session-startup-picks.test.ts | 237 ------------------ ...red-agent-session-stop-never-waits.test.ts | 5 +- .../structured-rewind-recovery.ts | 14 +- ...se-native-chat-provisional-launch.test.tsx | 17 +- .../use-native-chat-provisional-launch.ts | 54 +--- 35 files changed, 481 insertions(+), 1210 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-start-readiness.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-intent.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts 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 90ea2d072120..b0530e834e58 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,7 @@ export function createAgentModelCatalogService( ...(unavailable ? { unavailable } : {}) } } - // Death evidence keeps a read after a failed start from spawning another agent; a new chat + // Death evidence keeps a read after an observed exit from spawning another agent; a new chat // also starts released, but still needs its first listing. if ( params.savedOnly || 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 71ea4eb72968..c668863d40c7 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 @@ -609,8 +609,7 @@ 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 proves its start, then dies as its start step returns: its exit, - // asked for on the lane then, lands before the handover step. + // The child exits before the host can finish its protocol open. acquire.mockImplementation(async (input) => { const child = await spawnStartingChild(input) const acquisitionGeneration = `generation-${acquire.mock.calls.length}` @@ -630,8 +629,8 @@ describe('a child that exits before its message is handed over', () => { await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) expect(await submission(id)).toMatchObject({ - reason: 'Codex stopped before this message was sent.', - rejection: { kind: 'providerExited' } + reason: 'Codex stopped before it finished starting. Send your message to try again.', + rejection: { kind: 'providerStartFailed' } }) expect(acquire).toHaveBeenCalledTimes(2) expect(dispatch).not.toHaveBeenCalled() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts index 9fdc5e9a72b4..c5c0d3a06f88 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts @@ -1,10 +1,7 @@ -// The acquire each session has in flight, owned by the host that runs it, or another provider wait -// its queue is on (an option write). The queue runs one step at a time, so a session has at most -// one; a close, a Stop admitted now, quit, or the startup limit aborts it from outside the queue -// instead of waiting behind a provider that may never answer. +// Concurrent host waits keep their own cancellation handles until they settle. export class StructuredAgentSessionAcquireAborts { - private readonly inFlight = new Map() + private readonly inFlight = new Map>() /** Set by quit: the host is going away, so an attach that begins after it starts aborted. */ private quitReason: Error | null = null @@ -14,11 +11,14 @@ export class StructuredAgentSessionAcquireAborts { if (this.quitReason) { controller.abort(this.quitReason) } - this.inFlight.set(sessionId, controller) + const waits = this.inFlight.get(sessionId) ?? new Set() + waits.add(controller) + this.inFlight.set(sessionId, waits) return { signal: controller.signal, end: () => { - if (this.inFlight.get(sessionId) === controller) { + waits.delete(controller) + if (waits.size === 0 && this.inFlight.get(sessionId) === waits) { this.inFlight.delete(sessionId) } } @@ -27,14 +27,18 @@ export class StructuredAgentSessionAcquireAborts { /** A no-op when the session has nothing in flight. */ abort(sessionId: string, reason: string | Error): void { - this.inFlight.get(sessionId)?.abort(typeof reason === 'string' ? new Error(reason) : reason) + for (const controller of this.inFlight.get(sessionId) ?? []) { + controller.abort(typeof reason === 'string' ? new Error(reason) : reason) + } } /** Quit: every start under way stops, and so does any the attach drain still runs. */ abortAll(reason: string): void { this.quitReason ??= new Error(reason) - for (const controller of this.inFlight.values()) { - controller.abort(this.quitReason) + for (const waits of this.inFlight.values()) { + for (const controller of waits) { + controller.abort(this.quitReason) + } } } } 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 5bbb022cfa9f..2d1f72bfa4ca 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 @@ -316,6 +316,7 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & recoverRewind?(input: { sessionId: string fence: number + signal?: AbortSignal beforeTurnId: string }): Promise< | { ok: true; items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] } 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 db7063ec9321..4d256fc92457 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,11 +14,7 @@ import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { - performAttach, - type AttachedOwner, - type AttachFlowInput -} from './structured-agent-session-attach-flow' +import { performAttach, 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 { @@ -258,7 +254,6 @@ async function runAttachUnderAbort( // An acquire reports its phase; a re-attach keeps what its child already proved. phase: owner === 'acquired' ? providerChildPhase : (current?.phase ?? 'ready'), ...(startedFor === undefined ? {} : { startedFor }), - ...launchedOptionsOf(owner, attempt.startup, current), ...(permissionMode === undefined ? {} : { permissionMode }) } } @@ -308,15 +303,6 @@ 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-claude-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts index 6e047ae5773c..5aea5d70c253 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 @@ -34,6 +34,7 @@ let activeModel: string let dispatch: Mock let order: string[] let setOption: Mock +let releaseOpen: () => void function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { return { @@ -76,6 +77,15 @@ function adapter(): StructuredAgentSessionAdapter { } }) return { + open: () => + new Promise((resolve) => { + releaseOpen = () => + resolve({ + reportedOptions: { model: DEFAULT_MODEL }, + restoreSkippedOptions: [], + optionRevision: 0 + }) + }), acquire, dispatch, cancelTurn: vi.fn(async () => ({ cancelled: true })), @@ -94,6 +104,7 @@ beforeEach(async () => { resetHostTestOperationIds() activeModel = DEFAULT_MODEL order = [] + releaseOpen = () => {} store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ agents: claudeAndCodexDeclared(), @@ -125,7 +136,7 @@ afterEach(async () => { }) describe('Claude structured session options', () => { - it('takes a pick made while the agent starts as intent and applies it, model first, before the first send', async () => { + it('saves picks made while opening without changing the child', async () => { const pick = (key: string, value: string) => host.setOption(CALLER, { envelope: envelope('agentSession.setOption', { key, value }), @@ -144,19 +155,10 @@ describe('Claude structured session options', () => { ).toMatchObject({ ok: true }) expect(dispatch).not.toHaveBeenCalled() - // 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 - }) + releaseOpen() await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - expect(order).toEqual([`model=${PICKED_MODEL}`, 'effort=high', 'dispatch']) - expect(activeModel).toBe(PICKED_MODEL) + expect(order).toEqual(['dispatch']) + expect(activeModel).toBe(DEFAULT_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-claude-stop-exit-ends-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts index 91c0afd33317..14adfb0f2b6b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -451,22 +451,16 @@ it('rejects a message whose start meets a close still unverifiable, and starts n expect(scopes()).toContain('provider-close-unproven') }) -it('refuses an option change while the close stays unverifiable, never writing to the old child', async () => { +it('saves an option change while the close stays unverifiable without writing to the old child', async () => { const connection = await stopWithUnprovenClose(2) - await expect(setModel('claude-opus-5')).resolves.toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_ownership_unknown', - details: { reason: 'previousExitUnverifiable', ownerVerdict: 'unverifiable' } - } - }) - expect(connection.closeCount).toBe(2) + await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) + expect(connection.closeCount).toBe(1) expect(connection.calls.some((call) => call.subtype === 'set_model')).toBe(false) - // Joined again once the exit is proven, the pick is the chat's at rest, for the next start. + // The pick remains saved for a later start while the old child is still unverifiable. await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) - expect(child()).toBeNull() + expect(child()?.close).toBeDefined() expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'claude-opus-5' }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index 9989ef6386f0..9996e5e5fa36 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 @@ -30,7 +30,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { codexProviderHandle } 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' import { THREAD_ID, adapterFor, @@ -76,7 +76,7 @@ beforeEach(async () => { log = recordingStructuredAgentSessionLogger() store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, + agents: claudeAndCodexDeclared(), logger: log.logger, store, adapter: { @@ -222,7 +222,7 @@ describe('a Stop that names no turn', () => { ) expect(await pickOption('model', 'gpt-next')).toMatchObject({ ok: true, - value: { options: { model: 'gpt-next', serviceTier: 'priority' } } + value: { options: { model: 'gpt-next' } } }) expect(codex.connections[0].calls.filter((call) => call.method === 'model/list')).toHaveLength( 1 @@ -248,7 +248,7 @@ describe('a Stop that names no turn', () => { ) expect( codex.connections[0].calls.filter((call) => call.method === 'turn/start')[1]?.params - ).toMatchObject({ model: 'gpt-next', serviceTier: 'priority' }) + ).toMatchObject({ serviceTier: 'priority' }) pending.resolve({ data: [], nextCursor: null }) }) @@ -369,11 +369,11 @@ describe('a Stop that names no turn', () => { ]) }) - it('fails a turn Codex refuses for a model picked before the list, then sends again', async () => { + it('records a provider-refused turn, then sends again without changing saved picks', async () => { const pending = Promise.withResolvers() const codex = fakeCodex({ 'model/list': () => pending.promise }) codex.routes['turn/start'] = (params) => { - if (params?.model === 'gpt-missing') { + if (codex.connections[0].calls.filter((call) => call.method === 'turn/start').length === 1) { throw new CodexAppServerRequestError( 'turn/start', -32602, @@ -409,9 +409,7 @@ describe('a Stop that names no turn', () => { codex.connections[0].calls.filter((call) => call.method === 'turn/start') ).toHaveLength(2) ) - expect( - codex.connections[0].calls.filter((call) => call.method === 'turn/start')[1]?.params - ).toMatchObject({ model: 'gpt-live' }) + expect(store.getRecord(SESSION)?.options?.model).toBe('gpt-live') await eventually(async () => expect((await submission(retried.id))?.dispatchState).not.toBe('rejected') ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 2785e275da9d..ec67aeee67c8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -150,6 +150,7 @@ export class StructuredAgentSessionDeliveryLoop { } if (prepared.readiness) { await prepared.readiness.wait() + await prepared.readiness.waitForOperations?.() } const handed = await this.deps.serialize(sessionId, () => this.handOver(sessionId, prepared.awaited, prepared.readiness !== null) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts index a8f493d9d551..2a8dbf2f98f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts @@ -15,29 +15,25 @@ export type StructuredAgentSessionHeldStarts = { 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. */ + /** Answers the pending protocol open. */ prove(child: { sessionId: string; fence: number; acquisitionGeneration: string }): Promise clear(): void } export function structuredAgentSessionHeldStarts( - host: () => { handleAdapterEvent(event: StructuredAgentSessionLifecycleEvent): Promise } + _host: () => { handleAdapterEvent(event: StructuredAgentSessionLifecycleEvent): Promise } ): StructuredAgentSessionHeldStarts { - const heldGenerations = new Set() + const heldGenerations = new Map>() let generations = 0 - const prove: StructuredAgentSessionHeldStarts['prove'] = (child) => - host().handleAdapterEvent({ - type: 'started', - ...child, - reportedOptions: { model: '' }, - restoreSkippedOptions: [], - optionRevision: NO_OPTIONS_REPORTED - }) + const prove: StructuredAgentSessionHeldStarts['prove'] = async (child) => { + heldGenerations.get(child.acquisitionGeneration)?.resolve() + await Promise.resolve() + } return { held: (acquire) => async (input) => { const acquired = await acquire(input) const acquisitionGeneration = acquired.acquisitionGeneration ?? `held-start-${++generations}` - heldGenerations.add(acquisitionGeneration) + heldGenerations.set(acquisitionGeneration, Promise.withResolvers()) return { ...acquired, acquisitionGeneration, providerChildPhase: 'starting' as const } }, wrap: (adapter) => { @@ -45,18 +41,29 @@ export function structuredAgentSessionHeldStarts( 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, providerChildPhase: 'starting' as const } } - return { ...adapter, acquire } + const open: NonNullable = async (input) => { + if (input.acquisitionGeneration) { + await heldGenerations.get(input.acquisitionGeneration)?.promise + heldGenerations.delete(input.acquisitionGeneration) + } + return adapter.open + ? adapter.open(input) + : { + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: NO_OPTIONS_REPORTED + } + } + return { ...adapter, acquire, open } }, prove, - clear: () => heldGenerations.clear() + clear: () => { + for (const held of heldGenerations.values()) { + held.resolve() + } + heldGenerations.clear() + } } } 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 8dc39a62d567..db11e67fb6a4 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 @@ -27,10 +27,10 @@ import { } from './structured-agent-session-mutation-context' import { openForProviderWrite, + openForWrite, sendPreparation, structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' -import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../shared/agent-chat-permission-mode' import { cancelPlan, promptPlan, @@ -47,7 +47,6 @@ import { openWithReadyStructuredAgentSessionChild, runAfterRewindStart } from './structured-agent-session-ready-operation' -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' import { @@ -185,44 +184,16 @@ export async function setStructuredAgentSessionOption( params: { envelope: AgentSessionMutationEnvelope; key: string; value: string } ): Promise> { const plan = setOptionPlan(params) - // 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 ( - params.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID || !child || child.phase === 'starting' - ) - } return mutateStructuredAgentSession( context, caller, params.envelope, { ...plan, - // Read as the call is admitted; a ready child's pick is still its owner's to make. - get conversationWrite() { - return recordsIntent() ? (true as const) : undefined - }, - run: async (ctx) => { - if (recordsIntent()) { - if (params.key !== AGENT_CHAT_PERMISSION_MODE_OPTION_ID) { - // 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. - const wait = context.acquireAborts.begin(params.envelope.sessionId) - try { - return await performSetOption(ctx, params, wait.signal) - } finally { - wait.end() - // Whatever the write's outcome, the child may hold what it asked for now. - context.optionRevisions.advance(params.envelope.sessionId) - } - } + conversationWrite: true, + run: (ctx) => recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) }, - openForProviderWrite(context, params.envelope) + openForWrite(context, params.envelope) ) } 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 fd822f95086e..684d9b776f67 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 @@ -75,9 +75,6 @@ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProvider /** 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> /** The permission mode it was launched with, in memory only; absent for an agent with none. */ readonly permissionMode?: AgentChatPermissionMode 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 155e6cbb1d00..30f955bd0cca 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 @@ -517,7 +517,7 @@ describe('respondToPrompt', () => { }) describe('setOption', () => { - it('goes to the provider and writes nothing to the journal', async () => { + it('saves a next-message pick without changing the provider', async () => { await attach() setOption.mockResolvedValueOnce({ model: 'gpt-5', effort: 'high' }) const fields = { key: 'model', value: 'gpt-5' } @@ -528,20 +528,20 @@ describe('setOption', () => { const result = await host.setOption(CALLER, params) expect(result).toMatchObject({ ok: true, - value: { ...fields, options: { model: 'gpt-5', effort: 'high' } } + value: { ...fields, options: { model: 'gpt-5' } } }) expect(await host.setOption(CALLER, params)).toMatchObject({ ok: true, replayed: true, - value: { ...fields, options: { model: 'gpt-5', effort: 'high' } } + value: { ...fields, options: { model: 'gpt-5' } } }) - expect(setOption).toHaveBeenCalledTimes(1) - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5', effort: 'high' }) + expect(setOption).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5' }) const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.items).toHaveLength(0) }) - it('does not turn an unknown provider outcome into a successful replay', async () => { + it('replays the accepted pick without contacting the provider', async () => { await attach() setOption.mockRejectedValueOnce(new Error('reply lost')) const fields = { key: 'model', value: 'gpt-5' } @@ -550,12 +550,12 @@ describe('setOption', () => { ...fields } - await expect(host.setOption(CALLER, params)).rejects.toThrow('reply lost') + expect(await host.setOption(CALLER, params)).toMatchObject({ ok: true }) expect(await host.setOption(CALLER, params)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_unknown' } + ok: true, + replayed: true }) - expect(setOption).toHaveBeenCalledTimes(1) + expect(setOption).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts index c228180de5c0..b0463101fb35 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts @@ -1,7 +1,4 @@ -// 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. +// Fake children answer the host-awaited protocol open immediately. import type { StructuredAgentSessionAdapter, @@ -14,22 +11,23 @@ const NO_OPTIONS_REPORTED = -1 export function startsWhenPublished( adapter: A, - host: () => { handleAdapterEvent(event: StructuredAgentSessionLifecycleEvent): Promise } + _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, providerChildPhase: 'starting' as const } } - return { ...adapter, acquire } + return { + ...adapter, + acquire, + open: + adapter.open ?? + (async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: NO_OPTIONS_REPORTED + })) + } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 8a077cbfe2dc..63afe25e4f26 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -35,7 +35,7 @@ export type StructuredAgentSessionMutationContext = { openConversation: (sessionId: string) => Promise /** Gives the session a provider child; inside the caller's serialize. */ ensureAgent: (sessionId: string) => Promise - startupAttempts: Pick + startupAttempts: Pick /** Joins a close a stop began on the session's child, for an operation that starts no child; * inside the caller's serialize. */ joinChildClose: (sessionId: string) => Promise 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 8eba7f5bb605..67f12af9733e 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 @@ -10,7 +10,6 @@ import type { 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 { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { HOST_TEST_NOW as NOW, @@ -27,7 +26,6 @@ import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router- const CALLER = { callerKey: 'client-1' } const DEFAULT_MODEL = 'gpt-default' -const PICKED_MODEL = 'gpt-picked' let root: string let store: AgentSessionRecordStore @@ -164,7 +162,7 @@ afterEach(async () => { }) describe('structured session options and close', () => { - it('publishes an acknowledged model without waiting for journal traffic', async () => { + it('keeps provider readback out of the saved next-message choice', async () => { const body = { kind: 'message' as const, role: 'user' as const, @@ -184,47 +182,11 @@ describe('structured session options and close', () => { expect(events).toEqual([ { type: 'snapshot', - sessions: [expect.objectContaining({ status: 'idle', model: DEFAULT_MODEL })] + sessions: [expect.objectContaining({ status: 'idle' })] } ]) - const fields = { key: 'model', value: PICKED_MODEL } - - await host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', fields), - ...fields - }) - - expect(events.slice(1)).toEqual([ - { - type: 'status', - session: expect.objectContaining({ sessionId: SESSION, model: PICKED_MODEL }) - } - ]) - }) - - it('settles a pre-mutation rejection so a fresh retry can succeed', async () => { - optionFailure = new AgentSessionOptionRejectedError('model list unavailable') - const fields = { key: 'model', value: PICKED_MODEL } - const rejected = { - envelope: envelope('agentSession.setOption', fields), - ...fields - } - - expect(await host.setOption(CALLER, rejected)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid', message: 'model list unavailable' } - }) - expect(await host.setOption(CALLER, rejected)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid' } - }) - expect( - await host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', fields), - ...fields - }) - ).toMatchObject({ ok: true, value: { options: { model: PICKED_MODEL } } }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL }) + expect(events[0]).not.toHaveProperty('sessions.0.model') + expect(store.getRecord(SESSION)?.options).toBeUndefined() }) // Closing a chat used to leave its provider child resident for the whole app session: the host's 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 fefe23d1412f..67d9a7e71172 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 @@ -561,12 +561,12 @@ describe('a child that ends before its message is handed over', () => { await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) // The exit's stderr rides beside the sentence, never in it. const failure = { - kind: 'providerExited', + kind: 'providerStartFailed', detail: { text: 'codex app-server crashed', audience: 'log' } } expect(await submission(id)).toMatchObject({ - reason: 'Codex stopped before this message was sent.', - rejection: failure + reason: 'Codex stopped before it finished starting. Send your message to try again.', + rejection: { ...failure, kind: 'providerStartFailed' } }) expect(await statusRows()).toEqual([ { itemId: expect.any(String), text: (await submission(id))?.reason, tone: 'error', failure } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts index 06489fcd7510..385011d0a767 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -1,3 +1,4 @@ +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { expect, it, vi } from 'vitest' import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' import type { @@ -7,6 +8,7 @@ import type { import { CALLER, attach, + attachParams, envelope, hostTestState } from './structured-agent-session-host-test-harness' @@ -138,3 +140,169 @@ it('ends the host open wait when the matching child exits without answering', as }) expect(dispatch).not.toHaveBeenCalled() }) + +it.each(['close', 'quit'] as const)( + 'cancels the open on %s after a second client attach is refused', + async (ending) => { + const { host, open, close, dispatch, pending, send } = awaitOpen() + await attach() + await send('held') + expect(await host.attach({ callerKey: 'second-client' }, attachParams())).toMatchObject({ + ok: false + }) + const ended = + ending === 'quit' + ? host.flushAllStreamedEvents({ trigger: 'quit' }) + : host.close(SESSION, 'user-close') + try { + await vi.waitFor(() => expect(open.mock.calls[0]?.[0].signal.aborted).toBe(true)) + await ended + expect(close).toHaveBeenCalledTimes(1) + expect(dispatch).not.toHaveBeenCalled() + } finally { + pending.resolve(OPENED) + await ended + } + } +) + +it('runs a goal requested during opening before a message sent later', async () => { + const { host, pending, dispatch, send } = awaitOpen() + await attach() + const order: string[] = [] + host.deps.adapter.changeThreadGoal = vi.fn(async () => { + order.push('goal') + return { ok: true as const } + }) + const originalDispatch = dispatch.getMockImplementation() + dispatch.mockImplementation(async (input) => { + order.push('send') + if (!originalDispatch) { + throw new Error('Missing dispatch implementation') + } + return originalDispatch(input) + }) + const change = { kind: 'set' as const, objective: 'Requested before the message' } + const goal = host.changeThreadGoal(CALLER, { + envelope: envelope('agentSession.threadGoal', { change }), + change + }) + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + const later = send('later message') + pending.resolve(OPENED) + expect(await goal).toMatchObject({ ok: true }) + expect(await later).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(order).toHaveLength(2)) + expect(order).toEqual(['goal', 'send']) +}) + +it.each(['stop', 'close'] as const)( + 'cancels a startup rewind history read on %s and ignores its late result', + async (ending) => { + const { host, store, pending, close, dispatch } = awaitOpen() + await attach() + const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + rewind: { + operationId: envelope('agentSession.rewind', {}).clientOperationId, + callerKey: CALLER.callerKey, + itemId: 'codex:thread-1:turn-1:1', + providerItemId: 'codex:thread-1:turn-1:1', + expectedEpoch, + phase: 'prepared', + retained: [] + } + })) + const recovery = Promise.withResolvers<{ ok: true; items: [] }>() + const recover = vi.fn(() => recovery.promise) + host.deps.adapter.recoverRewind = recover + pending.resolve(OPENED) + await vi.waitFor(() => expect(recover).toHaveBeenCalledOnce()) + const ended = + ending === 'close' + ? host.close(SESSION, 'user-close') + : host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + try { + await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()) + await ended + expect(store.getRecord(SESSION)?.rewind?.phase).toBe('prepared') + } finally { + recovery.resolve({ ok: true, items: [] }) + await ended + await host.flushAllStreamedEvents() + } + expect(dispatch).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.rewind?.phase).toBe('prepared') + } +) + +it('saves picks during and after opening without writing them to the live child', async () => { + const { host, store, pending, setOption } = awaitOpen() + await attach() + for (const model of ['model-before-ready', 'model-after-ready']) { + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', { key: 'model', value: model }), + key: 'model', + value: model + }) + ).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.options?.model).toBe(model) + expect(setOption).not.toHaveBeenCalled() + pending.resolve(OPENED) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) + } + expect(store.getRecord(SESSION)?.options?.model).toBe('model-after-ready') +}) + +it('runs a rewind requested during opening before a later message reaches the provider', async () => { + const { host, open, dispatch, pending, send } = awaitOpen() + await attach() + pending.resolve(OPENED) + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) + await send('previous turn') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + await host.flushStreamedEvents(SESSION) + const previous = (await host.journalSnapshot(SESSION)).submissions[0] + if (!previous) { + throw new Error('Missing previous turn') + } + await host.close(SESSION, 'evict') + const reopened = Promise.withResolvers() + open.mockImplementationOnce(() => reopened.promise) + await host.attach(CALLER, attachParams()) + const order: string[] = [] + const originalDispatch = dispatch.getMockImplementation() + dispatch.mockImplementation(async (input) => { + order.push('send') + if (!originalDispatch) { + throw new Error('Missing dispatch implementation') + } + return originalDispatch(input) + }) + host.deps.adapter.rewindSupport = () => ({ supported: true }) + host.deps.adapter.rewind = vi.fn(async () => { + order.push('rewind') + return { ok: false as const, reason: 'history-not-paginated' as const } + }) + const itemId = agentJournalSubmissionKey(previous.clientMessageId) + const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch + const rewind = host.rewind(CALLER, { + envelope: envelope('agentSession.rewind', { itemId, expectedEpoch }), + itemId, + expectedEpoch + }) + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + const later = send('later message') + reopened.resolve(OPENED) + expect(await rewind).toMatchObject({ + ok: false, + refusal: { rewindReason: 'history-not-paginated' } + }) + expect(await later).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(order).toHaveLength(2)) + expect(order).toEqual(['rewind', 'send']) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts index 8a91a8389aab..9e9020fdb1fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts @@ -36,13 +36,17 @@ export async function openStructuredAgentSessionProviderChild( if (attempt.signal.aborted) { return } - await settleStructuredAgentSessionProviderStarted(context, { - ...result, - type: 'started', - sessionId, - fence: child.fence, - acquisitionGeneration: child.generation ?? '' - }) + await settleStructuredAgentSessionProviderStarted( + context, + { + ...result, + type: 'started', + sessionId, + fence: child.fence, + acquisitionGeneration: child.generation ?? '' + }, + attempt.signal + ) } catch (error) { // Stop, close, quit and expiry already own the child's end. if (!attempt.signal.aborted) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index 9fbebde55158..e36758ff177c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -134,7 +134,7 @@ function lastPhase(): string | undefined { } describe('a publish-first Claude create whose init is slow', () => { - it('never persists the catalog default, and persists the reported model once started', async () => { + it('keeps provider defaults out of saved picks after starting', async () => { await expect(host.attach(CALLER, claudeParams())).resolves.toMatchObject({ ok: true }) // Published, not yet answering: the record holds no model rather than a guessed one. @@ -146,7 +146,7 @@ describe('a publish-first Claude create whose init is slow', () => { ) await Promise.all(lifecycle) - expect(store.getRecord(SESSION)?.options?.model).toBe('claude-opus-9') + expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() expect(lastPhase()).toBe('ready') }) @@ -232,7 +232,7 @@ describe('a publish-first Claude create whose init is slow', () => { expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') ) await Promise.all(lifecycle) - expect(store.getRecord(SESSION)?.options?.model).toBe('claude-opus-9') + expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() expect(savedListings.at(-1)).toEqual({ sessionId: SESSION, listing: expect.objectContaining({ 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 1318096382ba..22aae02db81e 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,22 +1,5 @@ -// The host's half of a provider child proving its start. -// -// A starting acquisition hands the host an unproven child, 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 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. +// Opening records the provider handle and recovers history before delivery; reports never replace picks. -import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' import type { StructuredAgentSessionOptionsReportedEvent, StructuredAgentSessionOptionsSkippedEvent, @@ -25,16 +8,13 @@ import type { import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession, - StructuredAgentSessionProviderChild, StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' -import { nativeSessionOptionsFromReport } from './structured-agent-session-option-restoration' import { markProviderChildStarted, sameProviderChild, structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import type { StructuredAgentSessionOptionRevisions } from './structured-agent-session-option-revisions' import { saveStructuredAgentSessionStartListing, saveStructuredAgentSessionStartReadbackListing @@ -42,10 +22,6 @@ import { import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { recordAgentSessionProviderHandle } from '../../runtime/agent-session-provider-handle-transition' -import { - applyStructuredAgentSessionStartupIntent, - revertRefusedStartupIntent -} from './structured-agent-session-startup-intent' import { recoverStructuredRewind } from './structured-rewind-recovery' export type StructuredAgentSessionProviderStartedContext = { @@ -57,10 +33,6 @@ export type StructuredAgentSessionProviderStartedContext = { runtimeState: { startupAttempts: Pick acquireAborts: Pick - optionRevisions: Pick< - StructuredAgentSessionOptionRevisions, - 'admitReport' | 'advance' | 'isNewest' - > } /** The barrier lifts: what was accepted while the child started is handed over now. Settles once * the loop has handed over all it can. */ @@ -69,16 +41,14 @@ export type StructuredAgentSessionProviderStartedContext = { stopStartingChild: (sessionId: string, child: StructuredAgentSessionProviderChildIdentity) => void } -type ReportedOptions = Omit - export async function settleStructuredAgentSessionProviderStarted( context: StructuredAgentSessionProviderStartedContext, - event: StructuredAgentSessionStartedEvent + event: StructuredAgentSessionStartedEvent, + signal?: AbortSignal ): Promise { - // On arrival, before any wait: reports are admitted in the order the child made them. - const admitted = admitReportedOptions(context, event) // Serialized behind the attach that published this child, so the lease it proved is committed. - const started = await context.serialize(event.sessionId, async () => { + await context.serialize(event.sessionId, async () => { + signal?.throwIfAborted() 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. Nor @@ -95,14 +65,11 @@ export async function settleStructuredAgentSessionProviderStarted( context.stopStartingChild(event.sessionId, child) return null } - if (!(await applyPicksMadeWhileStarting(context, event, session.child))) { - return null - } - if (!(await recoverRewindAtStart(context, event.sessionId, session))) { + if (!(await recoverRewindAtStart(context, event.sessionId, session, signal))) { context.stopStartingChild(event.sessionId, child) return null } - // The limit can pass while the picks or the recovery ran; its stop ends this child next. + // The limit can pass while recovery ran; its stop ends this child next. if ( !context.runtimeState.startupAttempts.onClock(event.sessionId, child) || !markProviderChildStarted(session, child) @@ -110,51 +77,14 @@ export async function settleStructuredAgentSessionProviderStarted( return null } context.runtimeState.startupAttempts.ready(event.sessionId, child) - const delivered = context.wakeDelivery(event.sessionId) + void context.wakeDelivery(event.sessionId) noteStructuredAgentSessionProviderStarted(context.deps, event.sessionId) if (event.catalogListing) { saveStructuredAgentSessionStartListing(context, event.sessionId, event.catalogListing) } context.publishStatus?.(event.sessionId) - return { delivered } + return undefined }) - if (!started) { - return - } - // Not awaited: the adapter's next event may be what the handover itself waits on. - 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: @@ -163,7 +93,8 @@ async function applyPicksMadeWhileStarting( async function recoverRewindAtStart( context: StructuredAgentSessionProviderStartedContext, sessionId: string, - session: StructuredAgentSessionHostSession + session: StructuredAgentSessionHostSession, + signal?: AbortSignal ): Promise { try { await recoverStructuredRewind( @@ -172,10 +103,15 @@ async function recoverRewindAtStart( session.journal, structuredAgentSessionConversationFence(context.deps.store, sessionId), context.deps.adapter, - context.now + context.now, + undefined, + signal ) return true } catch (error) { + if (signal?.aborted) { + return false + } context.deps.logger.warn('settling a rewind in doubt at the start failed', { scope: 'rewind-recovery', sessionId, @@ -240,116 +176,16 @@ export async function settleStructuredAgentSessionOptionsReported( return } } - const admitted = admitReportedOptions(context, event) if (event.catalogListing) { void saveStructuredAgentSessionStartReadbackListing(context, event, event.catalogListing) } - return persistReportedOptions(context, event, admitted) -} - -function admitReportedOptions( - context: StructuredAgentSessionProviderStartedContext, - event: ReportedOptions -): number | null { - return context.runtimeState.optionRevisions.admitReport(event.sessionId, event.optionRevision) -} - -/** Non-fatal, and only for the child that reported, while its report is still the newest word: a - * pick or a later report since it was admitted is newer than what it read. */ -function persistReportedOptions( - context: StructuredAgentSessionProviderStartedContext, - event: ReportedOptions, - admitted: number | null -): Promise { - if (admitted === null) { - return Promise.resolve() - } - return context - .serialize(event.sessionId, async () => { - const child = context.sessions.get(event.sessionId)?.child - const record = context.deps.store.getRecord(event.sessionId) - if ( - !child || - !sameProviderChild(child, { - generation: event.acquisitionGeneration, - fence: event.fence - }) || - !record || - record.lease.runtimeFence !== event.fence || - !agentSessionLeaseAdmitsWriter(record.lease) || - !context.runtimeState.optionRevisions.isNewest( - event.sessionId, - event.optionRevision, - admitted - ) - ) { - return - } - try { - await context.deps.store.replaceSessionOptions({ - sessionId: event.sessionId, - fence: event.fence, - options: nativeSessionOptionsFromReport({ - reported: event.reportedOptions, - restoreSkipped: event.restoreSkippedOptions, - ...(event.retiredOptions ? { retired: event.retiredOptions } : {}), - ...(record.options ? { priorOptions: record.options } : {}) - }), - now: context.now() - }) - } finally { - context.publishStatus?.(event.sessionId) - } - }) - .catch((error: unknown) => { - context.deps.logger.warn('recording what a started provider reported failed', { - scope: 'provider-started-options', - sessionId: event.sessionId, - error - }) - }) + return undefined } -/** A running child showed saved options it cannot run: the record drops them, as a start that - * skipped them would, so the next start runs the provider's own. Reported, never thrown. */ +/** A rejected provider value is live protocol evidence; it does not change a later pick. */ export function settleStructuredAgentSessionOptionsSkipped( - context: StructuredAgentSessionProviderStartedContext, - event: StructuredAgentSessionOptionsSkippedEvent + _context: StructuredAgentSessionProviderStartedContext, + _event: StructuredAgentSessionOptionsSkippedEvent ): Promise { - // A report read before the child showed this is out of date: it would write the value back. - context.runtimeState.optionRevisions.advance(event.sessionId) - return context.serialize(event.sessionId, async () => { - const { store } = context.deps - const record = store.getRecord(event.sessionId) - if ( - !record?.options || - record.lease.runtimeFence !== event.fence || - !agentSessionLeaseAdmitsWriter(record.lease) - ) { - return - } - const options = { ...record.options } - for (const [key, value] of Object.entries(event.options)) { - // A pick made since the child launched is the user's, whatever the child showed. - if (options[key] === value) { - delete options[key] - } - } - try { - await store.replaceSessionOptions({ - sessionId: event.sessionId, - fence: event.fence, - options, - now: context.now() - }) - } catch (error) { - context.deps.logger.warn('dropping a saved option the provider cannot run failed', { - scope: 'provider-options-skipped', - sessionId: event.sessionId, - error - }) - } finally { - context.publishStatus?.(event.sessionId) - } - }) + return Promise.resolve() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts index 2590ffd88392..cd128384038c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts @@ -31,9 +31,11 @@ type ReadyOperationPlan = Pick< > type QueuedReadyOperation = | { result: AgentSessionMutationResult } + | { earlierOperations: Promise } | { child: StructuredAgentSessionProviderChildIdentity readiness: StructuredAgentSessionStartReadiness + release?: () => void } export type ReadyOperationChild = StructuredAgentSessionProviderChildIdentity | null @@ -103,6 +105,12 @@ export async function runWithReadyStructuredAgentSessionOperation( const selected = await context.serialize>( envelope.sessionId, async () => { + const earlierOperations = options.needsAgent + ? context.startupAttempts.pendingOperations(envelope.sessionId) + : null + if (earlierOperations) { + return { earlierOperations } + } const record = context.deps.store.getRecord(envelope.sessionId) if (options.needsAgent && !options.needsAgent(record)) { return { result: await run({ ok: true, child: null }) } @@ -190,24 +198,43 @@ export async function runWithReadyStructuredAgentSessionOperation( return { result: child } } if ('readiness' in child && child.readiness) { - return { child: child.child, readiness: child.readiness } + return { + child: child.child, + readiness: child.readiness, + release: child.readiness.reserveOperation?.() + } } return { result: await run(child) } } ) + if ('earlierOperations' in selected) { + await selected.earlierOperations + return runWithReadyStructuredAgentSessionOperation( + context, + callerKey, + envelope, + plan, + run, + options + ) + } if ('result' in selected) { return selected.result } - const outcome = await selected.readiness.wait(deadline - Date.now()) - if (outcome !== 'ready') { - return operationDidNotRun(outcome === 'timeout') - } - if (Date.now() >= deadline) { - return operationDidNotRun(true) + try { + const outcome = await selected.readiness.wait(deadline - Date.now()) + if (outcome !== 'ready') { + return operationDidNotRun(outcome === 'timeout') + } + if (Date.now() >= deadline) { + return operationDidNotRun(true) + } + return await context.serialize(envelope.sessionId, () => + run({ ok: true, child: selected.child, deadline }) + ) + } finally { + selected.release?.() } - return context.serialize(envelope.sessionId, () => - run({ ok: true, child: selected.child, deadline }) - ) } /** Replay uses the ledger answer; a new operation uses only its selected ready child. */ 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 744f933a10f2..72944ea397b6 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 @@ -4,7 +4,6 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { AGENT_SESSION_WIRE_REFUSAL_CODES, type AgentSessionMutationEnvelope, @@ -30,7 +29,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' import { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -96,7 +95,7 @@ async function createHarness(options: { attached?: boolean } = {}) { () => host ) const host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter, @@ -199,13 +198,6 @@ async function assertHostAgreement( return `${spec.method}:${code}` } -async function setLease( - harness: Harness, - update: (record: AgentSessionRecord) => AgentSessionRecord -): Promise { - await harness.store.transitionHandoff(SESSION, update) -} - // sendPlan and setOptionPlan have no unsupported branch. const UNREACHABLE = new Set([ 'agentSession.send:structured_agent_session_unsupported', @@ -222,6 +214,10 @@ const UNREACHABLE = new Set([ 'agentSession.setOption:agent_session_journal_unreadable', // Only a send restarts a lost owner. 'agentSession.setOption:agent_session_owner_restart_failed', + // Saving a next-message pick is a conversation write, independent of its child owner. + 'agentSession.setOption:agent_session_conflict', + 'agentSession.setOption:execution_owner_reconciling', + 'agentSession.setOption:agent_session_operation_unknown', // A write names its target, not an owner generation; only an attach compares fences. 'agentSession.setOption:agent_session_checkpoint_stale', 'agentSession.send:agent_session_checkpoint_stale', @@ -248,25 +244,7 @@ describe('agentSessionRefusalOperationState host oracle', () => { const spec = { method, operationId: operationId(), expectedRuntimeFence: 99 } await expect(invoke(stale, spec), method).resolves.toMatchObject({ ok: true }) } - expect(stale.setOption).toHaveBeenCalledTimes(1) - - const conflict = await createHarness() - for (const method of ['agentSession.setOption'] as const) { - await setLease(conflict, (current) => ({ - ...current, - lease: { ...current.lease, handoffStage: 'new-owner-proving' } - })) - const spec = { method, operationId: operationId() } - record( - await assertHostAgreement(conflict, spec, 'agent_session_conflict', async () => { - await setLease(conflict, (current) => ({ - ...current, - lease: { ...current.lease, handoffStage: null } - })) - return { harness: conflict, spec } - }) - ) - } + expect(stale.setOption).not.toHaveBeenCalled() const absent = await createHarness({ attached: false }) for (const method of METHODS) { @@ -317,10 +295,6 @@ describe('agentSessionRefusalOperationState host oracle', () => { } const unknown = await createHarness() - unknown.setOption.mockRejectedValueOnce(new Error('reply lost')) - const optionUnknown = { method: 'agentSession.setOption' as const, operationId: operationId() } - await expect(invoke(unknown, optionUnknown)).rejects.toThrow('reply lost') - record(await assertHostAgreement(unknown, optionUnknown, 'agent_session_operation_unknown')) const sendUnknown = { method: 'agentSession.send' as const, operationId: operationId() } await expect(invoke(unknown, sendUnknown)).resolves.toMatchObject({ ok: true }) openTestJournalHostDatabase(unknown.root) @@ -330,24 +304,6 @@ describe('agentSessionRefusalOperationState host oracle', () => { .run(sendUnknown.operationId) record(await assertHostAgreement(unknown, sendUnknown, 'agent_session_operation_unknown')) - const reconciling = await createHarness() - for (const method of ['agentSession.setOption'] as const) { - await setLease(reconciling, (current) => ({ - ...current, - lease: { ...current.lease, unreconciled: true } - })) - const spec = { method, operationId: operationId() } - record( - await assertHostAgreement(reconciling, spec, 'execution_owner_reconciling', async () => { - await setLease(reconciling, (current) => ({ - ...current, - lease: { ...current.lease, unreconciled: false } - })) - return { harness: reconciling, spec } - }) - ) - } - const unreadable = await createHarness() await unreadable.host.close(SESSION, 'evict') const unreadableOpen = vi 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 426e04c63936..158a0a360951 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 @@ -121,10 +121,12 @@ describe('a press sent again', () => { await pick('B') expect(await pick('A', first)).toMatchObject({ ok: true, replayed: true }) - expect(setOption.mock.calls.map(([input]) => input.value)).toEqual(['A', 'B']) + expect(setOption).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.options?.model).toBe('B') expect(await pick('A')).toMatchObject({ ok: true, replayed: false }) - expect(setOption.mock.calls.map(([input]) => input.value)).toEqual(['A', 'B', 'A']) + expect(setOption).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.options?.model).toBe('A') }) it('leaves a cleared goal cleared under the first id, and sets it under a new one', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-readiness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-readiness.ts new file mode 100644 index 000000000000..01687efd6c4c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-readiness.ts @@ -0,0 +1,48 @@ +export type StructuredAgentSessionStartReadiness = { + wait: (budgetMs?: number) => Promise<'ready' | 'ended' | 'timeout'> + reserveOperation?: () => () => void + waitForOperations?: () => Promise +} + +export async function waitForStructuredAgentSessionStart( + ready: Promise<'ready' | 'ended'>, + budgetMs?: number +): Promise<'ready' | 'ended' | 'timeout'> { + if (budgetMs === undefined) { + return ready + } + let timer: ReturnType | undefined + const timeout = new Promise<'timeout'>((resolve) => { + timer = setTimeout(() => resolve('timeout'), Math.max(0, budgetMs)) + timer.unref?.() + }) + try { + return await Promise.race([ready, timeout]) + } finally { + clearTimeout(timer) + } +} + +/** Reservations last only as long as the caller's bounded wait and queued operation. */ +export class StructuredAgentSessionOperationOrder { + private readonly operations = new Map>>() + + reserve(sessionId: string): () => void { + const operation = Promise.withResolvers() + const pending = this.operations.get(sessionId) ?? new Set>() + pending.add(operation.promise) + this.operations.set(sessionId, pending) + return () => { + operation.resolve() + pending.delete(operation.promise) + if (pending.size === 0 && this.operations.get(sessionId) === pending) { + this.operations.delete(sessionId) + } + } + } + + pending(sessionId: string): Promise | null { + const pending = this.operations.get(sessionId) + return pending?.size ? Promise.all(pending).then(() => undefined) : null + } +} 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 c453ac96d861..bdc7d8c1174c 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 @@ -188,23 +188,6 @@ function startedEvent(generation = `generation-${acquire.mock.calls.length}`) { } } -function pick(value: string) { - const fields = { key: 'model', value } - return host.setOption(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.setOption', - sessionId: SESSION, - fields - }) - }, - ...fields - }) -} - /** The send's start has published its child, still starting; the host holds the send. */ async function heldBehindStart(text: string): Promise { const id = await accept(text) @@ -436,149 +419,38 @@ describe('a start that ends before it proves itself', () => { }) describe('what a started child reports', () => { - it('is persisted only after what was held is handed over', async () => { - await restartWith(publishFirst) - await heldBehindStart('hello') - const order: string[] = [] - dispatch.mockImplementationOnce(async (input) => { - order.push('dispatch') - return { - state: 'accepted' as const, - providerIdentity: { - provider: 'codex' as const, - threadId: THREAD, - turnId: `turn-${input.clientMessageId}`, - ordinal: 1 - } - } - }) - const replace = store.replaceSessionOptions.bind(store) - vi.spyOn(store, 'replaceSessionOptions').mockImplementation(async (args) => { - order.push('persist') - return replace(args) - }) - - await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'reported' } }) - - await eventually(() => expect(order).toEqual(['dispatch', 'persist'])) - expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'reported' }) - }) - - it('never overwrites a pick made after the child reported', async () => { - await restartWith(publishFirst) - await heldBehindStart('hello') - const report = { ...startedEvent(), reportedOptions: { model: 'reported' } } - dispatch.mockImplementationOnce(async () => { - // A pick lands while the first handover is still running. - void pick('picked') - return { state: 'admitted' as const } - }) - - await host.handleAdapterEvent(report) - await eventually(() => expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' })) - await new Promise((resolve) => setTimeout(resolve, 50)) - await host.flushStreamedEvents(SESSION) - - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' }) - }) - - it('never lets a settings read made before a pick undo it, even queued behind the pick', async () => { + it('keeps provider reports out of the saved next-message choice', async () => { await restartWith(publishFirst) const id = await heldBehindStart('hello') - await host.handleAdapterEvent(startedEvent()) + await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'reported' } }) await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - await eventually(() => expect(store.getRecord(SESSION)?.options).toEqual({ model: 'default' })) - // The settings read starts, then the user picks while it is out. - const readAt = optionRevisionNow() - let landPick = (): void => {} - setOption.mockImplementationOnce( - () => - new Promise((resolve) => { - landPick = () => resolve(undefined) - }) - ) - const picked = pick('picked') - await eventually(() => expect(setOption).toHaveBeenCalled()) - - // The read answers with what the child ran before the pick, while the pick is still in flight. - const reported = host.handleAdapterEvent({ - ...startedEvent(), - type: 'options-reported', - reportedOptions: { model: 'default' }, - optionRevision: readAt - }) - landPick() - expect(await picked).toMatchObject({ ok: true }) - await reported - await host.flushStreamedEvents(SESSION) - - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' }) - }) - - it('never lands the start’s report after a newer settings report', async () => { - await restartWith(publishFirst) - await heldBehindStart('hello') - let landHandover = (): void => {} - dispatch.mockImplementationOnce( - () => - new Promise((resolve) => { - landHandover = () => resolve({ state: 'admitted' as const }) - }) - ) - // The start's report waits on the handover; the settings report that follows it does not. - await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'at-start' } }) - await eventually(() => expect(dispatch).toHaveBeenCalled()) - const reported = host.handleAdapterEvent({ + await host.handleAdapterEvent({ ...startedEvent(), type: 'options-reported', reportedOptions: { model: 'from-settings' } }) - - landHandover() - await reported - await new Promise((resolve) => setTimeout(resolve, 50)) await host.flushStreamedEvents(SESSION) - - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'from-settings' }) + expect(store.getRecord(SESSION)?.options).toBeUndefined() }) - it('keeps a settings read stamped before the host took the start’s report', async () => { - await restartWith(publishFirst) - await heldBehindStart('hello') - // Both reads are stamped before the host takes either report, as a delivery that lags would. - const started = { ...startedEvent(), reportedOptions: { model: 'at-start' } } - const settings = { - ...started, - type: 'options-reported' as const, - reportedOptions: { model: 'from-settings' } - } - - await host.handleAdapterEvent(started) - await host.handleAdapterEvent(settings) - await new Promise((resolve) => setTimeout(resolve, 50)) - await host.flushStreamedEvents(SESSION) - - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'from-settings' }) - }) - - it('takes a report read after a pick that failed', async () => { + it('preserves a later accepted pick when an older child reports options', async () => { await restartWith(publishFirst) const id = await heldBehindStart('hello') - await host.handleAdapterEvent(startedEvent()) + await store.replaceSessionOptions({ + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + options: { model: 'picked' }, + now: NOW + }) + await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'reported' } }) await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - await eventually(() => expect(store.getRecord(SESSION)?.options).toEqual({ model: 'default' })) - setOption.mockRejectedValueOnce(new Error('the provider refused the model')) - await expect(pick('refused')).rejects.toThrow('the provider refused the model') - await host.handleAdapterEvent({ ...startedEvent(), type: 'options-reported', reportedOptions: { model: 'from-settings' } }) - - await eventually(() => - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'from-settings' }) - ) + await host.flushStreamedEvents(SESSION) + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts index 64c681c9f9ed..9e263dbc0bd0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts @@ -1,3 +1,4 @@ +import type { StructuredAgentSessionProviderOpenResult } from './structured-agent-session-provider-open-contract' // Every agent this build registers starts under the host's one startup attempt and its hold on // delivery. Iterates the runtime's real registration list, so an agent registered later is covered // without being named here. @@ -64,6 +65,7 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ store: AgentSessionRecordStore acquire: Mock dispatch: Mock + opened: PromiseWithResolvers }> { root = await mkdtemp(join(tmpdir(), 'orca-startup-registrations-')) resetHostTestOperationIds() @@ -90,7 +92,9 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ const dispatch: Mock = vi.fn(async () => ({ state: 'admitted' as const })) + const opened = Promise.withResolvers() const adapter: StructuredAgentSessionAdapter = { + open: () => opened.promise, acquire, dispatch, closeSession: vi.fn(async () => true), @@ -114,7 +118,7 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, now: () => NOW }) - return { host, store, acquire, dispatch } + return { host, store, acquire, dispatch, opened } } describe.each( @@ -123,7 +127,7 @@ describe.each( ) )('%s starts under the host attempt', (agent, definition) => { it('acquires under a host-minted attempt and is handed nothing until it proves its start', async () => { - const { host, store, acquire, dispatch } = await hostFor(definition) + const { host, store, acquire, dispatch, opened } = await hostFor(definition) try { const attached = await host.attach( CALLER, @@ -160,17 +164,11 @@ describe.each( }) expect(sent).toMatchObject({ ok: true }) await vi.waitFor(() => - expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe( - false - ) + expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(true) ) expect(dispatch).not.toHaveBeenCalled() - await host.handleAdapterEvent({ - type: 'started', - sessionId: SESSION, - fence: record.lease.runtimeFence, - acquisitionGeneration: 'generation-1', + opened.resolve({ reportedOptions: { model: 'default' }, restoreSkippedOptions: [], optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) 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 8e19074321bf..04604c518bb3 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 @@ -11,6 +11,12 @@ // attempt ends nothing. import { randomUUID } from 'node:crypto' +import { + StructuredAgentSessionOperationOrder, + waitForStructuredAgentSessionStart, + type StructuredAgentSessionStartReadiness +} from './structured-agent-session-start-readiness' +export type { StructuredAgentSessionStartReadiness } from './structured-agent-session-start-readiness' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' @@ -100,14 +106,11 @@ type Tracked = { readiness: PromiseWithResolvers<'ready' | 'ended'> } -export type StructuredAgentSessionStartReadiness = { - wait: (budgetMs?: number) => Promise<'ready' | 'ended' | 'timeout'> -} - /** Each session's open attempt and its startup clock. A session starts one child at a time, so a * new attempt replaces any older one. */ export class StructuredAgentSessionStartupAttempts { private readonly open = new Map() + private readonly operationOrder = new StructuredAgentSessionOperationOrder() private disposed = false private readonly limits: StructuredAgentSessionStartupLimits private readonly now: () => number @@ -174,24 +177,16 @@ export class StructuredAgentSessionStartupAttempts { return null } return { - wait: async (budgetMs) => { - if (budgetMs === undefined) { - return tracked.readiness.promise - } - let timer: ReturnType | undefined - const timeout = new Promise<'timeout'>((resolve) => { - timer = setTimeout(() => resolve('timeout'), Math.max(0, budgetMs)) - timer.unref?.() - }) - try { - return await Promise.race([tracked.readiness.promise, timeout]) - } finally { - clearTimeout(timer) - } - } + reserveOperation: () => this.operationOrder.reserve(sessionId), + waitForOperations: () => this.operationOrder.pending(sessionId) ?? Promise.resolve(), + wait: (budgetMs) => waitForStructuredAgentSessionStart(tracked.readiness.promise, budgetMs) } } + pendingOperations(sessionId: string): Promise | null { + return this.operationOrder.pending(sessionId) + } + /** 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( 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 deleted file mode 100644 index c530536f548c..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-intent.ts +++ /dev/null @@ -1,143 +0,0 @@ -// 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 { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../shared/agent-chat-permission-mode' -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 ?? {}) - // Permission changes replace the child at delivery; providers read them only at launch. - .filter( - ([key, value]) => key !== AGENT_CHAT_PERMISSION_MODE_OPTION_ID && 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-permission-mode.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts deleted file mode 100644 index bd8f9aeb6412..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { - createRestTestRig, - REST_TEST_CALLER as CALLER, - REST_TEST_SESSION as SESSION, - restTestSend -} from './structured-agent-session-rest-test-rig' -import { - hostTestAttachParams, - hostTestOperationId -} from './structured-agent-session-host-test-data' - -it('keeps a permission pick while accepting the startup model report', async () => { - const rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) - try { - const acquire = rig.adapter.acquire.getMockImplementation() - if (!acquire) { - throw new Error('missing test acquisition') - } - rig.adapter.acquire.mockImplementationOnce(rig.heldStart(acquire)) - const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) - if (!attached.ok) { - throw new Error('test attach refused') - } - const child = await rig.adapter.acquire.mock.results[0].value - const fields = { key: 'permissionMode', value: 'full-access' } - expect( - await rig.host.setOption(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: attached.fence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.setOption', - sessionId: SESSION, - fields - }) - }, - ...fields - }) - ).toMatchObject({ ok: true }) - - await rig.host.handleAdapterEvent({ - type: 'started', - sessionId: SESSION, - fence: attached.fence, - acquisitionGeneration: child.acquisitionGeneration!, - reportedOptions: { model: 'provider-model', effort: 'medium' }, - restoreSkippedOptions: [], - optionRevision: 0 - }) - await vi.waitFor(() => - expect(rig.store.getRecord(SESSION)?.options).toEqual({ - model: 'provider-model', - effort: 'medium', - permissionMode: 'full-access' - }) - ) - } finally { - await rig.dispose() - } -}) - -it.each([false, true])( - 'keeps startup picks through replacement with dispatch ownership: %s', - async (hasOpenDispatch) => { - const rig = await createRestTestRig({ - idleSweep: { intervalMs: 3_600_000 }, - hasOpenDispatch: () => hasOpenDispatch - }) - try { - const acquire = rig.adapter.acquire.getMockImplementation() - if (!acquire) { - throw new Error('missing test acquisition') - } - rig.adapter.acquire.mockImplementationOnce(rig.heldStart(acquire)) - const writes = vi.spyOn(rig.host.deps.adapter, 'setOption') - const attached = await rig.host.attach(CALLER, { - ...hostTestAttachParams(null), - options: { model: 'launched-model', effort: 'low' } - }) - if (!attached.ok) { - throw new Error('test attach refused') - } - const firstChild = await rig.adapter.acquire.mock.results[0].value - const sent = await rig.host.send(CALLER, restTestSend('held while starting', attached.fence)) - expect(sent.ok).toBe(true) - for (const [key, value] of [ - ['effort', 'high'], - ['permissionMode', 'full-access'], - ['model', 'picked-model'] - ]) { - const fields = { key, value } - expect( - await rig.host.setOption(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: attached.fence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.setOption', - sessionId: SESSION, - fields - }) - }, - ...fields - }) - ).toMatchObject({ ok: true }) - } - expect(writes).not.toHaveBeenCalled() - expect(rig.adapter.dispatch).not.toHaveBeenCalled() - - await rig.host.handleAdapterEvent({ - type: 'started', - sessionId: SESSION, - fence: attached.fence, - acquisitionGeneration: firstChild.acquisitionGeneration!, - reportedOptions: { model: 'launched-model', effort: 'low' }, - restoreSkippedOptions: [], - optionRevision: -1 - }) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) - expect(writes.mock.calls.map(([input]) => [input.key, input.value])).toEqual([ - ['model', 'picked-model'], - ['effort', 'high'] - ]) - const options = { model: 'picked-model', effort: 'high', permissionMode: 'full-access' } - expect(rig.adapter.closeSession).toHaveBeenCalledTimes(hasOpenDispatch ? 0 : 1) - expect(rig.adapter.acquire).toHaveBeenCalledTimes(hasOpenDispatch ? 1 : 2) - expect(rig.adapter.acquire).toHaveBeenLastCalledWith( - expect.objectContaining( - hasOpenDispatch ? { permissionMode: 'ask' } : { permissionMode: 'full-access', options } - ) - ) - expect(rig.adapter.acquire.mock.invocationCallOrder[hasOpenDispatch ? 0 : 1]).toBeLessThan( - rig.adapter.dispatch.mock.invocationCallOrder[0] - ) - expect(rig.store.getRecord(SESSION)?.options).toEqual(options) - - await rig.restart() - expect((await rig.host.readOptions(SESSION)).current.permissionMode).toBe('full-access') - const nextFence = rig.store.getRecord(SESSION)!.lease.runtimeFence - expect((await rig.host.send(CALLER, restTestSend('after restart', nextFence))).ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) - expect(rig.adapter.acquire).toHaveBeenLastCalledWith( - expect.objectContaining({ permissionMode: 'full-access', options }) - ) - } finally { - await rig.dispose() - } - } -) 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 deleted file mode 100644 index 1b3b2e1a5beb..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -// 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 { - providerChildPhase: 'starting' as const, - 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-stop-never-waits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts index 6ee43bd38de3..150f7f611cb7 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 @@ -51,10 +51,7 @@ async function runningTurn(): Promise { /** A child whose handshake never answers, so a Stop ends the start. */ async function startingChild(): Promise<{ journal: AgentSessionJournal; closeSession: Mock }> { - const handle = host.handleAdapterEvent.bind(host) - vi.spyOn(host, 'handleAdapterEvent').mockImplementation(async (event) => - event.type === 'started' ? undefined : handle(event) - ) + host.deps.adapter.open = () => new Promise(() => {}) const closeSession = vi.fn(async () => true) Object.assign(host.deps.adapter, { closeSession }) await attach() diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts index b0320ffdcbcf..0134635df70e 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts @@ -1,3 +1,4 @@ +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import { mergeRetainedHostLifecycleRows, retainedRowReplacement @@ -78,8 +79,10 @@ export async function recoverStructuredRewind( rewind: AgentSessionRewindRecord, fence: number, cursor: AgentJournalCursor - ) => JournalOperationReceipt + ) => JournalOperationReceipt, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() const { store } = deps let rewind = store.getRecord(sessionId)?.rewind if (rewind?.phase !== 'provider-succeeded' && rewind?.phase !== 'prepared') { @@ -91,11 +94,16 @@ export async function recoverStructuredRewind( return } if (target?.provider === 'codex' && !rewind.hydrationVerified) { - const recovered = await adapter?.recoverRewind?.({ + const read = adapter?.recoverRewind?.({ sessionId, fence, - beforeTurnId: target.turnId + beforeTurnId: target.turnId, + ...(signal ? { signal } : {}) }) + const recovered = signal + ? await waitForPromiseWithSignal(Promise.resolve(read), signal) + : await read + signal?.throwIfAborted() if (!recovered?.ok) { // The target is still in the provider's history, and the journal is replaced only once the // revert is proven, so both still hold it even when the provider acknowledged the revert. diff --git a/src/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 6e12ee6543ef..1b95d7b9a122 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 @@ -169,7 +169,7 @@ 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 () => { + it('derives launch picks while held and drops them once the host owns the choice', async () => { saveSelection('gpt-5.5') const { sessionId } = startStructuredAgentLaunch('wt-picks', 'codex', { requestId: 'request-4' @@ -190,7 +190,8 @@ describe('a chat pane over its own launch', () => { settleStructuredLaunchCallers(state.callers, 'published') }) expect(getStructuredLaunchStateBySessionId(sessionId)).toBeUndefined() - expect(result.current.launch?.seedOptions).toMatchObject({ model: 'gpt-5.6-luna' }) + expect(result.current.launch?.seedOptions).toBeUndefined() + expect(result.current.launch?.heldOptions).toEqual({}) }) it('shows a pick the launch could not apply the way a refused option change is shown', async () => { @@ -216,6 +217,16 @@ describe('a chat pane over its own launch', () => { publishedTabs = [publishedSnapshot('wt-refused', sessionId)] readState = sessionState(1) + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' + ? Promise.resolve({ models: SAVED_LIST.models, current: { model: 'gpt-5.5' } }) + : method === 'agentSession.setOption' + ? Promise.resolve({ + ok: false, + refusal: { code: 'invalid_option', message: 'GPT-5.6 Luna is not available' } + }) + : answerCatalog(method) + ) await act(async () => { created({ sessionId, fence: 1 }) await launch.launchResult @@ -225,6 +236,6 @@ describe('a chat pane over its own launch', () => { expect(mocks.toastError).toHaveBeenCalledWith("The setting wasn't changed.") ) // Reverted to what the chat runs; the refusal never kept the launch from publishing. - expect(currentModel(result.current.optionSnapshot)).toBe('gpt-5.5') + await waitFor(() => expect(currentModel(result.current.optionSnapshot)).toBe('gpt-5.5')) }) }) 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 7588e6b27feb..84707aaddbc3 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 @@ -23,65 +23,27 @@ export type StructuredAgentSessionLaunchView = { const NO_HELD_OPTIONS: Readonly> = {} -type LatchedLaunch = { - kind: StructuredAgentSessionLaunchView['kind'] - 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( +function useLaunchView( sessionId: string, worktreeId: string | null | undefined, launching: boolean ): StructuredAgentSessionLaunchView | undefined { const selection = useStructuredAgentSessionLaunchSelection(sessionId) - const [latched, setLatched] = useState(() => - launching - ? { - kind: getStructuredAgentSessionLaunchResumes(sessionId) ? 'resume' : 'new', - seed: selection ? launchPicks(selection) : undefined - } - : null + const [kind] = useState(() => + launching ? (getStructuredAgentSessionLaunchResumes(sessionId) ? 'resume' : 'new') : null ) - if (latched && selection && !sameOptions(launchPicks(selection), latched.seed)) { - setLatched({ kind: latched.kind, seed: launchPicks(selection) }) - } const held = selection?.held ?? NO_HELD_OPTIONS return useMemo( () => - latched + kind ? { - kind: latched.kind, - ...(latched.seed ? { seedOptions: latched.seed } : {}), + kind, + ...(selection?.seed ? { seedOptions: selection.seed } : {}), heldOptions: held, ...(worktreeId ? { worktree: toRuntimeWorktreeSelector(worktreeId) } : {}) } : undefined, - [held, latched, worktreeId] + [held, kind, selection?.seed, worktreeId] ) } @@ -91,7 +53,7 @@ export function useNativeChatProvisionalLaunch( ) { const lifecycle = useStructuredAgentSessionLaunchLifecycle(worktreeId ?? '', sessionId) const failure = useStructuredAgentSessionLaunchFailure(worktreeId ?? '', sessionId) - const launch = useLatchedLaunchView(sessionId, worktreeId, lifecycle !== null) + const launch = useLaunchView(sessionId, worktreeId, lifecycle !== null) const retry = useCallback(() => { if (worktreeId) { retryStructuredAgentSessionLaunch(worktreeId, sessionId) From a5abf73b8a8397520ed09ff239cb63f8f114f240 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:38:00 -0700 Subject: [PATCH 22/55] Derive Codex startup exits from host phase --- .../codex/codex-structured-publish-at-spawn.test.ts | 3 +-- src/main/codex/codex-structured-session-acquire.ts | 3 +-- src/main/codex/codex-structured-session-start.ts | 2 +- ...ctured-agent-session-accept-then-deliver.test.ts | 3 +-- .../structured-agent-session-adapter.ts | 2 -- ...structured-agent-session-append-delivery.test.ts | 3 +-- .../structured-agent-session-child-exit.ts | 6 +----- ...ured-agent-session-provider-child-record.test.ts | 13 ++++++------- ...tructured-agent-session-send-preparation.test.ts | 3 +-- ...agent-session-send-restarts-failed-start.test.ts | 3 +-- ...tured-agent-session-start-failure-writer.test.ts | 6 ++---- ...tured-agent-session-startup-attempt-host.test.ts | 3 +-- ...tured-agent-session-startup-failure-exit.test.ts | 10 ++++++---- .../structured-agent-runtime-registrations.ts | 6 +----- 14 files changed, 24 insertions(+), 42 deletions(-) diff --git a/src/main/codex/codex-structured-publish-at-spawn.test.ts b/src/main/codex/codex-structured-publish-at-spawn.test.ts index 1c64cca70a26..523c8c94f548 100644 --- a/src/main/codex/codex-structured-publish-at-spawn.test.ts +++ b/src/main/codex/codex-structured-publish-at-spawn.test.ts @@ -188,8 +188,7 @@ describe('Codex host-awaited open', () => { expect(events).toContainEqual( expect.objectContaining({ type: 'ended', - cause: 'unexpected-exit', - startupUnproven: true + cause: 'unexpected-exit' }) ) expect(codex.connections[0].closeCount).toBe(0) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 5a905b8b56c2..2121cf50ad98 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -169,8 +169,7 @@ export async function acquireCodexStructuredSession(input: { : agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(error) }), - observedAt: deps.now?.() ?? Date.now(), - startupUnproven: true + observedAt: deps.now?.() ?? Date.now() }) } if (sessions.get(sessionId)?.connection === acquisition.connection) { diff --git a/src/main/codex/codex-structured-session-start.ts b/src/main/codex/codex-structured-session-start.ts index 74975510502e..4b8bde2919d8 100644 --- a/src/main/codex/codex-structured-session-start.ts +++ b/src/main/codex/codex-structured-session-start.ts @@ -76,6 +76,7 @@ export async function openCodexStructuredSession( assertCodexConnectionOpen(connection, sessionId) } current() + const optionRevision = input.optionRevision?.() ?? 0 await waitForPromiseWithSignal( initializeCodexAppServerConnection(connection, STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS), openInput.signal @@ -89,7 +90,6 @@ export async function openCodexStructuredSession( openInput.signal ) current() - const optionRevision = input.optionRevision?.() ?? 0 const opened = await waitForPromiseWithSignal( openCodexThread( connection, 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 c668863d40c7..5bea0c49a235 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 @@ -656,8 +656,7 @@ describe('a start that fails while messages wait on it', () => { failure: agentSessionFailureFact('providerStartFailed', { detail: { text: 'codex: config.toml is invalid', audience: 'person' } }), - cause: 'unexpected-exit', - startupUnproven: true + cause: 'unexpected-exit' }) await host.flushStreamedEvents(SESSION) 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 2d1f72bfa4ca..cd8c7e125eb8 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 @@ -202,8 +202,6 @@ export type StructuredAgentSessionEndedEvent = { acquisitionGeneration: string /** Host receipt of the child exit: the end time of a turn it interrupted. */ observedAt?: number - /** The provider ended before it finished starting, so resuming it would repeat the failure. */ - startupUnproven?: true /** The provider ended before it answered its start, so it ran nothing it was handed. */ startupUnanswered?: true } 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 c5ce6a79984c..b8ebf78b9370 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -100,8 +100,7 @@ function exitBeforeProof(): Promise { acquisitionGeneration: `generation-${generation}`, reason: EXIT_REASON, failure: { kind: 'providerExited', detail: { text: EXIT_REASON, audience: 'log' } }, - cause: 'unexpected-exit', - startupUnproven: true + cause: 'unexpected-exit' }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index ec64b076e5ba..4181bf1a09ce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -40,7 +40,6 @@ export type StructuredAgentSessionChildExit = { failure?: SubmissionRejectionFact /** Host receipt of the exit: the end time of a turn it interrupted. */ observedAt?: number - startupUnproven?: true startupUnanswered?: true } @@ -96,7 +95,6 @@ export function settleStructuredAgentSessionChildExit< reason: event.reason, ...(event.failure ? { failure: event.failure } : {}), ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), - ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}), ...(event.startupUnanswered ? { startupUnanswered: event.startupUnanswered } : {}) }) }) @@ -119,9 +117,7 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< const close = child.close // Receipt of the exit is the one end time the host may record for a running turn. const observedAt = exit.observedAt ?? context.now() - // The host's own phase decides, so a provider that omits the flag still gets a start that - // failed told as one: the row says so. - const exitedDuringStartup = exit.startupUnproven === true || child.phase === 'starting' + const exitedDuringStartup = child.phase === 'starting' if (child.phase === 'starting') { context.abortOpen?.(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 67d9a7e71172..8cbd7e4e5d92 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 @@ -230,15 +230,14 @@ function prove(): Promise { }) } -function exit(child: ReturnType, reason: string, startupUnproven?: true) { +function exit(child: ReturnType, reason: string) { return host.handleAdapterEvent({ type: 'ended', ...child, reason, // As an adapter reports it: the exit's stderr as a log detail. failure: { kind: 'providerExited', detail: { text: reason, audience: 'log' } }, - cause: 'unexpected-exit', - ...(startupUnproven ? { startupUnproven } : {}) + cause: 'unexpected-exit' }) } @@ -407,7 +406,7 @@ describe('a published child that dies while it proves its start', () => { await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) const child = currentChild() - await exit(child, EXIT, true) + await exit(child, EXIT) await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) expect(await statusRows()).toEqual([ @@ -453,7 +452,7 @@ describe('a start another operation made that dies while a sent message waits on // Accepted first; that child's exit is settled before the loop's first step. const sent = host.send(CALLER, params) - const exited = exit(operationChild, EXIT, true) + const exited = exit(operationChild, EXIT) expect(await sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } @@ -482,7 +481,7 @@ describe('a start another operation made that dies while a sent message waits on await restartHost() acquire.mockImplementationOnce(spawnStartingChild) await startForOperation() - await exit(currentChild(), EXIT, true) + await exit(currentChild(), EXIT) expect(await statusRows()).toHaveLength(1) const id = await accept('after the failure') @@ -515,7 +514,7 @@ describe('a start another operation made that dies while a sent message waits on // A second operation's start lands after the first child's exit, before the loop's first step. const sent = host.send(CALLER, params) - const exited = exit(currentChild(), EXIT, true) + const exited = exit(currentChild(), EXIT) const held = startForOperation() await sent await exited 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 a97271a580a9..431569f6fa97 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 @@ -255,8 +255,7 @@ describe('a send with no live owner', () => { reason: 'startup deadline', cause: 'unexpected-exit', fence: params.envelope.expectedRuntimeFence ?? 0, - acquisitionGeneration: 'generation-1', - startupUnproven: true + acquisitionGeneration: 'generation-1' }) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') acquire.mockClear() 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 a3fd1745f034..f347dc738615 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 @@ -113,8 +113,7 @@ function exitBeforeProof(): Promise { ...currentChild(), reason: EXIT_REASON, failure: { kind: 'providerExited', detail: { text: EXIT_REASON, audience: 'log' } }, - cause: 'unexpected-exit', - startupUnproven: true + cause: 'unexpected-exit' }) } 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 3bfa2112f3f8..f71343d75d58 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -57,8 +57,7 @@ function exitBeforeProof(): Promise { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, acquisitionGeneration: `generation-${generation}`, reason: EXIT_REASON, - cause: 'unexpected-exit', - startupUnproven: true + cause: 'unexpected-exit' }) } @@ -144,8 +143,7 @@ beforeEach(async () => { fence, acquisitionGeneration: child.acquisitionGeneration, reason: EXIT_REASON, - cause: 'unexpected-exit', - startupUnproven: true + cause: 'unexpected-exit' }) } return child diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts index bdc7d8c1174c..350c842858d6 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 @@ -288,8 +288,7 @@ describe('a start that ends before it proves itself', () => { fence: store.getRecord(SESSION)!.lease.runtimeFence, acquisitionGeneration: `generation-${acquire.mock.calls.length}`, reason: 'exited with code 1', - cause: 'unexpected-exit', - startupUnproven: true + cause: 'unexpected-exit' }) await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index fed25d7dc9b0..82361467d704 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -75,13 +75,15 @@ const ended = { describe('a provider that ends before it finished starting', () => { it('tells the user why, even with no response in progress', async () => { - const session = startedSession() + const session = { + ...startedSession(), + child: { generation: GENERATION, fence: 7, phase: 'starting' as const } + } await settleStructuredAgentSessionChildExit(contextFor(session), { ...ended, // The adapter typed the start's own failure; the host keeps it rather than reword it. - failure: { kind: 'notSignedIn' }, - startupUnproven: true + failure: { kind: 'notSignedIn' } }) expect(session.journal.appendLifecycleBatch).toHaveBeenCalledWith( @@ -111,7 +113,7 @@ describe('a provider that ends before it finished starting', () => { expect(session.journal.appendLifecycleBatch).not.toHaveBeenCalled() }) - it("reads a start that failed off the host's own phase when the provider omits the flag", async () => { + it("reads a start that failed off the host's own phase", async () => { const session = { ...startedSession(), child: { generation: GENERATION, fence: 7, phase: 'starting' as const } diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 6329c095110e..b39fc13efe72 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -155,11 +155,7 @@ function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredA onPrimaryThreadStoppedRunning: followUps.releaseUnansweredDispatches, logger: deps.logger, onEvent: (event) => { - if ( - event.type === 'started' || - event.type === 'options-reported' || - (event.type === 'ended' && 'cause' in event) - ) { + if (event.type === 'ended' && 'cause' in event) { context.deliverLifecycle(event) } } From eb1b8fa061280c10eb9b9e3996cdb0277473a928 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:08:59 -0700 Subject: [PATCH 23/55] Open Codex protocol in background-task test fixtures --- src/main/codex/codex-background-terminals.test.ts | 5 +++-- src/main/codex/codex-subagent-interrupts.test.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/codex/codex-background-terminals.test.ts b/src/main/codex/codex-background-terminals.test.ts index 818c8d2a6a06..c2cfb8deb24a 100644 --- a/src/main/codex/codex-background-terminals.test.ts +++ b/src/main/codex/codex-background-terminals.test.ts @@ -14,6 +14,7 @@ import { terminateCodexBackgroundTerminals } from './codex-background-terminals' import { + acquireReadyCodexForTest, adapterFor, fakeCodex, identityFor, @@ -93,7 +94,7 @@ function commandItem(status: 'inProgress' | 'completed') { /** A turn that starts a dev server and ends with it still running. */ async function backgroundedDevServer(codex: ReturnType): Promise { - await codex.adapter.acquire({ identity, fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(codex.adapter, { identity, fence: 7, spawnToken: 'spawn-1' }) codex.notify('turn/started', { threadId: THREAD_ID, turn: { id: TURN_ID, status: 'inProgress' } }) codex.notify('item/started', { threadId: THREAD_ID, @@ -226,7 +227,7 @@ describe('Codex background-command Stop', () => { return { data: [], nextCursor: null } } }) - await codex.adapter.acquire({ identity, fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(codex.adapter, { identity, fence: 7, spawnToken: 'spawn-1' }) codex.notify('turn/started', { threadId: THREAD_ID, turn: { id: TURN_ID, status: 'inProgress' } diff --git a/src/main/codex/codex-subagent-interrupts.test.ts b/src/main/codex/codex-subagent-interrupts.test.ts index ea04708fe45c..522b14d19883 100644 --- a/src/main/codex/codex-subagent-interrupts.test.ts +++ b/src/main/codex/codex-subagent-interrupts.test.ts @@ -4,6 +4,7 @@ import { codexPrimaryCommandTaskId } from '../../shared/structured-session-foreg import { CodexAppServerRequestError } from './codex-app-server-connection' import { CodexAppServerTimeoutError } from './codex-app-server-session' import { + acquireReadyCodexForTest, adapterFor, fakeCodex, identityFor, @@ -34,7 +35,7 @@ async function runningHelper(routes: Record) { const live = () => codex.connections.at(-1)! const notify = (method: string, params: unknown): void => live().handlers.onNotification?.(method, params) - await adapter.acquire({ identity, fence: 7, spawnToken: 'spawn-1' }) + await acquireReadyCodexForTest(adapter, { identity, fence: 7, spawnToken: 'spawn-1' }) notify('turn/started', { threadId: THREAD_ID, turn: { id: 'turn-1', status: 'inProgress' } }) notify('item/started', { threadId: THREAD_ID, From aad51e82ed3c6504fbcaaf8c5b94358c1cafce5a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:40:05 -0700 Subject: [PATCH 24/55] Keep ACP startup ownership until handshake completes --- .../acp/acp-structured-child-lifecycle.ts | 3 +-- src/main/acp/acp-structured-child.ts | 13 +++------- src/main/acp/acp-structured-handshake.ts | 26 +++++++++---------- .../acp-structured-publish-at-spawn.test.ts | 2 +- .../acp-structured-session-adapter.test.ts | 4 +-- .../acp/acp-structured-session-adapter.ts | 4 ++- 6 files changed, 23 insertions(+), 29 deletions(-) diff --git a/src/main/acp/acp-structured-child-lifecycle.ts b/src/main/acp/acp-structured-child-lifecycle.ts index 3a7decf5cbde..ea2a95243f20 100644 --- a/src/main/acp/acp-structured-child-lifecycle.ts +++ b/src/main/acp/acp-structured-child-lifecycle.ts @@ -101,8 +101,7 @@ export class AcpStructuredChildren { { detail: providerDiagnostic(session.connection.stderrTail(), 'person') } - ), - ...(!session.closeRequested ? { startupUnproven: true } : {}) + ) }) } if (this.sessions.get(session.sessionId) === session) { diff --git a/src/main/acp/acp-structured-child.ts b/src/main/acp/acp-structured-child.ts index 9f681ec1bd4c..4602b1738f9b 100644 --- a/src/main/acp/acp-structured-child.ts +++ b/src/main/acp/acp-structured-child.ts @@ -1,8 +1,4 @@ -import { - agentSessionFailureFact, - providerDiagnostic, - type SubmissionRejectionFact -} from '../../shared/agent-session-failure' +import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import { AcpAgentError } from './acp-errors' import { acpAuthenticationRequired, acpSignInRequiredFailure } from './acp-turn-failures' @@ -41,15 +37,12 @@ export function isAcpReadySession( export function acpStartupFailure( session: AcpStructuredChild, error: unknown -): SubmissionRejectionFact { +): SubmissionRejectionFact | undefined { if (error instanceof AcpAgentError && acpAuthenticationRequired(session.spec.dialect, error)) { // The agent's sign-in words, never log lines it wrote before refusing. return acpSignInRequiredFailure(session.spec.dialect, error) } - const text = - session.connection.stderrTail() || (error instanceof AcpAgentError ? error.message : '') - const detail = providerDiagnostic(text, 'person') - return agentSessionFailureFact('providerStartFailed', detail ? { detail } : {}) + return undefined } export function assertAcpStartingSession( diff --git a/src/main/acp/acp-structured-handshake.ts b/src/main/acp/acp-structured-handshake.ts index 2679ba2f68fc..823a3eaa4cec 100644 --- a/src/main/acp/acp-structured-handshake.ts +++ b/src/main/acp/acp-structured-handshake.ts @@ -114,19 +114,6 @@ export async function initializeAcpStructuredSession( observedAt: now(), ...takeover } - Object.assign(child, { - childStops, - lane: liveLane, - turns: new AcpStructuredTurns({ - connection, - withdrawRequests: () => prompts.withdrawAll(), - lane: liveLane, - agentName, - now, - settle: onSettled - }), - restoreSkipped - }) const reading = liveLane const unbind = acquire.events?.bindReadingControl?.({ pauseReading: () => connection.pauseReading(), @@ -150,6 +137,19 @@ export async function initializeAcpStructuredSession( liveLane.apply(acpSessionNotRestoredRow(key, started.sessionId, agentName)) } assertStarting() + Object.assign(child, { + childStops, + lane: liveLane, + turns: new AcpStructuredTurns({ + connection, + withdrawRequests: () => prompts.withdrawAll(), + lane: liveLane, + agentName, + now, + settle: onSettled + }), + restoreSkipped + }) if (!isAcpReadySession(child)) { throw new Error('ACP session did not open') } diff --git a/src/main/acp/acp-structured-publish-at-spawn.test.ts b/src/main/acp/acp-structured-publish-at-spawn.test.ts index 2205b4dfe3fd..f0cb2b5cfd62 100644 --- a/src/main/acp/acp-structured-publish-at-spawn.test.ts +++ b/src/main/acp/acp-structured-publish-at-spawn.test.ts @@ -133,7 +133,7 @@ describe('ACP child published before its handshake', () => { rig.child().exit() expect(await opening).toBeInstanceOf(Error) expect(rig.ended).toMatchObject([ - { startupUnproven: true, failure: { detail: { text: 'provider config is invalid' } } } + { failure: { detail: { text: 'provider config is invalid' } } } ]) expect(rig.ended[0]?.startupUnanswered).toBeUndefined() } diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 472eee34444c..716c6e65f5d2 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -136,7 +136,7 @@ it('reports an agent that exits while starting with its own last words', async ( }) await rig.acquire() expect(rig.ended).toMatchObject([ - { startupUnproven: true, failure: { detail: { text: 'grok: config.toml is invalid' } } } + { failure: { detail: { text: 'grok: config.toml is invalid' } } } ]) }) @@ -153,7 +153,7 @@ it('reads those last words when the agent ends its stdout before its exit is see }) await rig.acquire() expect(rig.ended).toMatchObject([ - { startupUnproven: true, failure: { detail: { text: 'grok: config.toml is invalid' } } } + { failure: { detail: { text: 'grok: config.toml is invalid' } } } ]) }) diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 0b61db21d14a..0540c6e4f940 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -151,7 +151,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return await child.open(input.signal) } catch (error) { const failure = acpStartupFailure(child, error) - if (failure.kind === 'notSignedIn') { + if (failure) { const refusal = new AgentSessionAcquisitionRefusal( `${acpAgentName(child.spec.agent)} requires sign-in`, 'notSignedIn' @@ -215,6 +215,8 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return { cancelled: true } } + // Stop is a session boundary: a cancel ends only the running turn, and background work could + // begin another turn. The next send reloads the session. stopEndsSession = (): boolean => true awaitStoppedRequestEnd = async (sessionId: string, stoppedAt: number): Promise => { From 6f0fedd1789a2d0bd838beb264e992b07babcc85 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:15:06 -0700 Subject: [PATCH 25/55] Run ACP opening catalog fixtures with Node SQLite --- config/scripts/vitest-sqlite-runtime-files.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index b80159973598..c60e05c0de37 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -2,6 +2,8 @@ import { SQLITE_DATABASE_RUNTIME_INCLUDE } from './vitest-sqlite-database-runtim // Real SQLite fixtures keep Node publication and close semantics. export const SQLITE_RUNTIME_INCLUDE = [ + 'src/main/acp/acp-structured-publish-at-spawn.test.ts', + 'src/main/acp/acp-structured-session-adapter.test.ts', ...SQLITE_DATABASE_RUNTIME_INCLUDE, 'src/main/acp/acp-structured-host-live-listing.test.ts', 'src/main/acp/acp-structured-host-configured-default.test.ts', From b1ca041b719bb4af07a86ab92befcc43946fd732 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:16:40 -0700 Subject: [PATCH 26/55] Verify saved ACP picks without live option writes --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 - .../acp-structured-host-live-listing.test.ts | 15 ++-- .../acp-structured-host-option-hang.test.ts | 74 +++++-------------- 3 files changed, 29 insertions(+), 61 deletions(-) diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index c60e05c0de37..495c6f61c708 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -3,7 +3,6 @@ import { SQLITE_DATABASE_RUNTIME_INCLUDE } from './vitest-sqlite-database-runtim // Real SQLite fixtures keep Node publication and close semantics. export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/acp/acp-structured-publish-at-spawn.test.ts', - 'src/main/acp/acp-structured-session-adapter.test.ts', ...SQLITE_DATABASE_RUNTIME_INCLUDE, 'src/main/acp/acp-structured-host-live-listing.test.ts', 'src/main/acp/acp-structured-host-configured-default.test.ts', diff --git a/src/main/acp/acp-structured-host-live-listing.test.ts b/src/main/acp/acp-structured-host-live-listing.test.ts index 57cca6e00aaf..f62aa69fa143 100644 --- a/src/main/acp/acp-structured-host-live-listing.test.ts +++ b/src/main/acp/acp-structured-host-live-listing.test.ts @@ -46,12 +46,17 @@ describe('a live Grok listing', () => { providerStarted: vi.fn() } // The rig waits for the child to turn ready: bookkeeping never fails a proven start. - const { store } = await openAttachedHostRig({}, undefined, modelCatalog) + const { host, rig, store, fence } = await openAttachedHostRig({}, undefined, modelCatalog) expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(1) expect(store.getRecord(SESSION)?.lease.ownerProcess).not.toBeNull() - // The rest of the start still lands: what it reported is saved. - await vi.waitFor(() => - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'grok-4.7', effort: 'high' }) - ) + // The rest of the start still lands, and live options remain readable. + await expect(host.readOptions(SESSION)).resolves.toMatchObject({ current: expect.any(Object) }) + expect( + (await rig.adapter.readOptions({ sessionId: SESSION, fence: fence() })).current + ).toMatchObject({ + model: 'grok-4.7', + effort: 'high' + }) + expect(store.getRecord(SESSION)?.options).toBeUndefined() }) }) diff --git a/src/main/acp/acp-structured-host-option-hang.test.ts b/src/main/acp/acp-structured-host-option-hang.test.ts index 5fe02884e7f3..889aa34009f1 100644 --- a/src/main/acp/acp-structured-host-option-hang.test.ts +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -1,5 +1,4 @@ -// A model or effort pick Grok never answers, through the real host: it must not hold the chat's -// Stop or Close behind it, and on its own it fails at a bound instead of holding the chat forever. +// Saving a choice never waits for the live agent. import { afterEach, describe, expect, it } from 'vitest' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' @@ -8,7 +7,6 @@ import { envelope } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { GROK_CONFIG_OPTIONS, waitFor } from './acp-structured-adapter.test-support' import { framesOf, openAttachedHostRig, stop } from './acp-structured-host.test-support' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' @@ -40,56 +38,22 @@ async function within(promise: Promise, ms: number) { } } -const failed = (outcome: Awaited>) => - outcome !== 'pending' && - (outcome.settled === 'rejected' || - (typeof outcome.value === 'object' && outcome.value !== null && 'ok' in outcome.value - ? outcome.value.ok === false - : false)) - -describe('a Grok model pick Grok never answers', () => { - it('does not hold a Stop behind it; the pick fails', async () => { - const { rig, host } = await openAttachedHostRig({ - script: (agent) => agent.on('session/set_config_option', () => {}) - }) - const picking = pick(host, 'grok-4.6') - await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) - expect(await within(stop(host), 2_000)).toMatchObject({ settled: 'resolved' }) - expect(failed(await within(picking, 1_000))).toBe(true) - expect(await within(host.close(SESSION, 'user-close'), 2_000)).not.toBe('pending') - }) - - it('does not hold a Close behind it; the pick fails', async () => { - const { rig, host } = await openAttachedHostRig({ - script: (agent) => agent.on('session/set_config_option', () => {}) - }) - const picking = pick(host, 'grok-4.6') - await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) - expect(await within(host.close(SESSION, 'user-close'), 2_000)).toMatchObject({ - settled: 'resolved' - }) - expect(failed(await within(picking, 1_000))).toBe(true) - }) - - it('fails at its bound, frees the chat, and keeps what Grok answers late', async () => { - const { rig, host } = await openAttachedHostRig({ - optionWriteTimeoutMs: 100, - script: (agent) => agent.on('session/set_config_option', () => {}) - }) - const picking = pick(host, 'grok-4.6') - await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) - expect(failed(await within(picking, 2_000))).toBe(true) - // The chat's queue moved on: a later operation runs. - expect(await within(host.readOptions(SESSION), 1_000)).not.toBe('pending') - const [frame] = framesOf(rig.child(), 'session/set_config_option') - rig.child().agent.reply(frame!, { - configOptions: GROK_CONFIG_OPTIONS.map((option) => - option.id === 'model' ? { ...option, currentValue: 'grok-4.6' } : option - ) - }) - await waitFor(async () => - expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' }) - ) - await host.close(SESSION, 'user-close') - }) +describe('a saved Grok pick', () => { + it.each(['Stop', 'Close'] as const)( + 'never contacts Grok or holds %s behind a setter', + async (ending) => { + const { rig, host, store } = await openAttachedHostRig({ + script: (agent) => agent.on('session/set_config_option', () => {}) + }) + expect(await within(pick(host, 'grok-4.6'), 1_000)).toMatchObject({ + settled: 'resolved', + value: { ok: true } + }) + expect(store.getRecord(SESSION)?.options?.model).toBe('grok-4.6') + expect(framesOf(rig.child(), 'session/set_config_option')).toEqual([]) + const ended = ending === 'Stop' ? stop(host) : host.close(SESSION, 'user-close') + expect(await within(ended, 2_000)).toMatchObject({ settled: 'resolved' }) + await host.close(SESSION, 'user-close') + } + ) }) From e68ab05b9b508f1bbda82ba91f52cad0530a416b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:18:14 -0700 Subject: [PATCH 27/55] Keep ACP catalog bookkeeping from failing an options read --- .../structured-agent-session-options-read.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 765203b69564..c11bdb216883 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -1,3 +1,4 @@ +import { saveStructuredAgentSessionStartListing } from './structured-agent-session-start-listing' // A chat's options, live or at rest. // // At rest nothing here needs a child. The pick is the record's own `options` — what the next start replays — @@ -218,7 +219,7 @@ export async function readStructuredAgentSessionOptions( const { catalogListing, ...answer } = live if (catalogListing) { // What a running child listed is its account's catalog too, so the next chat opens warm. - context.deps.modelCatalog?.recordLiveListing(sessionId, catalogListing) + saveStructuredAgentSessionStartListing(context, sessionId, catalogListing) } options = answer } else { From ed35c0285e2b5d0f14a8014cf2602b2ec703faab Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:59:36 -0700 Subject: [PATCH 28/55] Keep live choices and delivery order while agents start --- ...claude-structured-dispatch-test-support.ts | 1 - .../claude-structured-event-delivery.ts | 7 - .../claude/claude-structured-options.test.ts | 1 - .../claude-structured-session-acquisition.ts | 1 - .../claude-structured-session-adapter.test.ts | 7 +- .../claude/claude-structured-session-open.ts | 4 +- .../claude-structured-session-startup.test.ts | 3 +- .../claude-structured-session-startup.ts | 30 +--- .../claude/claude-structured-session-state.ts | 7 +- .../claude-structured-start-fast-mode.ts | 8 +- .../journal-context-rewind.ts | 13 +- .../journal-epoch-controller.ts | 3 + .../journal-epoch-replacement.ts | 39 ++++- .../journal-row-builders.ts | 4 +- .../journal-store-collaborators.ts | 1 + .../journal-store.test.ts | 6 + ...-agent-session-acquisition-options.test.ts | 12 -- ...-session-acquisition-runtime-stamp.test.ts | 1 - .../structured-agent-session-acquisition.ts | 3 +- .../structured-agent-session-adapter.ts | 15 -- ...tured-agent-session-adopted-import.test.ts | 1 - .../structured-agent-session-attach-flow.ts | 2 - ...ured-agent-session-attach-orchestration.ts | 1 - ...-agent-session-claude-option-queue.test.ts | 33 +++- ...red-agent-session-conversation-lifetime.ts | 3 - .../structured-agent-session-create.test.ts | 3 +- .../structured-agent-session-delivery-loop.ts | 34 ++-- ...structured-agent-session-delivery-order.ts | 77 ++++++++++ ...ructured-agent-session-drivability.test.ts | 1 - ...structured-agent-session-event-recovery.ts | 4 - ...d-agent-session-held-start.test-support.ts | 6 +- .../structured-agent-session-host-delivery.ts | 4 + ...structured-agent-session-host-mutations.ts | 29 +++- ...ctured-agent-session-host-runtime-state.ts | 4 +- .../structured-agent-session-host.test.ts | 20 +-- .../structured-agent-session-host.ts | 6 +- ...gent-session-instant-start.test-support.ts | 7 +- ...ructured-agent-session-mutation-context.ts | 6 +- ...ructured-agent-session-option-revisions.ts | 46 ------ ...ed-agent-session-option-settlement.test.ts | 31 +++- ...ent-session-pre-spawn-first-answer.test.ts | 1 - ...nt-session-processless-reservation.test.ts | 5 - ...gent-session-provider-child-record.test.ts | 3 +- ...ed-agent-session-provider-open-contract.ts | 1 - ...ctured-agent-session-provider-open.test.ts | 145 +++++++++++++----- ...t-session-provider-started-catalog.test.ts | 3 +- ...ructured-agent-session-provider-started.ts | 9 -- ...ent-session-queued-action-receipts.test.ts | 8 +- ...session-queued-message-rig.test-fixture.ts | 3 +- ...tructured-agent-session-ready-operation.ts | 55 ++++--- ...ctured-agent-session-refusal-retry.test.ts | 54 ++++++- ...uctured-agent-session-repeat-press.test.ts | 4 +- .../structured-agent-session-rewind.ts | 24 ++- ...session-send-restarts-failed-start.test.ts | 3 +- ...uctured-agent-session-start-failure-row.ts | 21 +++ ...tructured-agent-session-start-readiness.ts | 26 ---- ...-agent-session-startup-attempt-contract.ts | 2 - ...agent-session-startup-attempt-host.test.ts | 12 +- ...sion-startup-attempt-registrations.test.ts | 3 +- ...ured-agent-session-startup-attempt.test.ts | 3 +- ...tructured-agent-session-startup-attempt.ts | 12 +- .../structured-conversation-command.test.ts | 3 +- .../structured-rewind-recovery.ts | 19 ++- .../structured-rewind-replay.ts | 21 +++ ...ude-structured-retired-saved-model.test.ts | 40 ++--- ...ured-agent-session-failure-logging.test.ts | 3 +- .../structured-claude-runtime-adapter.ts | 2 +- 67 files changed, 550 insertions(+), 419 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-delivery-order.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-option-revisions.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-rewind-replay.ts diff --git a/src/main/claude/claude-structured-dispatch-test-support.ts b/src/main/claude/claude-structured-dispatch-test-support.ts index afe0352e2f87..4b9718ae45fe 100644 --- a/src/main/claude/claude-structured-dispatch-test-support.ts +++ b/src/main/claude/claude-structured-dispatch-test-support.ts @@ -46,7 +46,6 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C proof: createClaudeInitProof(), account: undefined, resumesTranscript: false, - optionRevision: () => 0, onLateRefusal: () => {} } } diff --git a/src/main/claude/claude-structured-event-delivery.ts b/src/main/claude/claude-structured-event-delivery.ts index 9e543a37a658..5430eb82f8c1 100644 --- a/src/main/claude/claude-structured-event-delivery.ts +++ b/src/main/claude/claude-structured-event-delivery.ts @@ -45,13 +45,6 @@ export function emitClaudeStructuredSessionEvent({ const retired = event.type === 'message' && session ? retireClaudeLaunchedModel(session, event.message) : null if (session && retired !== null) { - deps.onEvent?.({ - type: 'options-skipped', - sessionId: event.sessionId, - fence: session.fence, - acquisitionGeneration: session.acquisitionGeneration, - options: { model: retired } - }) resetClaudeRetiredModel(session, deps, event.sessionId, retired) } publishChildWork(event.sessionId, session, event.type === 'message' ? event.message : null) diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index efc0ce81ad55..64ada44671d7 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -55,7 +55,6 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe proof: createClaudeInitProof(), account: undefined, resumesTranscript: false, - optionRevision: () => 0, onLateRefusal: () => {} } } diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index d65c3bcfd90e..bba3eb1a1ee3 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -242,7 +242,6 @@ export async function acquireClaudeSession({ proof: initProof, account: launch.account, resumesTranscript: launch.resumesTranscript, - optionRevision: () => input.optionRevision?.() ?? 0, onLateRefusal: (error) => callbacks.handleExit(sessionId, attempt, error) }, ...(deps.mintLinkId ? { linkId: deps.mintLinkId() } : {}), diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 8145374dd960..a563461a1937 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -202,12 +202,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { answerApply(failure) await tick() - // A refusal drops the pick, as main's refused restore did, from the record too, after - // `started`; silence keeps it wanted and unconfirmed. - const skipped = events.filter((event) => event.type === 'options-skipped') - expect(skipped).toEqual( - kept ? [] : [expect.objectContaining({ fence: 7, options: { fastMode: 'true' } })] - ) + // A refusal drops the child's value; silence keeps it wanted and unconfirmed. expect(adapter.readOptionRestoreFailures('session-1')).toEqual(kept ? [] : ['fastMode']) const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) expect(options.current.fastMode === true).toBe(kept) diff --git a/src/main/claude/claude-structured-session-open.ts b/src/main/claude/claude-structured-session-open.ts index 55e4a7fbc828..f1870a7600c5 100644 --- a/src/main/claude/claude-structured-session-open.ts +++ b/src/main/claude/claude-structured-session-open.ts @@ -27,8 +27,7 @@ export async function openClaudeStructuredSession(input: { ) { throw new Error('claude session was replaced before open') } - const { proof, account, resumesTranscript, optionRevision, onLateRefusal } = - session.initialization + const { proof, account, resumesTranscript, onLateRefusal } = session.initialization const opened = openClaudeSession({ session, facts: readClaudeStartupFacts({ @@ -46,7 +45,6 @@ export async function openClaudeStructuredSession(input: { isCurrent: () => sessions.get(request.sessionId) === session, diagnose: (diagnostic) => emit(session, { type: 'auth-diagnostic', sessionId: request.sessionId, diagnostic }), - optionRevision, onLateRefusal, report: (event) => emit(session, { diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index c3f2ffbb0700..42cc302773cd 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -33,8 +33,7 @@ describe('Claude host-awaited open', () => { await vi.advanceTimersByTimeAsync(1) await expect(open).resolves.toMatchObject({ link: expect.objectContaining({ handle: expect.objectContaining({ agent: 'claude' }) }), - reportedOptions: expect.any(Object), - optionRevision: 0 + reportedOptions: expect.any(Object) }) expect(events.map((event) => event.type)).not.toContain('started') await adapter.closeAll() diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index b8002eff615f..41ff344a52af 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -2,7 +2,6 @@ // independently; the host owns a failed open and its single close. import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' -import type { StructuredAgentSessionOptionsSkippedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import { ClaudeSlashCommandCatalog } from './claude-slash-command-catalog' @@ -38,19 +37,13 @@ import { export type ClaudeOpenFacts = Pick< StructuredAgentSessionProviderOpenResult, - | 'reportedOptions' - | 'restoreSkippedOptions' - | 'retiredOptions' - | 'optionRevision' - | 'catalogListing' + 'reportedOptions' | 'restoreSkippedOptions' | 'retiredOptions' | 'catalogListing' > /** A lifecycle event the start reports, before the session's identity is stamped on it. */ -export type ClaudeStartupReport = - | ({ - type: 'options-reported' - } & ClaudeOpenFacts) - | Pick +export type ClaudeStartupReport = { + type: 'options-reported' +} & ClaudeOpenFacts /** What the initialize answer proved: enough to start. */ export type ClaudeInitializeFacts = { @@ -199,7 +192,6 @@ export async function openClaudeSession(input: { readSettings: () => Promise isCurrent: () => boolean diagnose: (diagnostic: ClaudeAuthDiagnostic) => void - optionRevision: () => number onLateRefusal: (error: Error) => void report: (event: ClaudeStartupReport) => void }): Promise { @@ -221,8 +213,7 @@ export async function openClaudeSession(input: { session.fastModeAtStart ? session.optionMutationSequence - 1 : undefined ), restoreSkippedOptions: [...session.restoreSkippedOptions], - ...claudeRetiredOptions(session), - optionRevision: input.optionRevision() + ...claudeRetiredOptions(session) } void settleClaudeStartupSettings(input, initialized) return result @@ -234,8 +225,6 @@ async function settleClaudeStartupSettings( ): Promise { const { session } = input const sequence = session.optionMutationSequence - // Only a pick moves this stamp; a later report cannot replace a newer choice. - const optionRevision = input.optionRevision() const settings = await input.readSettings() if (!input.isCurrent()) { return @@ -268,14 +257,9 @@ async function settleClaudeStartupSettings( // The one report of what the config resolved: the readback just applied it. ...claudeResolvedCatalogListing(session, readClaudeModels(facts.initialization)), restoreSkippedOptions: [...session.restoreSkippedOptions], - ...claudeRetiredOptions(session), - optionRevision + ...claudeRetiredOptions(session) }) if (startFastMode !== null) { - void applyClaudeStartFastMode(session, facts, startFastMode, (event) => { - if (input.isCurrent()) { - input.report(event) - } - }) + void applyClaudeStartFastMode(session, facts, startFastMode) } } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index f34c5db28628..c454ad63e67a 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -3,10 +3,7 @@ import type { AgentJournalDispatchRejection } from '../../shared/agent-session-f import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { - StructuredAgentSessionOptionsReportedEvent, - StructuredAgentSessionOptionsSkippedEvent -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionOptionsReportedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection, openClaudeStreamJsonConnection @@ -64,7 +61,6 @@ export type ClaudeStructuredSessionEvent = } | { type: 'auth-diagnostic'; sessionId: string; diagnostic: ClaudeAuthDiagnostic } | StructuredAgentSessionOptionsReportedEvent - | StructuredAgentSessionOptionsSkippedEvent | { type: 'ended' sessionId: string @@ -206,7 +202,6 @@ export type ClaudeSession = { proof: ClaudeInitProof account: ClaudeStructuredLaunch['account'] resumesTranscript: boolean - optionRevision: () => number onLateRefusal: (error: Error) => void } } diff --git a/src/main/claude/claude-structured-start-fast-mode.ts b/src/main/claude/claude-structured-start-fast-mode.ts index 66cb2df04cb9..4650cf897ada 100644 --- a/src/main/claude/claude-structured-start-fast-mode.ts +++ b/src/main/claude/claude-structured-start-fast-mode.ts @@ -5,7 +5,7 @@ import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' import { readClaudeModels } from './claude-structured-init-proof' import { listedModels } from './claude-structured-model-catalog' import { claudeModelFastModeSupport } from './claude-structured-session-options' -import type { ClaudeStartupFacts, ClaudeStartupReport } from './claude-structured-session-startup' +import type { ClaudeStartupFacts } from './claude-structured-session-startup' import type { ClaudeSession } from './claude-structured-session-state' /** A saved Fast the launch left out (`fastModeAtStart`): on for a new conversation, whose settings @@ -44,13 +44,12 @@ export function admitClaudeStartFastMode( } /** Applies a saved Fast the launch left out after open, so nothing waits on it. A refusal - * drops it as main's refused restore did, from the record too; silence keeps it wanted and + * drops the child's value; silence keeps it wanted and * unconfirmed. A write the user made meanwhile owns the option. */ export async function applyClaudeStartFastMode( session: ClaudeSession, facts: ClaudeStartupFacts, - fastMode: string, - report: (event: ClaudeStartupReport) => void + fastMode: string ): Promise { const sequence = session.optionMutationSequence try { @@ -66,7 +65,6 @@ export async function applyClaudeStartFastMode( if (error instanceof ClaudeControlRequestError) { session.options.delete('fastMode') session.restoreSkippedOptions.add('fastMode') - report({ type: 'options-skipped', options: { fastMode } }) } } } diff --git a/src/main/native-chat/agent-session-journal/journal-context-rewind.ts b/src/main/native-chat/agent-session-journal/journal-context-rewind.ts index dbaf544e3ef7..8db6d9c0c7f7 100644 --- a/src/main/native-chat/agent-session-journal/journal-context-rewind.ts +++ b/src/main/native-chat/agent-session-journal/journal-context-rewind.ts @@ -1,3 +1,4 @@ +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { agentJournalItemKey, agentJournalSubmissionKey @@ -33,14 +34,22 @@ export async function rewindJournalContext(input: { state.latestAcceptedTurnSequence, input.cards.pauses() ).cleared! + const queued = new Set( + [...state.submissions.values()] + .filter(isQueuedAgentJournalSubmission) + .map((entry) => agentJournalSubmissionKey(entry.clientMessageId)) + ) const removed = new Map() for (const item of state.items.values()) { - if (item.sequence > input.floor.sequence) { + if (item.sequence > input.floor.sequence && !queued.has(item.itemId)) { removed.set(item.itemId, false) } } for (const submission of state.submissions.values()) { - if ((submission.acceptedSequence ?? 0) > input.floor.sequence) { + if ( + (submission.acceptedSequence ?? 0) > input.floor.sequence && + !isQueuedAgentJournalSubmission(submission) + ) { removed.set(agentJournalSubmissionKey(submission.clientMessageId), true) } } diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts index 3a79762ee2dd..2648515c2b7a 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts @@ -2,6 +2,7 @@ import type { AgentJournalCursor, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { JournalReducerState } from './journal-reducer' import type { JournalHostDatabase } from './journal-host-database' import { replaceJournalEpoch, type JournalReplacementItem } from './journal-epoch-replacement' import type { JournalQueuePauseRestatement } from './queued-message-pause' @@ -21,6 +22,7 @@ export class JournalEpochController { database: () => JournalHostDatabase readOnly: () => boolean highestFence: () => number + state: () => JournalReducerState /** What of the live epoch's Stop and Resume a replacement restates. */ queuePauseRestatement: () => JournalQueuePauseRestatement cursor: () => AgentJournalCursor @@ -69,6 +71,7 @@ export class JournalEpochController { reason, fence, items, + previous: this.deps.state(), queuePause: this.deps.queuePauseRestatement(), now: this.deps.now, mintEpoch: this.deps.mintEpoch, diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index 67927b401588..10f87ca8c16c 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -3,6 +3,9 @@ // One transaction: discard the old epoch's rows, insert the epoch row plus the // replacement items, and move the session projection. +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' + import type { AgentJournalItemBody, AgentJournalItemIdentity, @@ -13,8 +16,16 @@ import type { import { agentSessionJournalProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import type { JournalHostDatabase } from './journal-host-database' import type { JournalLoad } from './journal-open' -import { applyJournalRow, createJournalReducerState } from './journal-reducer' -import { buildJournalItemRow, journalRowBase } from './journal-row-builders' +import { + applyJournalRow, + createJournalReducerState, + type JournalReducerState +} from './journal-reducer' +import { + buildJournalItemRow, + buildJournalSubmissionRow, + journalRowBase +} from './journal-row-builders' import { buildJournalQueueReopenRow, buildJournalQueueClearRow, @@ -46,6 +57,7 @@ export function replaceJournalEpoch(input: { reason: AgentJournalEpochReason fence: number items: readonly JournalReplacementItem[] + previous?: JournalReducerState /** Restated in the new epoch, or the rewind would release cards the person stopped, or bring * back a /clear pause they already lifted. */ queuePause: JournalQueuePauseRestatement @@ -79,6 +91,29 @@ export function replaceJournalEpoch(input: { applyJournalRow(state, row) rows.push(row) } + // An accepted message that never reached the agent survives a provider history replacement. + const queued = [...(input.previous?.submissions.values() ?? [])] + .filter(isQueuedAgentJournalSubmission) + .sort((a, b) => (a.acceptedSequence ?? 0) - (b.acceptedSequence ?? 0)) + for (const submission of queued) { + const body = input.previous?.items.get( + agentJournalSubmissionKey(submission.clientMessageId) + )?.body + if (body?.kind !== 'message') { + throw new Error('journal_queued_submission_body_missing') + } + const row = buildJournalSubmissionRow({ + ...submission, + state, + body, + providerHandle: agentSessionJournalProviderHandle(input.identity), + seq: state.lastSequence + 1, + fence: input.fence, + ts: submission.submittedAt + }) + applyJournalRow(state, row) + rows.push(row) + } const { lifted, liveStop, reopened } = input.queuePause const place = () => ({ state, seq: state.lastSequence + 1, fence: input.fence, ts: input.now() }) if (lifted) { diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index a6e3aa03d189..c5306bb98696 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -1,4 +1,5 @@ import type { + AgentJournalSubmission, AgentJournalItemBody, AgentJournalItemIdentity, AgentJournalMessageItem, @@ -8,7 +9,6 @@ import type { AgentSessionJournalIdentity, AgentSessionJournalProviderHandle } from '../../../shared/agent-session-journal-types' -import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import { agentSessionJournalProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' @@ -282,7 +282,7 @@ export function buildJournalSubmissionRow(input: { handoverRecorded?: true queuedMessageId?: string origin?: 'client' | 'host' - source?: Pick + source?: AgentJournalSubmission['source'] }): JournalSubmissionRow { return { kind: 'submission', diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 7ef45140b5af..dfcb96a77e7a 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -71,6 +71,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal database: host.database, readOnly: host.readOnly, highestFence: () => host.state().highestFence, + state: host.state, queuePauseRestatement: () => journalQueuePauseRestatement( host.state().queuePauseMarks, diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts index 5c5637de2dfa..4b25d0bac070 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -495,6 +495,12 @@ describe('what a handle found on disk when it opened', () => { const leftover = journal.submissions().find((entry) => entry.clientMessageId === 'earlier') expect(journal.wroteBeforeOpen(leftover?.acceptedSequence)).toBe(true) + await journal.resolveDispatch({ + clientMessageId: 'earlier', + state: 'unknown', + reason: 'unconfirmed from an earlier host', + fence: 1 + }) // Sequences restart with an epoch, so a row accepted after it can sit below the open cursor. await journal.replaceEpochItems('handle_forked', 1, []) await journal.appendSubmission(submission('later')) 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..ffb50f0ef99b 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 @@ -166,7 +166,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null), now: () => NOW, onAttached: (attached) => { @@ -206,7 +205,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(RESUME_OPERATION, releasedFence), now: () => NOW + 1, onAttached: () => {} @@ -240,7 +238,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, options), now: () => NOW, recordPhase, @@ -272,7 +269,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, options), now: () => NOW, onAttached: () => {} @@ -305,7 +301,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null), now: () => NOW, onAttached: () => {} @@ -344,7 +339,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(RESUME_OPERATION, releasedFence), now: () => NOW + 1, onAttached: () => {} @@ -383,7 +377,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, { model: 'gpt-standard', fastMode: 'true' @@ -420,7 +413,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, { model: 'gpt-saved', effort: 'medium' }), now: () => NOW, onAttached: () => {} @@ -457,7 +449,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, options), now: () => NOW, onAttached: () => {} @@ -494,7 +485,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null), now: () => NOW, onAttached: () => {} @@ -590,7 +580,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(operationId, fence), now: () => NOW, onAttached: () => {} @@ -691,7 +680,6 @@ describe('the tab a create reserves', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, // Beside the fingerprinted fields, like `options`: which tab shows the chat is not which // conversation this attaches to. params: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts index d47f8650abca..90c94306db1d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts @@ -114,7 +114,6 @@ it('starts a chat whose recorded owner carries the runtime stamp', async () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} 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 71e85ff801f9..01dc212ccdf4 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 @@ -76,8 +76,7 @@ export async function acquireOwner( spawnToken, ...(permissionMode ? { permissionMode } : {}), ...(input.eventSink ? { events: input.eventSink } : {}), - ...(input.acquireSignal ? { signal: input.acquireSignal } : {}), - optionRevision: input.optionRevision + ...(input.acquireSignal ? { signal: input.acquireSignal } : {}) }) const progress = input.onStartupAttempt?.(attempt) const acquired = await input.adapter.acquire({ 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 2d1f72bfa4ca..a6fbaa9d7ffd 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 @@ -226,20 +226,6 @@ export type StructuredAgentSessionStartedEvent = { retiredOptions?: Readonly> /** What the child listed at startup, saved as its account's catalog even if no view reads it. */ catalogListing?: AgentModelCatalogLiveListing - /** The attempt's `optionRevision()` as the read behind this report began: a pick the host took - * since makes it out of date. An earlier report never does, whenever the host took it. */ - optionRevision: number -} - -/** A running child showed saved options it cannot run, as a model the provider reports missing. - * The record drops them, so the next start uses the provider's own. */ -export type StructuredAgentSessionOptionsSkippedEvent = { - type: 'options-skipped' - sessionId: string - fence: number - acquisitionGeneration: string - /** Each saved value the child showed it cannot run; a record holding another value keeps it. */ - options: Readonly> } /** What a child that already proved its start reports later, such as an optional read that came @@ -253,7 +239,6 @@ export type StructuredAgentSessionLifecycleEvent = | StructuredAgentSessionEndedEvent | StructuredAgentSessionStartedEvent | 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` diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 79d5c5d44923..93bfc64bae2b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -137,7 +137,6 @@ async function attach( probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(transcriptPath), now: () => NOW, onAttached 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 36ec08717e6c..455e9affaba9 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 @@ -76,8 +76,6 @@ export type AttachFlowInput = { ) => StructuredAgentSessionStartupProgress /** A completed acquisition's start listing. */ onStartCatalogListing?: (listing: AgentModelCatalogLiveListing) => void - /** The conversation's option revision, which the child's reports are stamped with. */ - optionRevision: () => number /** The permission mode a start of this record launches with; absent or undefined for none. */ launchPermissionMode?: ( record: AgentSessionRecord 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 4d256fc92457..324f1c0acbc9 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 @@ -205,7 +205,6 @@ async function runAttachUnderAbort( attempt.startup = startup return startupAttempts.track(sessionId, startup) }, - optionRevision: () => context.runtimeState.optionRevisions.current(sessionId), launchPermissionMode: async (record) => (await structuredAgentSessionLaunchPermission(context.deps, record)) ?? undefined, ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), 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 5aea5d70c253..6b2d9acadb9b 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 @@ -82,8 +82,7 @@ function adapter(): StructuredAgentSessionAdapter { releaseOpen = () => resolve({ reportedOptions: { model: DEFAULT_MODEL }, - restoreSkippedOptions: [], - optionRevision: 0 + restoreSkippedOptions: [] }) }), acquire, @@ -163,3 +162,33 @@ describe('Claude structured session options', () => { expect(store.getRecord(SESSION)?.options).toEqual({ effort: 'high', model: PICKED_MODEL }) }) }) + +it('applies model and effort picks on a running Claude chat to the next message', async () => { + releaseOpen() + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) + for (const [key, value] of [ + ['model', PICKED_MODEL], + ['effort', 'high'] + ]) { + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', { key, value }), + key, + value + }) + ).toMatchObject({ ok: true }) + } + const body = hostTestMessage('with the new choices') + dispatch.mockImplementationOnce(async () => { + expect(activeModel).toBe(PICKED_MODEL) + expect(setOption).toHaveBeenCalledWith( + expect.objectContaining({ key: 'effort', value: 'high' }) + ) + return { state: 'admitted' as const } + }) + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(order).toEqual(['model=opus', 'effort=high']) +}) 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 ad4e098c2b0c..424107e39c93 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 @@ -80,9 +80,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { sessionId, atRest ) - if (closed) { - host.context().runtimeState.optionRevisions.forget(sessionId) - } return closed } 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 da6bee62a150..792e73fa158c 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 @@ -216,8 +216,7 @@ it('holds the created first message until the child proves its start, then hands fence: rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, acquisitionGeneration: generation, reportedOptions: { model: 'gpt-live' }, - restoreSkippedOptions: [], - optionRevision: rig.host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + restoreSkippedOptions: [] }) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index ec67aeee67c8..c43e2198ca3d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -12,6 +12,7 @@ // earlier host process left queued is never handed over: the open, or this loop's first step, // settles it first (`journal-unsent-send-hold.ts`). +import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' @@ -30,10 +31,10 @@ import type { } from './structured-agent-session-host-types' import { oldestQueuedSubmission, + startThatFailedWhileQueued, recordStructuredAgentSessionStartFailure, refusedStructuredAgentSessionStart } from './structured-agent-session-start-failure-row' -import { failedProviderChildStart } from './structured-agent-session-provider-child' import { handOverSubmission } from './structured-agent-session-turns' import { structuredAgentSessionNextHandover } from './structured-agent-session-opening-send' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' @@ -43,6 +44,7 @@ import { holdRestartedStructuredAgentSessionSends } from './structured-agent-ses import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' export type StructuredAgentSessionDeliveryLoopDeps = { + deliveryOrder: StructuredAgentSessionDeliveryOrder sessions: ReadonlyMap adapter: StructuredAgentSessionAdapter agents: StructuredAgentRegistry @@ -87,6 +89,7 @@ type Prepared = ok: true awaited: StructuredAgentSessionProviderChildIdentity | null readiness: StructuredAgentSessionStartReadiness | null + before: Promise } /** A failed start before it is worded; `fail` words it once, through the one wording point. */ @@ -106,6 +109,7 @@ export class StructuredAgentSessionDeliveryLoop { /** Quit: no step after this one starts a child or hands a message over. */ dispose(): void { this.disposed = true + this.deps.deliveryOrder.dispose() } /** From inside the session's serialize, after a message was accepted or the conversation @@ -115,6 +119,10 @@ export class StructuredAgentSessionDeliveryLoop { if (this.disposed) { return Promise.resolve() } + this.deps.deliveryOrder.reconcile( + sessionId, + this.deps.sessions.get(sessionId)?.journal.submissions() ?? [] + ) if (this.running.has(sessionId)) { return this.runs.get(sessionId) ?? Promise.resolve() } @@ -150,8 +158,8 @@ export class StructuredAgentSessionDeliveryLoop { } if (prepared.readiness) { await prepared.readiness.wait() - await prepared.readiness.waitForOperations?.() } + await prepared.before const handed = await this.deps.serialize(sessionId, () => this.handOver(sessionId, prepared.awaited, prepared.readiness !== null) ) @@ -224,7 +232,8 @@ export class StructuredAgentSessionDeliveryLoop { return { ok: true, awaited: child ? { generation: child.generation, fence: child.fence } : null, - readiness: child?.phase === 'starting' ? this.deps.readiness(sessionId, child) : null + readiness: child?.phase === 'starting' ? this.deps.readiness(sessionId, child) : null, + before: this.deps.deliveryOrder.beforeSend(sessionId, oldest.clientMessageId) } } @@ -347,22 +356,3 @@ export class StructuredAgentSessionDeliveryLoop { return 'stop' } } - -/** A start that died while this message waited on it — a view's, say — is the message's failed - * start: settled with it, under its key, rather than started again into the same failure. */ -function startThatFailedWhileQueued( - session: StructuredAgentSessionHostSession, - oldest: NonNullable> -): StartFailure | null { - const ended = failedProviderChildStart(session) - if ( - !ended || - oldest.acceptedSequence === undefined || - ended.endedAt.epoch !== session.journal.cursor().epoch || - ended.endedAt.sequence < oldest.acceptedSequence - ) { - return null - } - const cause = structuredAgentSessionEndedChildFailure(ended) - return cause ? { startKey: ended.generation, cause } : null -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-order.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-order.ts new file mode 100644 index 000000000000..d1321333c842 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-order.ts @@ -0,0 +1,77 @@ +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' + +export type StructuredAgentSessionDeliveryPlace = { + pending: boolean + before: Promise + release: () => void +} + +type Lane = { + tail: Promise + places: Set + sends: Map +} + +/** One delivery order; journal commits retire sends, bounded callers retire operations. */ +export class StructuredAgentSessionDeliveryOrder { + private readonly lanes = new Map() + + reconcile(sessionId: string, submissions: readonly AgentJournalSubmission[]): void { + const queued = submissions.filter(isQueuedAgentJournalSubmission) + const lane = this.lanes.get(sessionId) + const ids = new Set(queued.map((submission) => submission.clientMessageId)) + for (const [id, place] of lane?.sends ?? []) { + if (!ids.has(id)) { + lane?.sends.delete(id) + place.release() + } + } + for (const submission of queued.sort( + (a, b) => (a.acceptedSequence ?? 0) - (b.acceptedSequence ?? 0) + )) { + const current = this.lanes.get(sessionId) + if (!current?.sends.has(submission.clientMessageId)) { + const place = this.reserve(sessionId) + this.lanes.get(sessionId)?.sends.set(submission.clientMessageId, place) + } + } + } + + reserve(sessionId: string): StructuredAgentSessionDeliveryPlace { + const lane = this.lanes.get(sessionId) ?? { + tail: Promise.resolve(), + places: new Set(), + sends: new Map() + } + this.lanes.set(sessionId, lane) + const done = Promise.withResolvers() + const place: StructuredAgentSessionDeliveryPlace = { + pending: lane.places.size > 0, + before: lane.tail, + release: () => { + done.resolve() + lane.places.delete(place) + if (lane.places.size === 0 && this.lanes.get(sessionId) === lane) { + this.lanes.delete(sessionId) + } + } + } + lane.places.add(place) + lane.tail = Promise.all([place.before, done.promise]).then(() => undefined) + return place + } + + beforeSend(sessionId: string, messageId: string): Promise { + return this.lanes.get(sessionId)?.sends.get(messageId)?.before ?? Promise.resolve() + } + + dispose(): void { + for (const lane of this.lanes.values()) { + for (const place of lane.places) { + place.release() + } + } + this.lanes.clear() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts index 2814638e0413..bbcadedabfef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts @@ -143,7 +143,6 @@ async function attach(input: { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: input.params, now: () => NOW, onAttached: async (attached) => { 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 d0b3ca564ba6..141afd9406eb 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 @@ -12,7 +12,6 @@ import type { import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' import { settleStructuredAgentSessionOptionsReported, - settleStructuredAgentSessionOptionsSkipped, settleStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' import { @@ -139,9 +138,6 @@ export class StructuredAgentSessionEventRecovery { if (event.type === 'options-reported') { return settleStructuredAgentSessionOptionsReported(this.context, event) } - if (event.type === 'options-skipped') { - return settleStructuredAgentSessionOptionsSkipped(this.context, event) - } await settleStructuredAgentSessionChildExit(this.exitContext, event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts index 2a8dbf2f98f9..3dd46ba861d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts @@ -7,9 +7,6 @@ import type { 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 @@ -52,8 +49,7 @@ export function structuredAgentSessionHeldStarts( ? adapter.open(input) : { reportedOptions: { model: '' }, - restoreSkippedOptions: [], - optionRevision: NO_OPTIONS_REPORTED + restoreSkippedOptions: [] } } return { ...adapter, acquire, open } 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 5e987b4b126a..dd932c95676b 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 @@ -12,6 +12,7 @@ import { type StructuredAgentSessionConversationOpenOptions } from './structured-agent-session-conversation-open' import type { StructuredAgentSessionClientDelivery } from './structured-agent-session-client-delivery' +import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' import { StructuredAgentSessionDeliveryLoop } from './structured-agent-session-delivery-loop' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' @@ -60,6 +61,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessionId: string, startedFor: string ) => Promise + deliveryOrder: StructuredAgentSessionDeliveryOrder readiness: ( sessionId: string, child: StructuredAgentSessionProviderChildIdentity @@ -103,6 +105,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { return input.ensureProviderChild(sessionId, startedFor) }, readiness: input.readiness, + deliveryOrder: input.deliveryOrder, conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), holdClosed: async (sessionId, which) => { @@ -139,6 +142,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { } const wakesQueued = new Set() const afterCommit = (sessionId: string, journal: AgentSessionJournal): void => { + input.deliveryOrder.reconcile(sessionId, journal.submissions()) if ( wakesQueued.has(sessionId) || structuredAgentSessionCommandRunning(journal) || 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 db11e67fb6a4..9e8deac8bdb2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -7,6 +7,8 @@ // admitted without the writer lease; the delivery loop starts the provider child a send needs, and // an operation only the provider can perform starts it before admission. +import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../shared/agent-chat-permission-mode' +import { performSetOption } from './structured-agent-session-turns-options' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult, @@ -184,16 +186,37 @@ export async function setStructuredAgentSessionOption( params: { envelope: AgentSessionMutationEnvelope; key: string; value: string } ): Promise> { const plan = setOptionPlan(params) + const recordsIntent = () => { + const child = context.sessions.get(params.envelope.sessionId)?.child + return ( + params.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID || + !child || + child.phase === 'starting' || + child.close !== undefined + ) + } return mutateStructuredAgentSession( context, caller, params.envelope, { ...plan, - conversationWrite: true, - run: (ctx) => recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) + get conversationWrite() { + return recordsIntent() ? (true as const) : undefined + }, + run: async (ctx) => { + if (recordsIntent()) { + return recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) + } + const wait = context.acquireAborts.begin(params.envelope.sessionId) + try { + return await performSetOption(ctx, params, wait.signal) + } finally { + wait.end() + } + } }, - openForWrite(context, params.envelope) + () => (recordsIntent() ? openForWrite : openForProviderWrite)(context, params.envelope)() ) } 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 cbae46441bfe..04a1ade9f101 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 @@ -1,3 +1,4 @@ +import { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { @@ -12,7 +13,6 @@ import { heldProviderChildReader, type ProviderChildSessions } from './structured-agent-session-provider-child' -import { StructuredAgentSessionOptionRevisions } from './structured-agent-session-option-revisions' import { resolveStructuredSessionRecovery } from './structured-agent-session-recovery-resolution' import { recordAgentSessionStartup } from '../../observability/agent-session-instrumentation' import { @@ -23,9 +23,9 @@ import { export class StructuredAgentSessionHostRuntimeState { private readonly eventSinks = new Map() + readonly deliveryOrder = new StructuredAgentSessionDeliveryOrder() readonly acquireAborts = new StructuredAgentSessionAcquireAborts() readonly startupAttempts: StructuredAgentSessionStartupAttempts - readonly optionRevisions = new StructuredAgentSessionOptionRevisions() private readonly leaseRenewer: StructuredAgentSessionLeaseRenewer private readonly onEventSinkFailure?: (sessionId: string, error: unknown) => void diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 30f955bd0cca..155e6cbb1d00 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 @@ -517,7 +517,7 @@ describe('respondToPrompt', () => { }) describe('setOption', () => { - it('saves a next-message pick without changing the provider', async () => { + it('goes to the provider and writes nothing to the journal', async () => { await attach() setOption.mockResolvedValueOnce({ model: 'gpt-5', effort: 'high' }) const fields = { key: 'model', value: 'gpt-5' } @@ -528,20 +528,20 @@ describe('setOption', () => { const result = await host.setOption(CALLER, params) expect(result).toMatchObject({ ok: true, - value: { ...fields, options: { model: 'gpt-5' } } + value: { ...fields, options: { model: 'gpt-5', effort: 'high' } } }) expect(await host.setOption(CALLER, params)).toMatchObject({ ok: true, replayed: true, - value: { ...fields, options: { model: 'gpt-5' } } + value: { ...fields, options: { model: 'gpt-5', effort: 'high' } } }) - expect(setOption).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5' }) + expect(setOption).toHaveBeenCalledTimes(1) + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5', effort: 'high' }) const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.items).toHaveLength(0) }) - it('replays the accepted pick without contacting the provider', async () => { + it('does not turn an unknown provider outcome into a successful replay', async () => { await attach() setOption.mockRejectedValueOnce(new Error('reply lost')) const fields = { key: 'model', value: 'gpt-5' } @@ -550,12 +550,12 @@ describe('setOption', () => { ...fields } - expect(await host.setOption(CALLER, params)).toMatchObject({ ok: true }) + await expect(host.setOption(CALLER, params)).rejects.toThrow('reply lost') expect(await host.setOption(CALLER, params)).toMatchObject({ - ok: true, - replayed: true + ok: false, + refusal: { code: 'agent_session_operation_unknown' } }) - expect(setOption).not.toHaveBeenCalled() + expect(setOption).toHaveBeenCalledTimes(1) }) }) 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 13678a71a95c..d04eee53e7a0 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 @@ -123,8 +123,8 @@ export class StructuredAgentSessionHost { trackStart: (start) => this.tasks.trackAttach(start), ensureProviderChild: (sessionId, startedFor) => agentStart.ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), - readiness: (sessionId, child) => - this.runtimeState.startupAttempts.readiness(sessionId, child), + deliveryOrder: this.runtimeState.deliveryOrder, + readiness: (id, child) => this.runtimeState.startupAttempts.readiness(id, child), // Put to rest like an idle agent: the stop writes nothing, and the next start is new. stopOutgrownAgent: (id) => this.lifetime.stopAgent(id, { cause: 'evict', resting: true }), clientDelivery: this.clientDelivery @@ -287,13 +287,13 @@ export class StructuredAgentSessionHost { ensureAgent: (sessionId) => agentStart.ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), startupAttempts: this.runtimeState.startupAttempts, + deliveryOrder: this.runtimeState.deliveryOrder, joinChildClose: (sessionId) => agentStart.joinClosingStructuredAgentSessionChild(this.attachContext(), sessionId), wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), stopAgent: (sessionId, ending) => this.lifetime.stopAgent(sessionId, ending), wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), acquireAborts: this.runtimeState.acquireAborts, - optionRevisions: this.runtimeState.optionRevisions, now: () => this.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts index b0463101fb35..8c112868362a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts @@ -5,10 +5,6 @@ import type { 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 } @@ -26,8 +22,7 @@ export function startsWhenPublished( adapter.open ?? (async () => ({ reportedOptions: { model: '' }, - restoreSkippedOptions: [], - optionRevision: NO_OPTIONS_REPORTED + restoreSkippedOptions: [] })) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 63afe25e4f26..69276039fc01 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -14,7 +14,7 @@ import { import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' -import type { StructuredAgentSessionOptionRevisions } from './structured-agent-session-option-revisions' +import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' import type { StructuredAgentSessionCaller, @@ -35,7 +35,8 @@ export type StructuredAgentSessionMutationContext = { openConversation: (sessionId: string) => Promise /** Gives the session a provider child; inside the caller's serialize. */ ensureAgent: (sessionId: string) => Promise - startupAttempts: Pick + startupAttempts: Pick + deliveryOrder: StructuredAgentSessionDeliveryOrder /** Joins a close a stop began on the session's child, for an operation that starts no child; * inside the caller's serialize. */ joinChildClose: (sessionId: string) => Promise @@ -52,7 +53,6 @@ export type StructuredAgentSessionMutationContext = { * outside that serialize aborts. */ acquireAborts: Pick /** Moved by every pick a running child takes, so a report it read before is never persisted. */ - optionRevisions: Pick now: () => number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-revisions.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-revisions.ts deleted file mode 100644 index 0c20a84c1f59..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-revisions.ts +++ /dev/null @@ -1,46 +0,0 @@ -// The order of what a conversation's record learns about its child's options. -// -// A pick and a child's report both write the record's options, but a report describes the child as -// of its read, which can predate a pick still in flight, a saved value the child showed it cannot -// run, or a newer report. A pick or such a value moves the revision; a report carries the revision -// it was read at and is admitted only if nothing moved it since. Reports are ordered among -// themselves by arrival, so a read's stamp never depends on when an earlier report was taken, and -// the newest admitted report is the only one persisted. - -export class StructuredAgentSessionOptionRevisions { - private readonly revisions = new Map() - private readonly newestReports = new Map() - private reports = 0 - - current(sessionId: string): number { - return this.revisions.get(sessionId) ?? 0 - } - - /** What any report read before now says is out of date. */ - advance(sessionId: string): number { - const next = this.current(sessionId) + 1 - this.revisions.set(sessionId, next) - return next - } - - /** A report read at `readAt` becomes the newest word, or null when a pick came since its read. */ - admitReport(sessionId: string, readAt: number): number | null { - if (readAt !== this.current(sessionId)) { - return null - } - const admitted = ++this.reports - this.newestReports.set(sessionId, admitted) - return admitted - } - - /** Whether the report admitted as `admitted`, read at `readAt`, is still the newest word. */ - isNewest(sessionId: string, readAt: number, admitted: number): boolean { - return readAt === this.current(sessionId) && this.newestReports.get(sessionId) === admitted - } - - /** The conversation closed with no child: nothing it reported is still to be persisted. */ - forget(sessionId: string): void { - this.revisions.delete(sessionId) - this.newestReports.delete(sessionId) - } -} 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 67f12af9733e..a29ece6f449f 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 @@ -65,10 +65,7 @@ function adapter(): StructuredAgentSessionAdapter { fence, acquisitionGeneration, reportedOptions: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) }, - restoreSkippedOptions: [], - optionRevision: host - .collaboratorsForTests() - .runtimeState.optionRevisions.current(identity.sessionId) + restoreSkippedOptions: [] }) return { providerChildPhase: 'starting' as const, @@ -216,3 +213,29 @@ describe('structured session options and close', () => { expect(closeNativeSession).not.toHaveBeenCalled() }) }) + +it('applies running Codex model and effort picks to the next message', async () => { + await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) + for (const [key, value] of [ + ['model', 'gpt-picked'], + ['effort', 'high'] + ]) { + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', { key, value }), + key, + value + }) + ).toMatchObject({ ok: true }) + } + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'with the new choices' }] + } + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(dispatchedModels).toEqual(['gpt-picked'])) + expect(activeEffort).toBe('high') +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index c9ecb34dad48..2d82658a1bd1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -100,7 +100,6 @@ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { probe: { outcome: 'reservation-unused' as const } }, callerKey: 'client-1', - optionRevision: () => 0, params: createParams(), now: () => NOW, onAttached: () => {} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index bebf68bd2f34..543abc1e2537 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -97,7 +97,6 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} @@ -146,7 +145,6 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' as const } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} @@ -186,7 +184,6 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' as const } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} @@ -240,7 +237,6 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} @@ -310,7 +306,6 @@ describe('processless structured session reservation', () => { probe: { outcome: 'reservation-unused' as const } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(), now: () => NOW, onAttached: () => {} 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 67d9a7e71172..e3a717bd4131 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 @@ -225,8 +225,7 @@ function prove(): Promise { ...child, acquisitionGeneration, reportedOptions: { model: 'default' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + restoreSkippedOptions: [] }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts index 3e60c6ee17a1..afc7081dae3d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts @@ -16,7 +16,6 @@ export type StructuredAgentSessionProviderOpenResult = { restoreSkippedOptions: readonly string[] retiredOptions?: Readonly> catalogListing?: AgentModelCatalogLiveListing - optionRevision: number } export type StructuredAgentSessionProviderOpen = ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts index 385011d0a767..de4649ddd201 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -19,8 +19,7 @@ import { const OPENED: StructuredAgentSessionProviderOpenResult = { reportedOptions: { model: 'gpt-test' }, - restoreSkippedOptions: [], - optionRevision: -1 + restoreSkippedOptions: [] } function awaitOpen() { @@ -189,7 +188,16 @@ it('runs a goal requested during opening before a message sent later', async () }) await host.collaboratorsForTests().serialize(SESSION, async () => {}) const later = send('later message') - pending.resolve(OPENED) + let accepted = false + void later.then(() => { + accepted = true + }) + try { + await vi.waitFor(() => expect(accepted).toBe(true)) + expect(dispatch).not.toHaveBeenCalled() + } finally { + pending.resolve(OPENED) + } expect(await goal).toMatchObject({ ok: true }) expect(await later).toMatchObject({ ok: true }) await vi.waitFor(() => expect(order).toHaveLength(2)) @@ -237,7 +245,7 @@ it.each(['stop', 'close'] as const)( } ) -it('saves picks during and after opening without writing them to the live child', async () => { +it('saves startup picks and writes running picks to the live child', async () => { const { host, store, pending, setOption } = awaitOpen() await attach() for (const model of ['model-before-ready', 'model-after-ready']) { @@ -249,7 +257,7 @@ it('saves picks during and after opening without writing them to the live child' }) ).toMatchObject({ ok: true }) expect(store.getRecord(SESSION)?.options?.model).toBe(model) - expect(setOption).not.toHaveBeenCalled() + expect(setOption).toHaveBeenCalledTimes(model === 'model-before-ready' ? 0 : 1) pending.resolve(OPENED) await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') @@ -258,51 +266,104 @@ it('saves picks during and after opening without writing them to the live child' expect(store.getRecord(SESSION)?.options?.model).toBe('model-after-ready') }) -it('runs a rewind requested during opening before a later message reaches the provider', async () => { - const { host, open, dispatch, pending, send } = awaitOpen() - await attach() - pending.resolve(OPENED) - await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) - await send('previous turn') - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - await host.flushStreamedEvents(SESSION) - const previous = (await host.journalSnapshot(SESSION)).submissions[0] - if (!previous) { - throw new Error('Missing previous turn') +it.each([false, true])( + 'delivers a later message after a startup rewind (success: %s)', + async (succeeds) => { + const { host, open, dispatch, pending, send } = awaitOpen() + await attach() + pending.resolve(OPENED) + await vi.waitFor(() => + expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') + ) + await send('previous turn') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + await host.flushStreamedEvents(SESSION) + const previous = (await host.journalSnapshot(SESSION)).submissions[0] + if (!previous) { + throw new Error('Missing previous turn') + } + await host.close(SESSION, 'evict') + const reopened = Promise.withResolvers() + open.mockImplementationOnce(() => reopened.promise) + await host.attach(CALLER, attachParams()) + const order: string[] = [] + const originalDispatch = dispatch.getMockImplementation() + dispatch.mockImplementation(async (input) => { + order.push('send') + if (!originalDispatch) { + throw new Error('Missing dispatch implementation') + } + return originalDispatch(input) + }) + host.deps.adapter.rewindSupport = () => ({ supported: true }) + host.deps.adapter.rewind = vi.fn(async () => { + order.push('rewind') + return succeeds + ? { ok: true as const, items: [] } + : { ok: false as const, reason: 'history-not-paginated' as const } + }) + const itemId = agentJournalSubmissionKey(previous.clientMessageId) + const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch + const rewind = host.rewind(CALLER, { + envelope: envelope('agentSession.rewind', { itemId, expectedEpoch }), + itemId, + expectedEpoch + }) + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + const later = send('later message') + let accepted = false + void later.then(() => { + accepted = true + }) + try { + await vi.waitFor(() => expect(accepted).toBe(true)) + expect(order).toEqual([]) + } finally { + reopened.resolve(OPENED) + } + expect(await rewind).toMatchObject( + succeeds + ? { ok: true } + : { + ok: false, + refusal: { rewindReason: 'history-not-paginated' } + } + ) + expect(await later).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(order).toHaveLength(2)) + expect(order).toEqual(['rewind', 'send']) } - await host.close(SESSION, 'evict') - const reopened = Promise.withResolvers() - open.mockImplementationOnce(() => reopened.promise) - await host.attach(CALLER, attachParams()) +) + +it('delivers two earlier sends before a later goal, then the send after that goal', async () => { + const { host, pending, dispatch, send } = awaitOpen() + await attach() const order: string[] = [] const originalDispatch = dispatch.getMockImplementation() + if (!originalDispatch) { + throw new Error('Missing dispatch implementation') + } dispatch.mockImplementation(async (input) => { - order.push('send') - if (!originalDispatch) { - throw new Error('Missing dispatch implementation') - } + const { body } = input + order.push(body.blocks[0]?.type === 'text' ? body.blocks[0].text : 'send') return originalDispatch(input) }) - host.deps.adapter.rewindSupport = () => ({ supported: true }) - host.deps.adapter.rewind = vi.fn(async () => { - order.push('rewind') - return { ok: false as const, reason: 'history-not-paginated' as const } + host.deps.adapter.changeThreadGoal = vi.fn(async () => { + order.push('goal') + return { ok: true as const } }) - const itemId = agentJournalSubmissionKey(previous.clientMessageId) - const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch - const rewind = host.rewind(CALLER, { - envelope: envelope('agentSession.rewind', { itemId, expectedEpoch }), - itemId, - expectedEpoch + await send('first') + await send('second') + const change = { kind: 'set' as const, objective: 'After the first two messages' } + const goal = host.changeThreadGoal(CALLER, { + envelope: envelope('agentSession.threadGoal', { change }), + change }) await host.collaboratorsForTests().serialize(SESSION, async () => {}) - const later = send('later message') - reopened.resolve(OPENED) - expect(await rewind).toMatchObject({ - ok: false, - refusal: { rewindReason: 'history-not-paginated' } - }) + const later = send('third') + pending.resolve(OPENED) + expect(await goal).toMatchObject({ ok: true }) expect(await later).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(order).toHaveLength(2)) - expect(order).toEqual(['rewind', 'send']) + await vi.waitFor(() => expect(order).toHaveLength(4)) + expect(order).toEqual(['first', 'second', 'goal', 'third']) }) 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 883b63dfaf7d..1c593dbc742f 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 @@ -88,8 +88,7 @@ function proveStart(): Promise { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, acquisitionGeneration: 'generation-1', reportedOptions: { model: 'sonnet' }, - restoreSkippedOptions: [], - optionRevision: 0 + restoreSkippedOptions: [] }) } 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 22aae02db81e..8c44ce53ecb0 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 @@ -2,7 +2,6 @@ import type { StructuredAgentSessionOptionsReportedEvent, - StructuredAgentSessionOptionsSkippedEvent, StructuredAgentSessionStartedEvent } from './structured-agent-session-adapter' import type { @@ -181,11 +180,3 @@ export async function settleStructuredAgentSessionOptionsReported( } return undefined } - -/** A rejected provider value is live protocol evidence; it does not change a later pick. */ -export function settleStructuredAgentSessionOptionsSkipped( - _context: StructuredAgentSessionProviderStartedContext, - _event: StructuredAgentSessionOptionsSkippedEvent -): Promise { - return Promise.resolve() -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts index 06d3bd164bc6..a5526f06102d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts @@ -66,17 +66,13 @@ function writeUnreadableReceipt(id: string, method: string): void { .run(id, SESSION, CALLER.callerKey, method) } -/** What a rewind does to the conversation: a new epoch rebuilt with none of the old rows. */ +/** Simulates an older host retiring the whole epoch, including its unsent submissions. */ async function rewindConversation(): Promise { const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal if (!journal) { throw new Error('expected an open conversation') } - await journal.replaceEpochItems( - 'handle_forked', - rig.store.getRecord(SESSION)!.lease.runtimeFence, - [] - ) + await journal.rollEpoch('handle_forked', rig.store.getRecord(SESSION)!.lease.runtimeFence) } /** A stop that leaves a waiting card paused, which only a Resume lifts. */ 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 41fc7807bcc4..3e34fcfcfe98 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 @@ -264,8 +264,7 @@ export async function createQueuedMessageTestRig( fence: store.getRecord(SESSION)!.lease.runtimeFence, acquisitionGeneration: 'generation-1', reportedOptions: { model: 'default' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + restoreSkippedOptions: [] }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts index cd128384038c..0e0c0e02caca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts @@ -23,6 +23,8 @@ import type { AgentSessionMutationSessionPreparation } from './structured-agent- import type { MutationPlan } from './structured-agent-session-mutation-plans' import { commandReceiptScope } from '../agent-session-journal/command-receipt-schema' import { parseAgentSessionOperationTimestamp } from '../../../shared/agent-session-host-authority' +import { waitForStructuredAgentSessionStart } from './structured-agent-session-start-readiness' +import type { StructuredAgentSessionDeliveryPlace } from './structured-agent-session-delivery-order' import type { StructuredAgentSessionStartReadiness } from './structured-agent-session-startup-attempt' type ReadyOperationPlan = Pick< @@ -31,11 +33,10 @@ type ReadyOperationPlan = Pick< > type QueuedReadyOperation = | { result: AgentSessionMutationResult } - | { earlierOperations: Promise } | { child: StructuredAgentSessionProviderChildIdentity - readiness: StructuredAgentSessionStartReadiness - release?: () => void + readiness?: StructuredAgentSessionStartReadiness + place: StructuredAgentSessionDeliveryPlace } export type ReadyOperationChild = StructuredAgentSessionProviderChildIdentity | null @@ -105,12 +106,6 @@ export async function runWithReadyStructuredAgentSessionOperation( const selected = await context.serialize>( envelope.sessionId, async () => { - const earlierOperations = options.needsAgent - ? context.startupAttempts.pendingOperations(envelope.sessionId) - : null - if (earlierOperations) { - return { earlierOperations } - } const record = context.deps.store.getRecord(envelope.sessionId) if (options.needsAgent && !options.needsAgent(record)) { return { result: await run({ ok: true, child: null }) } @@ -197,32 +192,36 @@ export async function runWithReadyStructuredAgentSessionOperation( if (!child.ok) { return { result: child } } - if ('readiness' in child && child.readiness) { - return { - child: child.child, - readiness: child.readiness, - release: child.readiness.reserveOperation?.() + context.deliveryOrder.reconcile( + envelope.sessionId, + context.sessions.get(envelope.sessionId)?.journal.submissions() ?? [] + ) + const place = context.deliveryOrder.reserve(envelope.sessionId) + if (!('readiness' in child) && !place.pending) { + try { + return { result: await run(child) } + } finally { + place.release() } } - return { result: await run(child) } + return { + child: child.child, + ...('readiness' in child ? { readiness: child.readiness } : {}), + place + } } ) - if ('earlierOperations' in selected) { - await selected.earlierOperations - return runWithReadyStructuredAgentSessionOperation( - context, - callerKey, - envelope, - plan, - run, - options - ) - } if ('result' in selected) { return selected.result } try { - const outcome = await selected.readiness.wait(deadline - Date.now()) + const ready = selected.readiness?.wait() ?? Promise.resolve('ready' as const) + const outcome = await waitForStructuredAgentSessionStart( + Promise.all([ready, selected.place.before]).then(([outcome]) => + outcome === 'ready' ? 'ready' : 'ended' + ), + deadline - Date.now() + ) if (outcome !== 'ready') { return operationDidNotRun(outcome === 'timeout') } @@ -233,7 +232,7 @@ export async function runWithReadyStructuredAgentSessionOperation( run({ ok: true, child: selected.child, deadline }) ) } finally { - selected.release?.() + selected.place.release() } } 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 72944ea397b6..e443c55b9eb8 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 @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { AGENT_SESSION_WIRE_REFUSAL_CODES, type AgentSessionMutationEnvelope, @@ -198,6 +199,13 @@ async function assertHostAgreement( return `${spec.method}:${code}` } +async function setLease( + harness: Harness, + update: (record: AgentSessionRecord) => AgentSessionRecord +): Promise { + await harness.store.transitionHandoff(SESSION, update) +} + // sendPlan and setOptionPlan have no unsupported branch. const UNREACHABLE = new Set([ 'agentSession.send:structured_agent_session_unsupported', @@ -214,10 +222,6 @@ const UNREACHABLE = new Set([ 'agentSession.setOption:agent_session_journal_unreadable', // Only a send restarts a lost owner. 'agentSession.setOption:agent_session_owner_restart_failed', - // Saving a next-message pick is a conversation write, independent of its child owner. - 'agentSession.setOption:agent_session_conflict', - 'agentSession.setOption:execution_owner_reconciling', - 'agentSession.setOption:agent_session_operation_unknown', // A write names its target, not an owner generation; only an attach compares fences. 'agentSession.setOption:agent_session_checkpoint_stale', 'agentSession.send:agent_session_checkpoint_stale', @@ -244,7 +248,25 @@ describe('agentSessionRefusalOperationState host oracle', () => { const spec = { method, operationId: operationId(), expectedRuntimeFence: 99 } await expect(invoke(stale, spec), method).resolves.toMatchObject({ ok: true }) } - expect(stale.setOption).not.toHaveBeenCalled() + expect(stale.setOption).toHaveBeenCalledTimes(1) + + const conflict = await createHarness() + for (const method of ['agentSession.setOption'] as const) { + await setLease(conflict, (current) => ({ + ...current, + lease: { ...current.lease, handoffStage: 'new-owner-proving' } + })) + const spec = { method, operationId: operationId() } + record( + await assertHostAgreement(conflict, spec, 'agent_session_conflict', async () => { + await setLease(conflict, (current) => ({ + ...current, + lease: { ...current.lease, handoffStage: null } + })) + return { harness: conflict, spec } + }) + ) + } const absent = await createHarness({ attached: false }) for (const method of METHODS) { @@ -295,6 +317,10 @@ describe('agentSessionRefusalOperationState host oracle', () => { } const unknown = await createHarness() + unknown.setOption.mockRejectedValueOnce(new Error('reply lost')) + const optionUnknown = { method: 'agentSession.setOption' as const, operationId: operationId() } + await expect(invoke(unknown, optionUnknown)).rejects.toThrow('reply lost') + record(await assertHostAgreement(unknown, optionUnknown, 'agent_session_operation_unknown')) const sendUnknown = { method: 'agentSession.send' as const, operationId: operationId() } await expect(invoke(unknown, sendUnknown)).resolves.toMatchObject({ ok: true }) openTestJournalHostDatabase(unknown.root) @@ -304,6 +330,24 @@ describe('agentSessionRefusalOperationState host oracle', () => { .run(sendUnknown.operationId) record(await assertHostAgreement(unknown, sendUnknown, 'agent_session_operation_unknown')) + const reconciling = await createHarness() + for (const method of ['agentSession.setOption'] as const) { + await setLease(reconciling, (current) => ({ + ...current, + lease: { ...current.lease, unreconciled: true } + })) + const spec = { method, operationId: operationId() } + record( + await assertHostAgreement(reconciling, spec, 'execution_owner_reconciling', async () => { + await setLease(reconciling, (current) => ({ + ...current, + lease: { ...current.lease, unreconciled: false } + })) + return { harness: reconciling, spec } + }) + ) + } + const unreadable = await createHarness() await unreadable.host.close(SESSION, 'evict') const unreadableOpen = vi 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 158a0a360951..b5190d19f8eb 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 @@ -121,11 +121,11 @@ describe('a press sent again', () => { await pick('B') expect(await pick('A', first)).toMatchObject({ ok: true, replayed: true }) - expect(setOption).not.toHaveBeenCalled() + expect(setOption.mock.calls.map(([input]) => input.value)).toEqual(['A', 'B']) expect(store.getRecord(SESSION)?.options?.model).toBe('B') expect(await pick('A')).toMatchObject({ ok: true, replayed: false }) - expect(setOption).not.toHaveBeenCalled() + expect(setOption.mock.calls.map(([input]) => input.value)).toEqual(['A', 'B', 'A']) expect(store.getRecord(SESSION)?.options?.model).toBe('A') }) 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 18f54eba1b4a..f794f02867da 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 @@ -24,6 +24,7 @@ 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 { completedStructuredRewind } from './structured-rewind-replay' import { rewindRefusal, rewindRefusalBehindClear } from './structured-rewind-refusal' import { renameRewindTurnOpener } from './structured-rewind-journal-body' import { persistRewindRecord, recoverStructuredRewind } from './structured-rewind-recovery' @@ -37,7 +38,10 @@ export async function rewindStructuredAgentSession( params: AgentSessionRewindParams ): Promise> { const { sessionId, clientOperationId } = params.envelope - const store = context.deps.store + const { + readChildWork, + deps: { store } + } = context const fields = { itemId: params.itemId, expectedEpoch: params.expectedEpoch } const operation = { method: 'agentSession.rewind', fields } const run = async (selection: ReadyOperationSelection) => { @@ -71,17 +75,11 @@ export async function rewindStructuredAgentSession( if (outcome.status === 'succeeded' && outcome.rewind) { return outcome.rewind } - const prior = store.getRecord(sessionId)?.rewind - return prior?.operationId === clientOperationId && - prior.callerKey === caller.callerKey && - prior.phase === 'completed' && - prior.epoch - ? { - itemId: prior.itemId, - epoch: prior.epoch, - ...(prior.sequence !== undefined ? { sequence: prior.sequence } : {}) - } - : null + return completedStructuredRewind( + store.getRecord(sessionId)?.rewind, + caller.callerKey, + clientOperationId + ) }, run: async (ctx) => { await attachContext.runtimeState.flushEventSink(sessionId) @@ -93,7 +91,7 @@ export async function rewindStructuredAgentSession( if (rewindInDoubt(record)) { return rewindRefusal('outcome-unknown') } - if (conversationCommandBlocked(ctx, record, context.readChildWork(sessionId))) { + if (conversationCommandBlocked(ctx, record, readChildWork(sessionId), 'handover')) { return rewindRefusal('busy') } const snapshot = ctx.journal.snapshot() 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 a3fd1745f034..a63a3850c59e 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 @@ -102,8 +102,7 @@ function proveStarted(): Promise { type: 'started', ...currentChild(), reportedOptions: { model: 'sonnet' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + restoreSkippedOptions: [] }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index 70dcc73a3b46..c4f08c6a71c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -1,3 +1,5 @@ +import { failedProviderChildStart } from './structured-agent-session-provider-child' +import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' @@ -106,3 +108,22 @@ export function oldestQueuedSubmission( } return oldest } + +/** A start that died while this message waited on it — a view's, say — is the message's failed + * start: settled with it, under its key, rather than started again into the same failure. */ +export function startThatFailedWhileQueued( + session: StructuredAgentSessionHostSession, + oldest: NonNullable> +): { startKey: string | null; cause: StructuredAgentSessionStartFailureCause } | null { + const ended = failedProviderChildStart(session) + if ( + !ended || + oldest.acceptedSequence === undefined || + ended.endedAt.epoch !== session.journal.cursor().epoch || + ended.endedAt.sequence < oldest.acceptedSequence + ) { + return null + } + const cause = structuredAgentSessionEndedChildFailure(ended) + return cause ? { startKey: ended.generation, cause } : null +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-readiness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-readiness.ts index 01687efd6c4c..da7f7fad76b3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-readiness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-readiness.ts @@ -1,7 +1,5 @@ export type StructuredAgentSessionStartReadiness = { wait: (budgetMs?: number) => Promise<'ready' | 'ended' | 'timeout'> - reserveOperation?: () => () => void - waitForOperations?: () => Promise } export async function waitForStructuredAgentSessionStart( @@ -22,27 +20,3 @@ export async function waitForStructuredAgentSessionStart( clearTimeout(timer) } } - -/** Reservations last only as long as the caller's bounded wait and queued operation. */ -export class StructuredAgentSessionOperationOrder { - private readonly operations = new Map>>() - - reserve(sessionId: string): () => void { - const operation = Promise.withResolvers() - const pending = this.operations.get(sessionId) ?? new Set>() - pending.add(operation.promise) - this.operations.set(sessionId, pending) - return () => { - operation.resolve() - pending.delete(operation.promise) - if (pending.size === 0 && this.operations.get(sessionId) === pending) { - this.operations.delete(sessionId) - } - } - } - - pending(sessionId: string): Promise | null { - const pending = this.operations.get(sessionId) - return pending?.size ? Promise.all(pending).then(() => undefined) : null - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts index 297b5e396cd2..14767b058d1f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts @@ -41,6 +41,4 @@ export type StructuredAgentSessionStartupAttempt = { readonly signal?: AbortSignal /** Any stdout frame or stderr line from the child: proof the start is still moving. */ readonly onOutput?: () => void - /** The conversation's option revision now; a report stamps it as its read begins. */ - readonly optionRevision: () => number } 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 bdc7d8c1174c..239397ea7c0c 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 @@ -170,12 +170,6 @@ async function submission(id: string): Promise { expect(input.onOutput).toEqual(expect.any(Function)) expect(input.attemptId).toEqual(expect.any(String)) expect(input.signal).toBeInstanceOf(AbortSignal) - expect(input.optionRevision!()).toBe( - host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) - ) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts index 9e263dbc0bd0..24ed0941b32e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts @@ -170,8 +170,7 @@ describe.each( opened.resolve({ reportedOptions: { model: 'default' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + restoreSkippedOptions: [] }) await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) 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 661e0044b8eb..cc71a92590aa 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 @@ -34,8 +34,7 @@ function mint() { agent: 'codex', providerHandle: null }, - spawnToken: 'spawn-1', - optionRevision: () => 0 + spawnToken: 'spawn-1' }) } 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 04604c518bb3..83b9c7a2a172 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 @@ -12,7 +12,6 @@ import { randomUUID } from 'node:crypto' import { - StructuredAgentSessionOperationOrder, waitForStructuredAgentSessionStart, type StructuredAgentSessionStartReadiness } from './structured-agent-session-start-readiness' @@ -36,7 +35,6 @@ export function mintStructuredAgentSessionStartupAttempt(input: { permissionMode?: AgentChatPermissionMode events?: StructuredAgentSessionEventSink signal?: AbortSignal - optionRevision: () => number }): StructuredAgentSessionStartupAttempt { const { record } = input return { @@ -52,8 +50,7 @@ export function mintStructuredAgentSessionStartupAttempt(input: { ...(record.options ? { options: record.options } : {}), ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}), ...(input.events ? { events: input.events } : {}), - ...(input.signal ? { signal: input.signal } : {}), - optionRevision: input.optionRevision + ...(input.signal ? { signal: input.signal } : {}) } } @@ -110,7 +107,6 @@ type Tracked = { * new attempt replaces any older one. */ export class StructuredAgentSessionStartupAttempts { private readonly open = new Map() - private readonly operationOrder = new StructuredAgentSessionOperationOrder() private disposed = false private readonly limits: StructuredAgentSessionStartupLimits private readonly now: () => number @@ -177,16 +173,10 @@ export class StructuredAgentSessionStartupAttempts { return null } return { - reserveOperation: () => this.operationOrder.reserve(sessionId), - waitForOperations: () => this.operationOrder.pending(sessionId) ?? Promise.resolve(), wait: (budgetMs) => waitForStructuredAgentSessionStart(tracked.readiness.promise, budgetMs) } } - pendingOperations(sessionId: string): Promise | null { - return this.operationOrder.pending(sessionId) - } - /** 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( 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 fca0340c41ca..a706b79f5da6 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 @@ -114,9 +114,8 @@ beforeEach(async () => { fence: input.fence, acquisitionGeneration, reportedOptions: { model: '' }, - restoreSkippedOptions: [], + restoreSkippedOptions: [] // Never admitted, so the empty report persists nothing. - optionRevision: -1 }) return { providerChildPhase: 'starting' as const, diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts index 0134635df70e..23a51acec32c 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts @@ -1,3 +1,4 @@ +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import { mergeRetainedHostLifecycleRows, @@ -6,6 +7,7 @@ import { import { isDeepStrictEqual } from 'node:util' import { agentJournalItemKey, + agentJournalSubmissionKey, parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind' @@ -184,13 +186,22 @@ export async function recoverStructuredRewind( } // A crash after the journal transaction must settle its existing epoch, not replace it twice. const alreadyReplaced = journal.cursor().epoch !== rewind.expectedEpoch + const queued = new Set( + journal + .submissions() + .filter(isQueuedAgentJournalSubmission) + .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) + ) if ( alreadyReplaced && !isDeepStrictEqual( - journal.snapshot().items.map(({ itemId }) => ({ - itemId, - body: journal.itemBody(itemId) - })), + journal + .snapshot() + .items.filter(({ itemId }) => !queued.has(itemId)) + .map(({ itemId }) => ({ + itemId, + body: journal.itemBody(itemId) + })), replacement.map(({ identity, body }) => ({ itemId: agentJournalItemKey(identity), body })) ) ) { diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-replay.ts b/src/main/native-chat/agent-session-wire/structured-rewind-replay.ts new file mode 100644 index 000000000000..e6565290e6b7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-rewind-replay.ts @@ -0,0 +1,21 @@ +import type { + AgentSessionRewindRecord, + AgentSessionRewindResult +} from '../../../shared/agent-session-rewind' + +export function completedStructuredRewind( + prior: AgentSessionRewindRecord | undefined, + callerKey: string, + operationId: string +): AgentSessionRewindResult | null { + return prior?.operationId === operationId && + prior.callerKey === callerKey && + prior.phase === 'completed' && + prior.epoch + ? { + itemId: prior.itemId, + epoch: prior.epoch, + ...(prior.sequence !== undefined ? { sequence: prior.sequence } : {}) + } + : null +} diff --git a/src/main/runtime/claude-structured-retired-saved-model.test.ts b/src/main/runtime/claude-structured-retired-saved-model.test.ts index d4743996f63a..05730f9b6835 100644 --- a/src/main/runtime/claude-structured-retired-saved-model.test.ts +++ b/src/main/runtime/claude-structured-retired-saved-model.test.ts @@ -1,6 +1,5 @@ -// A chat's saved model rides the launch unchecked. When the CLI answers a turn by saying that model -// does not exist, the record drops it, so the next start runs the CLI's own default; the user's -// message and the CLI's own answer stay as they are. Against the production runtime, adapter, +// A chat's saved model rides the launch unchecked. A missing-model reply retires the live child's +// choice; provider reports leave the saved pick alone. Against the production runtime, adapter, // record store and host, with only the CLI process scripted. import { afterEach, describe, expect, it, vi } from 'vitest' @@ -55,17 +54,17 @@ async function startedWith(options: Record): Promise { - it('drops the model from the record, keeping the rest, so the next start runs the default', async () => { + it('keeps the saved pick while the live child retires its missing model', async () => { const host = await startedWith({ model: RETIRED, effort: 'high' }) expect(claude.child(SESSION).launch.options.model).toBe(RETIRED) expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED) claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) - - await vi.waitFor(() => - expect(host.deps.store.getRecord(SESSION)?.options).not.toHaveProperty('model') - ) - expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({ effort: 'high' }) + await host.flushStreamedEvents(SESSION) + expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({ + model: RETIRED, + effort: 'high' + }) }) it("keeps a model the reply did not come from: a subagent's own error", async () => { @@ -97,21 +96,13 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { }) expect(changed, JSON.stringify(changed)).toMatchObject({ ok: true }) expect(record()?.options?.model).toBe('opus') - - await host.handleAdapterEvent({ - type: 'options-skipped', - sessionId: SESSION, - fence: record()?.lease.runtimeFence ?? 0, - acquisitionGeneration: 'generation-heal', - options: { model: RETIRED } - }) - + claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) + await host.flushStreamedEvents(SESSION) expect(record()?.options?.model).toBe('opus') }) - // A slow settings readback lets the turn's error land first; the report that follows it still - // names the CLI's own model, which is the retired one, and must not write it back. - it('does not let a settings report that lands after the heal write the retired model back', async () => { + // A slow settings readback can report the retired live model after the error. + it('leaves the saved pick alone when that settings report arrives late', async () => { claude.behave(SESSION, { sendsNoStartFrame: true, startupSettingsReadHangs: true, @@ -132,9 +123,8 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { model: RETIRED }) claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) - await vi.waitFor(() => - expect(host.deps.store.getRecord(SESSION)?.options).not.toHaveProperty('model') - ) + await host.flushStreamedEvents(SESSION) + expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED) // The settings read gives up at its deadline and reports what the child showed. await vi.waitFor( @@ -142,6 +132,6 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { { timeout: 5_000 } ) await host.flushStreamedEvents(SESSION) - expect(host.deps.store.getRecord(SESSION)?.options).not.toHaveProperty('model') + expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED) }) }) diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts index a9ba53700dd3..dd0d4fe48736 100644 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -111,8 +111,7 @@ describe('failures the desktop host used to drop', () => { fence: 1, acquisitionGeneration: 'generation-1', reportedOptions: { model: 'gpt-5' }, - restoreSkippedOptions: [], - optionRevision: 0 + restoreSkippedOptions: [] }) lifecycle.deliver({ type: 'ended', diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index f331d774c615..ddecee82e6e7 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -43,7 +43,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { export function structuredClaudeLifecycleEvent( event: ClaudeStructuredSessionEvent ): StructuredAgentSessionLifecycleEvent | null { - if (event.type === 'options-reported' || event.type === 'options-skipped') { + if (event.type === 'options-reported') { return event } // Every exit of a child with an identity, expected or not: the host ends that child's record. From b2e9969ae9116a81bbdb3ee777b503e242ef1015 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:02:35 -0700 Subject: [PATCH 29/55] Drop retired startup revision from Codex opening --- src/main/codex/codex-structured-publish-at-spawn.test.ts | 3 +-- src/main/codex/codex-structured-session-start.ts | 4 +--- src/main/runtime/structured-agent-runtime-registrations.ts | 1 + 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/codex/codex-structured-publish-at-spawn.test.ts b/src/main/codex/codex-structured-publish-at-spawn.test.ts index 523c8c94f548..5249bdff3ef2 100644 --- a/src/main/codex/codex-structured-publish-at-spawn.test.ts +++ b/src/main/codex/codex-structured-publish-at-spawn.test.ts @@ -60,8 +60,7 @@ describe('Codex host-awaited open', () => { expect(await opening).toMatchObject({ link: { handle: { nativeId: THREAD_ID } }, reportedOptions: { model: 'gpt-saved' }, - restoreSkippedOptions: [], - optionRevision: 0 + restoreSkippedOptions: [] }) expect(events.some((event) => event.type === 'started')).toBe(false) await adapter.closeAll() diff --git a/src/main/codex/codex-structured-session-start.ts b/src/main/codex/codex-structured-session-start.ts index 4b8bde2919d8..a38fe9f3398a 100644 --- a/src/main/codex/codex-structured-session-start.ts +++ b/src/main/codex/codex-structured-session-start.ts @@ -76,7 +76,6 @@ export async function openCodexStructuredSession( assertCodexConnectionOpen(connection, sessionId) } current() - const optionRevision = input.optionRevision?.() ?? 0 await waitForPromiseWithSignal( initializeCodexAppServerConnection(connection, STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS), openInput.signal @@ -156,7 +155,6 @@ export async function openCodexStructuredSession( observedAt: deps.now?.() ?? Date.now() }), reportedOptions: reportedCodexSessionOptions(session), - restoreSkippedOptions: [], - optionRevision + restoreSkippedOptions: [] } } diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index b39fc13efe72..746f55af3f91 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -155,6 +155,7 @@ 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) { context.deliverLifecycle(event) } From 937d236d5083cd4393117f60d5659b961ba48c18 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:05:13 -0700 Subject: [PATCH 30/55] Keep live-option cancellation until message choices and clarify catalog reads --- src/main/acp/acp-structured-acquire.ts | 4 +- .../acp-structured-adapter.test-support.ts | 2 - src/main/acp/acp-structured-handshake.ts | 2 - .../acp-structured-host-option-hang.test.ts | 67 +++++++++++++------ .../acp-structured-publish-at-spawn.test.ts | 6 +- .../structured-agent-session-options-read.ts | 4 +- ...ructured-agent-session-provider-started.ts | 4 +- .../structured-agent-session-start-listing.ts | 8 +-- 8 files changed, 59 insertions(+), 38 deletions(-) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 308c75610068..64ccddac53f1 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -160,7 +160,6 @@ export async function acquireAcpStructuredSession(input: { input.track(connection) connection.subscribe((event: AcpSessionEvent) => whenLane(() => { - const optionRevision = acquire.optionRevision?.() ?? 0 if (slot.lane) { routeAcpSessionEvent({ lane: slot.lane, options }, event, now(), slot.reattaching) } @@ -176,8 +175,7 @@ export async function acquireAcpStructuredSession(input: { fence: acquire.fence, acquisitionGeneration: generation, reportedOptions: options.read().current, - restoreSkippedOptions: [], - optionRevision + restoreSkippedOptions: [] }) } }) diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index 7049381ae810..3620304c5cd7 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -65,7 +65,6 @@ export type AcpAdapterRig = { signal?: AbortSignal waitForStart?: boolean options?: Readonly> - optionRevision?: () => number }): ReturnType /** Frames Orca wrote to the agent with this method. */ sent(method: string): FakeFrame[] @@ -196,7 +195,6 @@ export async function openAcpAdapterRig( 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') diff --git a/src/main/acp/acp-structured-handshake.ts b/src/main/acp/acp-structured-handshake.ts index 823a3eaa4cec..27d0880922b5 100644 --- a/src/main/acp/acp-structured-handshake.ts +++ b/src/main/acp/acp-structured-handshake.ts @@ -54,7 +54,6 @@ export async function initializeAcpStructuredSession( const now = deps.now ?? Date.now const assertStarting = () => assertAcpStartingSession(child, input.abandoned) const agentName = acpAgentName(spec.agent) - const optionRevision = acquire.optionRevision?.() ?? 0 assertStarting() const initialized = await connection.initialize() assertStarting() @@ -157,7 +156,6 @@ export async function initializeAcpStructuredSession( link, reportedOptions: options.read().current, restoreSkippedOptions: restoreSkipped, - optionRevision, // Its saved picks restored: what it runs now is what its start resolved. catalogListing: options.startListing() } diff --git a/src/main/acp/acp-structured-host-option-hang.test.ts b/src/main/acp/acp-structured-host-option-hang.test.ts index 889aa34009f1..32f0a6df64f6 100644 --- a/src/main/acp/acp-structured-host-option-hang.test.ts +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -1,4 +1,5 @@ -// Saving a choice never waits for the live agent. +// A model or effort pick Grok never answers, through the real host: it must not hold the chat's +// Stop or Close behind it, and on its own it fails at a bound instead of holding the chat forever. import { afterEach, describe, expect, it } from 'vitest' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' @@ -7,6 +8,7 @@ import { envelope } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { GROK_CONFIG_OPTIONS, waitFor } from './acp-structured-adapter.test-support' import { framesOf, openAttachedHostRig, stop } from './acp-structured-host.test-support' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' @@ -38,22 +40,49 @@ async function within(promise: Promise, ms: number) { } } -describe('a saved Grok pick', () => { - it.each(['Stop', 'Close'] as const)( - 'never contacts Grok or holds %s behind a setter', - async (ending) => { - const { rig, host, store } = await openAttachedHostRig({ - script: (agent) => agent.on('session/set_config_option', () => {}) - }) - expect(await within(pick(host, 'grok-4.6'), 1_000)).toMatchObject({ - settled: 'resolved', - value: { ok: true } - }) - expect(store.getRecord(SESSION)?.options?.model).toBe('grok-4.6') - expect(framesOf(rig.child(), 'session/set_config_option')).toEqual([]) - const ended = ending === 'Stop' ? stop(host) : host.close(SESSION, 'user-close') - expect(await within(ended, 2_000)).toMatchObject({ settled: 'resolved' }) - await host.close(SESSION, 'user-close') - } - ) +const failed = (outcome: Awaited>) => + outcome !== 'pending' && + (outcome.settled === 'rejected' || + (typeof outcome.value === 'object' && outcome.value !== null && 'ok' in outcome.value + ? outcome.value.ok === false + : false)) + +describe('a Grok model pick Grok never answers', () => { + it('does not hold a Stop behind it; the pick fails', async () => { + const { rig, host } = await openAttachedHostRig() + const picking = pick(host, 'grok-4.6') + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) + expect(await within(stop(host), 2_000)).toMatchObject({ settled: 'resolved' }) + expect(failed(await within(picking, 1_000))).toBe(true) + expect(await within(host.close(SESSION, 'user-close'), 2_000)).not.toBe('pending') + }) + + it('does not hold a Close behind it; the pick fails', async () => { + const { rig, host } = await openAttachedHostRig() + const picking = pick(host, 'grok-4.6') + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) + expect(await within(host.close(SESSION, 'user-close'), 2_000)).toMatchObject({ + settled: 'resolved' + }) + expect(failed(await within(picking, 1_000))).toBe(true) + }) + + it('fails at its bound, frees the chat, and keeps what Grok answers late', async () => { + const { rig, host } = await openAttachedHostRig({ optionWriteTimeoutMs: 100 }) + const picking = pick(host, 'grok-4.6') + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) + expect(failed(await within(picking, 2_000))).toBe(true) + // The chat's queue moved on: a later operation runs. + expect(await within(host.readOptions(SESSION), 1_000)).not.toBe('pending') + const [frame] = framesOf(rig.child(), 'session/set_config_option') + rig.child().agent.reply(frame!, { + configOptions: GROK_CONFIG_OPTIONS.map((option) => + option.id === 'model' ? { ...option, currentValue: 'grok-4.6' } : option + ) + }) + await waitFor(async () => + expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' }) + ) + await host.close(SESSION, 'user-close') + }) }) diff --git a/src/main/acp/acp-structured-publish-at-spawn.test.ts b/src/main/acp/acp-structured-publish-at-spawn.test.ts index f0cb2b5cfd62..41151659f176 100644 --- a/src/main/acp/acp-structured-publish-at-spawn.test.ts +++ b/src/main/acp/acp-structured-publish-at-spawn.test.ts @@ -37,8 +37,7 @@ describe('ACP child published before its handshake', () => { }) const acquired = await rig.acquire({ waitForStart: false, - options: { effort: 'low', model: 'grok-4.6' }, - optionRevision: () => 7 + options: { effort: 'low', model: 'grok-4.6' } }) expect(acquired.process.pid).toBe(4242) if (!acquired.acquisitionGeneration) { @@ -60,8 +59,7 @@ describe('ACP child published before its handshake', () => { expect(await opening).toMatchObject({ link: { handle: { nativeId: PROVIDER_SESSION }, origin: 'created' }, reportedOptions: { model: 'grok-4.6', effort: 'low' }, - restoreSkippedOptions: [], - optionRevision: 7 + restoreSkippedOptions: [] }) expect(writes).toEqual(['model', 'reasoning_effort']) rig.child().agent.notify('session/update', { 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 c11bdb216883..888e5859cf47 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -1,4 +1,3 @@ -import { saveStructuredAgentSessionStartListing } from './structured-agent-session-start-listing' // A chat's options, live or at rest. // // At rest nothing here needs a child. The pick is the record's own `options` — what the next start replays — @@ -6,6 +5,7 @@ import { saveStructuredAgentSessionStartListing } from './structured-agent-sessi // exists. A pick made at rest is written to the record as intent, through the same transition a live // pick takes, so the next start applies it. +import { saveStructuredAgentSessionListing } from './structured-agent-session-start-listing' import { refuse, type AgentSessionModelCatalogResult, @@ -219,7 +219,7 @@ export async function readStructuredAgentSessionOptions( const { catalogListing, ...answer } = live if (catalogListing) { // What a running child listed is its account's catalog too, so the next chat opens warm. - saveStructuredAgentSessionStartListing(context, sessionId, catalogListing) + saveStructuredAgentSessionListing(context, sessionId, catalogListing) } options = answer } else { 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 8c44ce53ecb0..e0e6c3130027 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 @@ -15,7 +15,7 @@ import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { - saveStructuredAgentSessionStartListing, + saveStructuredAgentSessionListing, saveStructuredAgentSessionStartReadbackListing } from './structured-agent-session-start-listing' import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' @@ -79,7 +79,7 @@ export async function settleStructuredAgentSessionProviderStarted( void context.wakeDelivery(event.sessionId) noteStructuredAgentSessionProviderStarted(context.deps, event.sessionId) if (event.catalogListing) { - saveStructuredAgentSessionStartListing(context, event.sessionId, event.catalogListing) + saveStructuredAgentSessionListing(context, event.sessionId, event.catalogListing) } context.publishStatus?.(event.sessionId) return undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-listing.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-listing.ts index 4b1029308383..074e9114db52 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-listing.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-listing.ts @@ -15,8 +15,8 @@ export type StructuredAgentSessionStartListingContext = { serialize: (sessionId: string, task: () => Promise) => Promise } -/** A proven start's listing, saved inside its serialized step. */ -export function saveStructuredAgentSessionStartListing( +/** A provider listing, saved inside its serialized step; persistence never fails its read. */ +export function saveStructuredAgentSessionListing( context: Pick, sessionId: string, listing: AgentModelCatalogLiveListing @@ -24,8 +24,8 @@ export function saveStructuredAgentSessionStartListing( try { context.deps.modelCatalog?.recordLiveListing(sessionId, listing) } catch (error) { - context.deps.logger.warn('saving what a started provider listed failed', { - scope: 'provider-started-catalog', + context.deps.logger.warn('saving the provider model list failed', { + scope: 'provider-catalog', sessionId, error }) From d25715ea6a66853ccbed80363cc9ef10c9c79aaf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:07:09 -0700 Subject: [PATCH 31/55] Hold ACP option replies in live-write cancellation regressions --- .../acp/acp-structured-host-option-hang.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/acp/acp-structured-host-option-hang.test.ts b/src/main/acp/acp-structured-host-option-hang.test.ts index 32f0a6df64f6..5fe02884e7f3 100644 --- a/src/main/acp/acp-structured-host-option-hang.test.ts +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -49,7 +49,9 @@ const failed = (outcome: Awaited>) => describe('a Grok model pick Grok never answers', () => { it('does not hold a Stop behind it; the pick fails', async () => { - const { rig, host } = await openAttachedHostRig() + const { rig, host } = await openAttachedHostRig({ + script: (agent) => agent.on('session/set_config_option', () => {}) + }) const picking = pick(host, 'grok-4.6') await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) expect(await within(stop(host), 2_000)).toMatchObject({ settled: 'resolved' }) @@ -58,7 +60,9 @@ describe('a Grok model pick Grok never answers', () => { }) it('does not hold a Close behind it; the pick fails', async () => { - const { rig, host } = await openAttachedHostRig() + const { rig, host } = await openAttachedHostRig({ + script: (agent) => agent.on('session/set_config_option', () => {}) + }) const picking = pick(host, 'grok-4.6') await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) expect(await within(host.close(SESSION, 'user-close'), 2_000)).toMatchObject({ @@ -68,7 +72,10 @@ describe('a Grok model pick Grok never answers', () => { }) it('fails at its bound, frees the chat, and keeps what Grok answers late', async () => { - const { rig, host } = await openAttachedHostRig({ optionWriteTimeoutMs: 100 }) + const { rig, host } = await openAttachedHostRig({ + optionWriteTimeoutMs: 100, + script: (agent) => agent.on('session/set_config_option', () => {}) + }) const picking = pick(host, 'grok-4.6') await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) expect(failed(await within(picking, 2_000))).toBe(true) From 24d2a861ced9bdd9b96a7a3c40e29683123da293 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:11:19 -0700 Subject: [PATCH 32/55] Apply startup picks and let parked messages release operations [skip ci] --- .../claude/claude-structured-retired-model.ts | 10 -- .../claude-structured-session-adapter.test.ts | 6 +- .../claude-structured-session-startup.ts | 11 +-- ...structured-agent-session-adapter-router.ts | 2 - .../structured-agent-session-adapter.ts | 6 -- ...-agent-session-claude-option-queue.test.ts | 39 +++++--- .../structured-agent-session-create.test.ts | 3 +- .../structured-agent-session-delivery-loop.ts | 1 + ...structured-agent-session-delivery-order.ts | 11 ++- ...d-agent-session-held-start.test-support.ts | 3 +- ...structured-agent-session-host-mutations.ts | 28 +++--- ...gent-session-instant-start.test-support.ts | 3 +- ...ed-agent-session-option-settlement.test.ts | 3 +- ...gent-session-provider-child-record.test.ts | 3 +- ...ed-agent-session-provider-open-contract.ts | 2 - ...ctured-agent-session-provider-open.test.ts | 22 ++--- ...t-session-provider-started-catalog.test.ts | 3 +- ...session-queued-message-rig.test-fixture.ts | 3 +- ...tructured-agent-session-ready-operation.ts | 16 ++-- ...nt-session-running-operation-order.test.ts | 92 +++++++++++++++++++ ...session-send-restarts-failed-start.test.ts | 3 +- ...agent-session-startup-attempt-host.test.ts | 3 +- ...sion-startup-attempt-registrations.test.ts | 3 +- .../structured-conversation-command.test.ts | 3 +- ...ured-agent-session-failure-logging.test.ts | 6 +- 25 files changed, 180 insertions(+), 105 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-running-operation-order.test.ts diff --git a/src/main/claude/claude-structured-retired-model.ts b/src/main/claude/claude-structured-retired-model.ts index b6433d7a060a..3b233201722b 100644 --- a/src/main/claude/claude-structured-retired-model.ts +++ b/src/main/claude/claude-structured-retired-model.ts @@ -60,13 +60,3 @@ export function resetClaudeRetiredModel( }) ) } - -/** The saved values this child showed it cannot run. Only a retired launch model is one: a launch - * never skips a model, so a skipped model is that. */ -export function claudeRetiredOptions( - session: Pick -): { retiredOptions?: Record } { - return session.restoreSkippedOptions.has('model') && session.launchedModel !== null - ? { retiredOptions: { model: session.launchedModel } } - : {} -} diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index a563461a1937..87c04b453ec7 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -164,8 +164,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { params: { settings: { fastMode: true } } }) expect(await claudeOpened(adapter, 'session-1')).toMatchObject({ - reportedOptions: { fastMode: true }, - restoreSkippedOptions: [] + reportedOptions: { fastMode: true } }) }) @@ -196,8 +195,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { // Started, with the pick, while the apply is still unanswered: nothing waits on it. expect(claude.connections[0].calls.at(-1)?.subtype).toBe('apply_flag_settings') expect(await claudeOpened(adapter, 'session-1')).toMatchObject({ - reportedOptions: { fastMode: true }, - restoreSkippedOptions: [] + reportedOptions: { fastMode: true } }) answerApply(failure) await tick() diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index 41ff344a52af..f924756e6221 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -17,7 +17,6 @@ import { claudeStructuredSessionPublicationOptions, prepareClaudeStructuredSessionAcquisitionOptions } from './claude-structured-session-acquisition-options' -import { claudeRetiredOptions } from './claude-structured-retired-model' import { claudeResolvedCatalogListing, claudeStructuredSessionOptionsFrom, @@ -37,7 +36,7 @@ import { export type ClaudeOpenFacts = Pick< StructuredAgentSessionProviderOpenResult, - 'reportedOptions' | 'restoreSkippedOptions' | 'retiredOptions' | 'catalogListing' + 'reportedOptions' | 'catalogListing' > /** A lifecycle event the start reports, before the session's identity is stamped on it. */ @@ -211,9 +210,7 @@ export async function openClaudeSession(input: { session, readClaudeModels(initialized.initialization), session.fastModeAtStart ? session.optionMutationSequence - 1 : undefined - ), - restoreSkippedOptions: [...session.restoreSkippedOptions], - ...claudeRetiredOptions(session) + ) } void settleClaudeStartupSettings(input, initialized) return result @@ -255,9 +252,7 @@ async function settleClaudeStartupSettings( reportedOptions: claudeStartedReportedOptions(session, readClaudeModels(facts.initialization)) .reportedOptions, // The one report of what the config resolved: the readback just applied it. - ...claudeResolvedCatalogListing(session, readClaudeModels(facts.initialization)), - restoreSkippedOptions: [...session.restoreSkippedOptions], - ...claudeRetiredOptions(session) + ...claudeResolvedCatalogListing(session, readClaudeModels(facts.initialization)) }) if (startFastMode !== null) { void applyClaudeStartFastMode(session, facts, startFastMode) 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 ad17ad5e5e0f..a5ec23fccc3a 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 @@ -171,8 +171,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) 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 a6fbaa9d7ffd..449169f2e2c2 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 @@ -220,10 +220,6 @@ export type StructuredAgentSessionStartedEvent = { /** 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'] - /** Saved options the child could not take; the host drops them rather than persist them. */ - restoreSkippedOptions: readonly string[] - /** Values the child showed it cannot run: a report naming the same value is not persisted. */ - retiredOptions?: Readonly> /** What the child listed at startup, saved as its account's catalog even if no view reads it. */ catalogListing?: AgentModelCatalogLiveListing } @@ -390,8 +386,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 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 6b2d9acadb9b..2d4a0441a5ca 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 @@ -31,6 +31,7 @@ let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let acquire: Mock let activeModel: string +let activeEffort: string | null let dispatch: Mock let order: string[] let setOption: Mock @@ -54,8 +55,10 @@ function adapter(): StructuredAgentSessionAdapter { order.push(`${key}=${value}`) if (key === 'model') { activeModel = value + } else if (key === 'effort') { + activeEffort = value } - return { [key]: value } + return { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) } }) dispatch = vi.fn(async () => { order.push('dispatch') @@ -81,8 +84,7 @@ function adapter(): StructuredAgentSessionAdapter { new Promise((resolve) => { releaseOpen = () => resolve({ - reportedOptions: { model: DEFAULT_MODEL }, - restoreSkippedOptions: [] + reportedOptions: { model: DEFAULT_MODEL } }) }), acquire, @@ -102,6 +104,7 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-claude-options-')) resetHostTestOperationIds() activeModel = DEFAULT_MODEL + activeEffort = null order = [] releaseOpen = () => {} store = await openTestAgentSessionRecordStore(root) @@ -135,29 +138,37 @@ afterEach(async () => { }) describe('Claude structured session options', () => { - it('saves picks made while opening without changing the child', async () => { + it('writes picks made while opening once the child is ready', 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 effort = pick('effort', 'high') + const model = pick('model', PICKED_MODEL) + let settled = false + void Promise.all([effort, model]).then(() => { + settled = true + }) + try { + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + expect(settled).toBe(false) + expect(setOption).not.toHaveBeenCalled() + } finally { + releaseOpen() + } + expect(await effort).toMatchObject({ ok: true }) + expect(await model).toMatchObject({ ok: true }) + expect(order).toEqual(['effort=high', 'model=opus']) const body = hostTestMessage('hello') expect( await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) ).toMatchObject({ ok: true }) - expect(dispatch).not.toHaveBeenCalled() - - releaseOpen() await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - expect(order).toEqual(['dispatch']) - expect(activeModel).toBe(DEFAULT_MODEL) + expect(order).toEqual(['effort=high', 'model=opus', '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-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts index 792e73fa158c..e6e05f91d6cd 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 @@ -215,8 +215,7 @@ it('holds the created first message until the child proves its start, then hands sessionId: SESSION, fence: rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, acquisitionGeneration: generation, - reportedOptions: { model: 'gpt-live' }, - restoreSkippedOptions: [] + reportedOptions: { model: 'gpt-live' } }) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index c43e2198ca3d..710b758a97a3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -353,6 +353,7 @@ export class StructuredAgentSessionDeliveryLoop { /** Inside the serialized step that found nothing to do, so an accept after it wakes anew. */ private stop(sessionId: string): 'stop' { this.running.delete(sessionId) + this.deps.deliveryOrder.parkSends(sessionId) return 'stop' } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-order.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-order.ts index d1321333c842..8347f64c5127 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-order.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-order.ts @@ -2,7 +2,6 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' export type StructuredAgentSessionDeliveryPlace = { - pending: boolean before: Promise release: () => void } @@ -47,7 +46,6 @@ export class StructuredAgentSessionDeliveryOrder { this.lanes.set(sessionId, lane) const done = Promise.withResolvers() const place: StructuredAgentSessionDeliveryPlace = { - pending: lane.places.size > 0, before: lane.tail, release: () => { done.resolve() @@ -66,6 +64,15 @@ export class StructuredAgentSessionDeliveryOrder { return this.lanes.get(sessionId)?.sends.get(messageId)?.before ?? Promise.resolve() } + /** Queued messages keep their journal rows, but stop ordering operations when delivery parks. */ + parkSends(sessionId: string): void { + const lane = this.lanes.get(sessionId) + for (const place of lane?.sends.values() ?? []) { + place.release() + } + lane?.sends.clear() + } + dispose(): void { for (const lane of this.lanes.values()) { for (const place of lane.places) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts index 3dd46ba861d4..ebcd15553a78 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts @@ -48,8 +48,7 @@ export function structuredAgentSessionHeldStarts( return adapter.open ? adapter.open(input) : { - reportedOptions: { model: '' }, - restoreSkippedOptions: [] + reportedOptions: { model: '' } } } return { ...adapter, acquire, open } 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 9e8deac8bdb2..a77430f5afc9 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 @@ -47,8 +47,10 @@ import { mutateWithChatStop } from './structured-agent-session-chat-stop' import { runWithReadyStructuredAgentSessionOperation, openWithReadyStructuredAgentSessionChild, - runAfterRewindStart + runAfterRewindStart, + operationDidNotRun } from './structured-agent-session-ready-operation' +import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from './structured-agent-session-send-settlement' export type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { @@ -185,15 +187,19 @@ export async function setStructuredAgentSessionOption( caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope; key: string; value: string } ): Promise> { + const recordOnly = params.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID + const child = context.sessions.get(params.envelope.sessionId)?.child + if (!recordOnly && child?.phase === 'starting' && !child.close) { + const readiness = context.startupAttempts.readiness(params.envelope.sessionId, child) + const outcome = await readiness?.wait(STRUCTURED_AGENT_SESSION_START_WAIT_MS) + if (outcome !== 'ready') { + return operationDidNotRun(outcome === 'timeout') + } + } const plan = setOptionPlan(params) - const recordsIntent = () => { + const atRest = () => { const child = context.sessions.get(params.envelope.sessionId)?.child - return ( - params.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID || - !child || - child.phase === 'starting' || - child.close !== undefined - ) + return recordOnly || !child || child.close !== undefined } return mutateStructuredAgentSession( context, @@ -202,10 +208,10 @@ export async function setStructuredAgentSessionOption( { ...plan, get conversationWrite() { - return recordsIntent() ? (true as const) : undefined + return atRest() ? (true as const) : undefined }, run: async (ctx) => { - if (recordsIntent()) { + if (atRest()) { return recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) } const wait = context.acquireAborts.begin(params.envelope.sessionId) @@ -216,7 +222,7 @@ export async function setStructuredAgentSessionOption( } } }, - () => (recordsIntent() ? openForWrite : openForProviderWrite)(context, params.envelope)() + () => (atRest() ? openForWrite : openForProviderWrite)(context, params.envelope)() ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts index 8c112868362a..a1a99ce71836 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts @@ -21,8 +21,7 @@ export function startsWhenPublished( open: adapter.open ?? (async () => ({ - reportedOptions: { model: '' }, - restoreSkippedOptions: [] + reportedOptions: { model: '' } })) } } 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 a29ece6f449f..1876416e7d5a 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 @@ -64,8 +64,7 @@ function adapter(): StructuredAgentSessionAdapter { sessionId: identity.sessionId, fence, acquisitionGeneration, - reportedOptions: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) }, - restoreSkippedOptions: [] + reportedOptions: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) } }) return { providerChildPhase: 'starting' as const, 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 e3a717bd4131..c8914cf56a78 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 @@ -224,8 +224,7 @@ function prove(): Promise { type: 'started', ...child, acquisitionGeneration, - reportedOptions: { model: 'default' }, - restoreSkippedOptions: [] + reportedOptions: { model: 'default' } }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts index afc7081dae3d..981636f2caad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts @@ -13,8 +13,6 @@ export type StructuredAgentSessionProviderOpenInput = { export type StructuredAgentSessionProviderOpenResult = { link?: AgentSessionProviderHandleLink reportedOptions: AgentSessionOptionsResult['current'] - restoreSkippedOptions: readonly string[] - retiredOptions?: Readonly> catalogListing?: AgentModelCatalogLiveListing } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts index de4649ddd201..de8d13fe7335 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -18,8 +18,7 @@ import { } from './structured-agent-session-host-test-data' const OPENED: StructuredAgentSessionProviderOpenResult = { - reportedOptions: { model: 'gpt-test' }, - restoreSkippedOptions: [] + reportedOptions: { model: 'gpt-test' } } function awaitOpen() { @@ -245,20 +244,19 @@ it.each(['stop', 'close'] as const)( } ) -it('saves startup picks and writes running picks to the live child', async () => { +it('writes both startup and running picks to the live child', async () => { const { host, store, pending, setOption } = awaitOpen() await attach() for (const model of ['model-before-ready', 'model-after-ready']) { - expect( - await host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', { key: 'model', value: model }), - key: 'model', - value: model - }) - ).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options?.model).toBe(model) - expect(setOption).toHaveBeenCalledTimes(model === 'model-before-ready' ? 0 : 1) + const picked = host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', { key: 'model', value: model }), + key: 'model', + value: model + }) pending.resolve(OPENED) + expect(await picked).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.options?.model).toBe(model) + expect(setOption).toHaveBeenCalledTimes(model === 'model-before-ready' ? 1 : 2) await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') ) 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 1c593dbc742f..a256f02037e4 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 @@ -87,8 +87,7 @@ function proveStart(): Promise { sessionId: SESSION, fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'sonnet' }, - restoreSkippedOptions: [] + reportedOptions: { model: 'sonnet' } }) } 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 3e34fcfcfe98..0d4c821007f8 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 @@ -263,8 +263,7 @@ export async function createQueuedMessageTestRig( sessionId: SESSION, fence: store.getRecord(SESSION)!.lease.runtimeFence, acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'default' }, - restoreSkippedOptions: [] + reportedOptions: { model: 'default' } }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts index 0e0c0e02caca..39a663aa299a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts @@ -35,7 +35,7 @@ type QueuedReadyOperation = | { result: AgentSessionMutationResult } | { child: StructuredAgentSessionProviderChildIdentity - readiness?: StructuredAgentSessionStartReadiness + readiness: StructuredAgentSessionStartReadiness place: StructuredAgentSessionDeliveryPlace } @@ -192,21 +192,17 @@ export async function runWithReadyStructuredAgentSessionOperation( if (!child.ok) { return { result: child } } + if (!child.readiness) { + return { result: await run(child) } + } context.deliveryOrder.reconcile( envelope.sessionId, context.sessions.get(envelope.sessionId)?.journal.submissions() ?? [] ) const place = context.deliveryOrder.reserve(envelope.sessionId) - if (!('readiness' in child) && !place.pending) { - try { - return { result: await run(child) } - } finally { - place.release() - } - } return { child: child.child, - ...('readiness' in child ? { readiness: child.readiness } : {}), + readiness: child.readiness, place } } @@ -215,7 +211,7 @@ export async function runWithReadyStructuredAgentSessionOperation( return selected.result } try { - const ready = selected.readiness?.wait() ?? Promise.resolve('ready' as const) + const ready = selected.readiness.wait() const outcome = await waitForStructuredAgentSessionStart( Promise.all([ready, selected.place.before]).then(([outcome]) => outcome === 'ready' ? 'ready' : 'ended' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-running-operation-order.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-running-operation-order.test.ts new file mode 100644 index 000000000000..27c25109e10d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-running-operation-order.test.ts @@ -0,0 +1,92 @@ +import { expect, it, vi } from 'vitest' +import { + CALLER, + attach, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' +import { structuredAgentSessionCompactBody } from './structured-agent-session-command-turn' +import type { StructuredAgentSessionProviderOpenResult } from './structured-agent-session-provider-open-contract' + +it.each(['goal', 'rewind'] as const)( + 'answers a %s while an earlier message is queued behind compact', + async (operation) => { + const { host, dispatch } = hostTestState() + const compact = vi.fn(async () => ({ state: 'accepted' as const, providerIdentity: null })) + host.deps.adapter.compact = compact + host.deps.adapter.changeThreadGoal = vi.fn(async () => ({ ok: true as const })) + host.deps.adapter.rewindSupport = () => ({ supported: true }) + await attach() + const body = structuredAgentSessionCompactBody() + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const queued = hostTestMessage('After compact') + expect( + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: queued }), + body: queued + }) + ).toMatchObject({ ok: true }) + const change = { kind: 'set' as const, objective: 'A goal during compact' } + const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch + const itemId = 'codex:thread-1:turn-1:1' + const result = + operation === 'goal' + ? host.changeThreadGoal(CALLER, { + envelope: envelope('agentSession.threadGoal', { change }), + change + }) + : host.rewind(CALLER, { + envelope: envelope('agentSession.rewind', { itemId, expectedEpoch }), + itemId, + expectedEpoch + }) + let settled = false + void result.then(() => { + settled = true + }) + await vi.waitFor(() => expect(settled).toBe(true)) + expect(await result).toMatchObject( + operation === 'goal' ? { ok: true } : { ok: false, refusal: { rewindReason: 'busy' } } + ) + expect(dispatch).not.toHaveBeenCalled() + expect((await host.journalSnapshot(SESSION)).submissions.at(-1)?.dispatchState).toBe('pending') + } +) + +it('releases a startup goal when earlier delivery parks behind compact', async () => { + const { host, dispatch } = hostTestState() + const opened = Promise.withResolvers() + host.deps.adapter.open = () => opened.promise + host.deps.adapter.compact = vi.fn(async () => ({ + state: 'accepted' as const, + providerIdentity: null + })) + host.deps.adapter.changeThreadGoal = vi.fn(async () => ({ ok: true as const })) + await attach() + for (const body of [structuredAgentSessionCompactBody(), hostTestMessage('After compact')]) { + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + } + const change = { kind: 'set' as const, objective: 'A goal requested during startup' } + const result = host.changeThreadGoal(CALLER, { + envelope: envelope('agentSession.threadGoal', { change }), + change + }) + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + opened.resolve({ reportedOptions: { model: '' } }) + let settled = false + void result.then(() => { + settled = true + }) + await vi.waitFor(() => expect(settled).toBe(true)) + expect(await result).toMatchObject({ ok: true }) + expect(dispatch).not.toHaveBeenCalled() +}) 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 a63a3850c59e..87cd7ae22639 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 @@ -101,8 +101,7 @@ function proveStarted(): Promise { return host.handleAdapterEvent({ type: 'started', ...currentChild(), - reportedOptions: { model: 'sonnet' }, - restoreSkippedOptions: [] + reportedOptions: { model: 'sonnet' } }) } 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 239397ea7c0c..2e1a85c68d34 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 @@ -176,8 +176,7 @@ function startedEvent(generation = `generation-${acquire.mock.calls.length}`) { sessionId: SESSION, fence: store.getRecord(SESSION)!.lease.runtimeFence, acquisitionGeneration: generation, - reportedOptions: { model: 'default' }, - restoreSkippedOptions: [] + reportedOptions: { model: 'default' } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts index 24ed0941b32e..13bd46a60d35 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts @@ -169,8 +169,7 @@ describe.each( expect(dispatch).not.toHaveBeenCalled() opened.resolve({ - reportedOptions: { model: 'default' }, - restoreSkippedOptions: [] + reportedOptions: { model: 'default' } }) await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) 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 a706b79f5da6..7191ada60a86 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 @@ -113,8 +113,7 @@ beforeEach(async () => { sessionId: input.identity.sessionId, fence: input.fence, acquisitionGeneration, - reportedOptions: { model: '' }, - restoreSkippedOptions: [] + reportedOptions: { model: '' } // Never admitted, so the empty report persists nothing. }) return { diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts index dd0d4fe48736..dbd98119d2e2 100644 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -26,6 +26,7 @@ import { neverThrowingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { StructuredAgentSessionDeliveryOrder } from '../native-chat/agent-session-wire/structured-agent-session-delivery-order' import { _resetTracerForTests, setActiveSink } from '../observability/tracer' import { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups' import { createStructuredAgentSessionLifecycleDelivery } from './structured-agent-session-lifecycle-delivery' @@ -110,8 +111,7 @@ describe('failures the desktop host used to drop', () => { sessionId: SESSION, fence: 1, acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'gpt-5' }, - restoreSkippedOptions: [] + reportedOptions: { model: 'gpt-5' } }) lifecycle.deliver({ type: 'ended', @@ -172,6 +172,8 @@ describe('host collaborators log with their own scope', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a step that rejects at its first serialize reads only these members. { serialize: rejectingSerialize, + sessions: new Map(), + deliveryOrder: new StructuredAgentSessionDeliveryOrder(), trackStart: (start: Promise) => start, logger: log.logger } as unknown as StructuredAgentSessionDeliveryLoopDeps From eabdda0e1fa33d8a65eeca62dde08c6ab006984f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:13:40 -0700 Subject: [PATCH 33/55] Remove unused Codex opening report field [skip ci] --- src/main/codex/codex-structured-publish-at-spawn.test.ts | 3 +-- src/main/codex/codex-structured-session-start.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/codex/codex-structured-publish-at-spawn.test.ts b/src/main/codex/codex-structured-publish-at-spawn.test.ts index 5249bdff3ef2..581092306e51 100644 --- a/src/main/codex/codex-structured-publish-at-spawn.test.ts +++ b/src/main/codex/codex-structured-publish-at-spawn.test.ts @@ -59,8 +59,7 @@ describe('Codex host-awaited open', () => { threaded.resolve({ thread: { id: THREAD_ID }, model: 'gpt-live' }) expect(await opening).toMatchObject({ link: { handle: { nativeId: THREAD_ID } }, - reportedOptions: { model: 'gpt-saved' }, - restoreSkippedOptions: [] + reportedOptions: { model: 'gpt-saved' } }) expect(events.some((event) => event.type === 'started')).toBe(false) await adapter.closeAll() diff --git a/src/main/codex/codex-structured-session-start.ts b/src/main/codex/codex-structured-session-start.ts index a38fe9f3398a..4355ed48c2cb 100644 --- a/src/main/codex/codex-structured-session-start.ts +++ b/src/main/codex/codex-structured-session-start.ts @@ -154,7 +154,6 @@ export async function openCodexStructuredSession( linkId: deps.mintLinkId?.(), observedAt: deps.now?.() ?? Date.now() }), - reportedOptions: reportedCodexSessionOptions(session), - restoreSkippedOptions: [] + reportedOptions: reportedCodexSessionOptions(session) } } From 34f1349f288739954647b1badfdfcbb142e4d820 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:15:53 -0700 Subject: [PATCH 34/55] Keep ACP restoration facts local to the provider [skip ci] --- src/main/acp/acp-structured-acquire.ts | 3 +-- src/main/acp/acp-structured-handshake.ts | 1 - src/main/acp/acp-structured-publish-at-spawn.test.ts | 7 +++---- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 64ccddac53f1..071458ba0a89 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -174,8 +174,7 @@ export async function acquireAcpStructuredSession(input: { sessionId, fence: acquire.fence, acquisitionGeneration: generation, - reportedOptions: options.read().current, - restoreSkippedOptions: [] + reportedOptions: options.read().current }) } }) diff --git a/src/main/acp/acp-structured-handshake.ts b/src/main/acp/acp-structured-handshake.ts index 27d0880922b5..1d61e51a8240 100644 --- a/src/main/acp/acp-structured-handshake.ts +++ b/src/main/acp/acp-structured-handshake.ts @@ -155,7 +155,6 @@ export async function initializeAcpStructuredSession( return { link, reportedOptions: options.read().current, - restoreSkippedOptions: restoreSkipped, // Its saved picks restored: what it runs now is what its start resolved. catalogListing: options.startListing() } diff --git a/src/main/acp/acp-structured-publish-at-spawn.test.ts b/src/main/acp/acp-structured-publish-at-spawn.test.ts index 41151659f176..957c2b356584 100644 --- a/src/main/acp/acp-structured-publish-at-spawn.test.ts +++ b/src/main/acp/acp-structured-publish-at-spawn.test.ts @@ -58,8 +58,7 @@ describe('ACP child published before its handshake', () => { answer?.() expect(await opening).toMatchObject({ link: { handle: { nativeId: PROVIDER_SESSION }, origin: 'created' }, - reportedOptions: { model: 'grok-4.6', effort: 'low' }, - restoreSkippedOptions: [] + reportedOptions: { model: 'grok-4.6', effort: 'low' } }) expect(writes).toEqual(['model', 'reasoning_effort']) rig.child().agent.notify('session/update', { @@ -156,7 +155,7 @@ describe('ACP child published before its handshake', () => { expect(rig.child().exited).toBe(true) }) - it('reports saved picks the provider refused without delaying readiness for another read', async () => { + it('keeps refused picks in provider-local facts without delaying readiness for another read', async () => { const rig = await openAcpAdapterRig({ script: (agent) => agent.on('session/set_config_option', (frame) => @@ -165,9 +164,9 @@ describe('ACP child published before its handshake', () => { }) await rig.acquire({ options: { model: 'retired-model' } }) expect(rig.started()).toMatchObject({ - restoreSkippedOptions: ['model'], reportedOptions: { model: 'grok-4.7', effort: 'high' } }) + expect(rig.adapter.readOptionRestoreFailures(SESSION)).toEqual(['model']) expect(rig.child().exited).toBe(false) }) From 349b20126697a57cb2fd0ce54627530fb778d0d3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:36:37 -0700 Subject: [PATCH 35/55] Save a waiting startup pick after Stop ends its opening [skip ci] --- ...-agent-session-claude-option-queue.test.ts | 19 +++++++++++++++++++ ...structured-agent-session-host-mutations.ts | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) 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 2d4a0441a5ca..9835f6c32d5b 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 @@ -203,3 +203,22 @@ it('applies model and effort picks on a running Claude chat to the next message' await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) expect(order).toEqual(['model=opus', 'effort=high']) }) + +it('saves a waiting startup pick when Stop ends the child before it is ready', async () => { + const fields = { key: 'model', value: PICKED_MODEL } + const picked = host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', fields), + ...fields + }) + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + try { + expect( + await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + ).toMatchObject({ ok: true }) + expect(await picked).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.options?.model).toBe(PICKED_MODEL) + expect(setOption).not.toHaveBeenCalled() + } finally { + releaseOpen() + } +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index a77430f5afc9..a4ed32f82fec 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 @@ -192,7 +192,7 @@ export async function setStructuredAgentSessionOption( if (!recordOnly && child?.phase === 'starting' && !child.close) { const readiness = context.startupAttempts.readiness(params.envelope.sessionId, child) const outcome = await readiness?.wait(STRUCTURED_AGENT_SESSION_START_WAIT_MS) - if (outcome !== 'ready') { + if (outcome !== 'ready' && outcome !== 'ended') { return operationDidNotRun(outcome === 'timeout') } } From 2b3db6de3fad59776bd622e1616842fbfc87a758 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:07:02 -0700 Subject: [PATCH 36/55] Save choices when startup ends or a replacement is still opening --- .../structured-agent-session-host-mutations.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a4ed32f82fec..00c46800419c 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 @@ -191,7 +191,7 @@ export async function setStructuredAgentSessionOption( const child = context.sessions.get(params.envelope.sessionId)?.child if (!recordOnly && child?.phase === 'starting' && !child.close) { const readiness = context.startupAttempts.readiness(params.envelope.sessionId, child) - const outcome = await readiness?.wait(STRUCTURED_AGENT_SESSION_START_WAIT_MS) + const outcome = (await readiness?.wait(STRUCTURED_AGENT_SESSION_START_WAIT_MS)) ?? 'ended' if (outcome !== 'ready' && outcome !== 'ended') { return operationDidNotRun(outcome === 'timeout') } @@ -199,7 +199,7 @@ export async function setStructuredAgentSessionOption( const plan = setOptionPlan(params) const atRest = () => { const child = context.sessions.get(params.envelope.sessionId)?.child - return recordOnly || !child || child.close !== undefined + return recordOnly || !child || child.phase === 'starting' || child.close !== undefined } return mutateStructuredAgentSession( context, From f09f33d7dd1964f9863490bc45adbbc8d432ce4b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:27:15 -0700 Subject: [PATCH 37/55] Check saved choices separately from Claude startup reports --- ...startup-unanswered-control-request.test.ts | 48 +++++++++---------- 1 file changed, 22 insertions(+), 26 deletions(-) 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 c4df2f4e73d7..2cba0bdb2c0f 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 @@ -56,6 +56,15 @@ async function setOption( } /** What the picker is handed: the value it shows, and which ones the CLI vouched for. */ +async function ready(host: StructuredAgentSessionHost): Promise { + await vi.waitFor( + () => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready'), + { + timeout: DEADLINE_MS * 40 + } + ) +} + async function picker(host: StructuredAgentSessionHost) { const { model, effort, confirmed } = (await host.readOptions(SESSION)).current return { model, effort, confirmed } @@ -122,9 +131,8 @@ describe('a Claude start whose CLI answers initialize but not a control request' permissionMode: 'acceptEdits' }) // The record keeps the saved choices through the start. - await vi.waitFor(() => expect(record(host)?.options).toEqual({ ...saved, effort: 'high' }), { - timeout: DEADLINE_MS * 40 - }) + await ready(host) + expect(record(host)?.options).toEqual(saved) expect(await statusRows(host)).toEqual([]) expect(claude.children(SESSION)).toHaveLength(1) expect(claude.child(SESSION).calls).not.toContain('set_model') @@ -142,12 +150,8 @@ describe('a Claude start whose CLI answers initialize but not a control request' await expect( host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'sonnet' } })) ).resolves.toMatchObject({ ok: true }) - await vi.waitFor( - () => expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high' }), - { - timeout: DEADLINE_MS * 40 - } - ) + await ready(host) + expect(record(host)?.options).toEqual({ model: 'sonnet' }) // The CLI answers again; the user sets another option on the running child. behavior.optionWritesHang = false @@ -164,10 +168,8 @@ describe('a Claude start whose CLI answers initialize but not a control request' await expect( host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'sonnet' } })) ).resolves.toMatchObject({ ok: true }) - await vi.waitFor( - () => expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high' }), - { timeout: DEADLINE_MS * 40 } - ) + await ready(host) + expect(record(host)?.options).toEqual({ model: 'sonnet' }) // The picker offers the saved model, which nothing has vouched for yet. expect(await picker(host)).toEqual({ model: 'sonnet', effort: 'high', confirmed: ['effort'] }) @@ -179,14 +181,13 @@ describe('a Claude start whose CLI answers initialize but not a control request' effort: 'high', confirmed: ['model', 'effort'] }) - expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high' }) + expect(record(host)?.options).toEqual({ model: 'sonnet' }) behavior.optionWritesHang = false // The permission mode is the record's alone; the running child is not asked. await setOption(host, 'permissionMode', 'accept-edits') expect(record(host)?.options).toEqual({ model: 'sonnet', - effort: 'high', permissionMode: 'accept-edits' }) @@ -227,7 +228,6 @@ describe('a Claude start whose CLI answers initialize but not a control request' await vi.waitFor(() => expect(record(host)?.options).toEqual({ model: 'sonnet', - effort: 'high', permissionMode: 'accept-edits' }) ) @@ -241,12 +241,9 @@ describe('a Claude start whose CLI answers initialize but not a control request' await expect( host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'sonnet' } })) ).resolves.toMatchObject({ ok: true }) - // The record holds the saved model from creation; only the start's own report (effort) says - // startup finished, and an option write before then waits for it. - await vi.waitFor( - () => expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high' }), - { timeout: DEADLINE_MS * 40 } - ) + // Wait on host readiness; provider reports never replace saved choices. + await ready(host) + expect(record(host)?.options).toEqual({ model: 'sonnet' }) turnReportsModel('claude-sonnet-5') behavior.optionWritesHang = false @@ -255,16 +252,15 @@ describe('a Claude start whose CLI answers initialize but not a control request' expect((await picker(host)).model).toBe('opus') }) - it("lands with effort unknown when startup's own settings read goes unanswered", async () => { + it("keeps saved choices empty when startup's settings read goes unanswered", async () => { claude.behave(SESSION, { startupSettingsReadHangs: true, controlTimeoutMs: DEADLINE_MS }) const host = await claude.install() await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ ok: true }) - await vi.waitFor(() => expect(record(host)?.options).toEqual({ model: 'claude-sonnet-5' }), { - timeout: DEADLINE_MS * 40 - }) + await ready(host) + expect(record(host)?.options).toBeUndefined() expect(record(host)?.lease.claimStatus).toBe('live') expect(await statusRows(host)).toEqual([]) expect(claude.children(SESSION)).toHaveLength(1) From 48f2fe6cf08217bcab42c5ba38852f0e7e66ddfc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:33:49 -0700 Subject: [PATCH 38/55] Release scripted Codex startup before awaiting queued picks --- ...agent-session-codex-scripted-child.test.ts | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) 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 index f2d87dcbe8da..7619e7fbf346 100644 --- a/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts +++ b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts @@ -101,23 +101,27 @@ it('can Stop the scripted child during initialization and settle a held send as 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 picks = ( + [ + ['model', 'scripted-alt-model'], + ['effort', 'high'] + ] as const + ).map(([key, value]) => { const fields = { key, value } - expect( - await host.setOption(caller, { - envelope: envelope('agentSession.setOption', fence, fields), - ...fields - }) - ).toMatchObject({ ok: true }) - } + return host.setOption(caller, { + envelope: envelope('agentSession.setOption', fence, fields), + ...fields + }) + }) + child.releaseHandshake() + expect(await Promise.all(picks)).toEqual([ + expect.objectContaining({ ok: true }), + expect.objectContaining({ 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') From eae95007de1712a1171702eb2bb87eaddb52de76 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:31:07 -0700 Subject: [PATCH 39/55] Align startup fixtures and preserve observed Claude exits --- .../claude-structured-session-adapter.ts | 30 +++++--- ...ude-structured-failed-start-resume.test.ts | 10 ++- ...structured-fake-connection-test-fixture.ts | 7 +- ...-send-restart-dies-before-dispatch.test.ts | 6 +- ...ude-structured-session-integration.test.ts | 70 +++++++++---------- ...tured-startup-fault-is-not-an-exit.test.ts | 5 +- ...t-session-runtime-provider-started.test.ts | 57 ++++++--------- ...ed-claude-scripted-runtime-test-support.ts | 3 + ...ss-version-agent-session-wire.unit.test.ts | 15 ++-- 9 files changed, 102 insertions(+), 101 deletions(-) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index a82b3be18775..d4c9689d863d 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -1,4 +1,5 @@ import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import type { AgentSessionAcquisition, @@ -232,14 +233,27 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda input, this.deps.requestTimeoutMs ) - open: NonNullable = (request) => - openClaudeStructuredSession({ - request, - session: this.session(request.sessionId), - sessions: this.sessions, - deps: this.deps, - emit: (session, event) => this.emit(session, event) - }) + open: NonNullable = async (request) => { + try { + return await openClaudeStructuredSession({ + request, + session: this.session(request.sessionId), + sessions: this.sessions, + deps: this.deps, + emit: (session, event) => this.emit(session, event) + }) + } catch (error) { + const exit = this.exits.get(request.sessionId) + // Publish an already-observed exit before the host treats this rejection as its own fault. + if ( + exit?.session.acquisitionGeneration === request.acquisitionGeneration && + exit.publication + ) { + await waitForPromiseWithSignal(exit.publication, request.signal) + } + throw error + } + } readOptions = (input: { sessionId: string; fence: number }) => readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) readOptionRestoreFailures = (sessionId: string): readonly string[] => [ diff --git a/src/main/runtime/claude-structured-failed-start-resume.test.ts b/src/main/runtime/claude-structured-failed-start-resume.test.ts index cd4f44d59f8a..9f82c959be8d 100644 --- a/src/main/runtime/claude-structured-failed-start-resume.test.ts +++ b/src/main/runtime/claude-structured-failed-start-resume.test.ts @@ -1,6 +1,6 @@ // A Claude chat whose first start died before initialize (not signed in, a crash) wrote no // transcript, so its next start launches that same provider id fresh. It is still the same -// conversation: the new child continues the record's chain rather than creating a second root. +// conversation: only the first child to prove its start creates the record's handle chain. import { afterEach, describe, expect, it, vi } from 'vitest' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' @@ -24,6 +24,7 @@ describe('a Claude chat whose first start died before initialize', () => { await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ ok: true }) + await vi.waitFor(() => expect(claude.child(SESSION).initializing).toBe(true)) claude.child(SESSION).exit(new Error('claude stream-json exited (code 1): not signed in')) await waitForStructuredAgentSessionRecovery() const failed = host.deps.store.getRecord(SESSION) @@ -37,14 +38,17 @@ describe('a Claude chat whose first start died before initialize', () => { ) expect(reopened, JSON.stringify(reopened)).toMatchObject({ ok: true }) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) const providerSessionId = claudeSessionIdForOrcaSession(SESSION) // No transcript exists to `--resume`, so the id is started fresh... expect(claude.child(SESSION).launch.options).toMatchObject({ sessionId: providerSessionId }) expect(claude.child(SESSION).launch.options.resume).toBeUndefined() - // ...and the record still holds one conversation under one root. + // ...and only this proven start records the conversation's root. expect( host.deps.store.getRecord(SESSION)?.providerHandleChain.map((link) => link.origin) - ).toEqual(['created', 'resumed']) + ).toEqual(['created']) await vi.waitFor(() => expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') ) diff --git a/src/main/runtime/claude-structured-fake-connection-test-fixture.ts b/src/main/runtime/claude-structured-fake-connection-test-fixture.ts index 3404bcbf283c..528d8a8b7c7d 100644 --- a/src/main/runtime/claude-structured-fake-connection-test-fixture.ts +++ b/src/main/runtime/claude-structured-fake-connection-test-fixture.ts @@ -35,7 +35,12 @@ export function fakeClaude(providerSession: string) { initializationResult: async () => { connection.calls.push({ subtype: 'initialize' }) if (selfExit) { - handlers.onExit?.(new Error(selfExit.message)) + const error = new Error(selfExit.message) + if (selfExit.exitVerdict.root === 'exited') { + handlers.onExit?.(error) + } else { + handlers.onFault?.(error) + } return { models: [] } } handlers.onMessage?.({ diff --git a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts index a05905e9599c..ae873d22e210 100644 --- a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts +++ b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts @@ -95,6 +95,7 @@ async function submission(host: StructuredAgentSessionHost, clientMessageId: str async function failLatestStart(host: StructuredAgentSessionHost, count: number): Promise { await eventually(() => expect(claude.children(SESSION)).toHaveLength(count)) + await eventually(() => expect(claude.child(SESSION).initializing).toBe(true)) claude.child(SESSION).exit(scriptedClaudeExitError(DIAGNOSTIC)) await waitForStructuredAgentSessionRecovery() await eventually(() => @@ -141,9 +142,8 @@ describe('a send whose restarted Claude child dies before it proves its start', // Accepted: the answer comes before the restart it needs. expect(answered.get(sent)).toBe('pending') await eventually(() => expect(claude.children(SESSION)).toHaveLength(2)) - await eventually(() => - expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) - ) + await eventually(() => expect(claude.child(SESSION).initializing).toBe(true)) + expect(await submission(host, sent)).toMatchObject({ dispatchState: 'pending' }) expect(claude.child(SESSION).calls).not.toContain('send') await failLatestStart(host, 2) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index dedde5033497..2e4009ed7e42 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -280,6 +280,7 @@ beforeEach(async () => { afterEach(async () => { vi.unstubAllEnvs() + claude.setSelfExit(null) await stopStructuredAgentSessionRuntime() await rm(root, { recursive: true, force: true }) }) @@ -401,7 +402,9 @@ describe('a structured Claude session over agentSession.*', () => { text: "Claude isn't signed in. Run `claude auth login`, or choose an account in Claude Accounts settings.", failure: { kind: 'notSignedIn', account: 'system' } }) - expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) + await vi.waitFor(() => + expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) + ) // A failed start is not auto-resumed into the same failure. expect(claude.connections).toHaveLength(1) }) @@ -409,7 +412,7 @@ describe('a structured Claude session over agentSession.*', () => { // The root's death is first-hand. Its descendants were never snapshottable, or one was seen // alive; either way the lease follows the root, so the reservation goes with it. it.each(['unverifiable', 'live'] as const)( - 'releases a session whose CLI self-exited during create with its tree %s, refused in a sentence', + 'releases a session whose CLI self-exited after spawn with its tree %s, with a readable failure', async (tree) => { claude.setSelfExit({ message: 'claude stream-json exited (code 1): claude: not signed in', @@ -418,18 +421,13 @@ describe('a structured Claude session over agentSession.*', () => { const failed = await call('agentSession.create', createIntentParams()) - // Answered once, as the refusal a replay of this operation gives, never thrown first. - expect(failed).toMatchObject({ - ok: true, - result: { - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - // The CLI's stderr is log text; the person reads what the chat's start failure says. - message: 'Claude stopped before it finished starting. Send your message to try again.', - ownerVerdict: 'exited' - } - } + expect(failed).toMatchObject({ ok: true, result: { ok: true } }) + await vi.waitFor(() => expect(leaseOf(SESSION).claimStatus).toBe('released')) + const failure = itemsOf(await subscribe()).find((item) => item.body?.kind === 'status') + expect(failure?.body).toMatchObject({ + kind: 'status', + text: 'Claude stopped before it finished starting. Send your message to try again.', + failure: { kind: 'providerStartFailed' } }) const lease = leaseOf(SESSION) // Latching here would refuse every later attach with agent_session_ownership_unknown, @@ -437,7 +435,7 @@ describe('a structured Claude session over agentSession.*', () => { expect(lease).toMatchObject({ claimStatus: 'released', handoffStage: null }) expect(lease.deathEvidence).toMatchObject({ kind: 'exit-observed', - detail: 'the provider process exited; its descendants were not proven gone' + detail: 'claude stream-json exited (code 1): claude: not signed in' }) claude.setSelfExit(null) @@ -446,7 +444,7 @@ describe('a structured Claude session over agentSession.*', () => { } ) - it('answers a create whose whole CLI tree exited as exited on the first call', async () => { + it('releases a spawned child whose whole CLI tree exited', async () => { claude.setSelfExit({ message: 'claude stream-json exited (code 1): claude: not signed in', // The common case: the close ladder proves the root and every descendant gone. @@ -455,22 +453,15 @@ describe('a structured Claude session over agentSession.*', () => { const failed = await call('agentSession.create', createIntentParams()) - expect(failed).toMatchObject({ - ok: true, - result: { - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: 'Claude stopped before it finished starting. Send your message to try again.', - ownerVerdict: 'exited' - } - } - }) - expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) + expect(failed).toMatchObject({ ok: true, result: { ok: true } }) + await vi.waitFor(() => + expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) + ) + expect(leaseOf(SESSION).deathEvidence).toMatchObject({ kind: 'exit-observed' }) claude.setSelfExit(null) }) - it('releases a failed start that recorded no owner without claiming it exited', async () => { + it('keeps the owner when a published child failure has no proof of exit', async () => { claude.setSelfExit({ message: 'claude stream-json exited (code 1): claude: not signed in', // The root was never seen to exit, so nothing proves this start's process gone. @@ -478,15 +469,16 @@ describe('a structured Claude session over agentSession.*', () => { }) await call('agentSession.create', createIntentParams()) - claude.setSelfExit(null) + await vi.waitFor(() => expect(claude.live().closed).toBe(true)) - // The adapter closed the stdio of what it spawned, and no owner was recorded to stop. The next - // start goes ahead; with no death evidence nothing reads the failed start as exited. + // Losing stdio is not proof that a published owner exited. expect(leaseOf(SESSION)).toMatchObject({ - claimStatus: 'released', + claimStatus: 'live', handoffStage: null, deathEvidence: null }) + expect(claude.connections).toHaveLength(1) + claude.setSelfExit(null) }) it.each(['unverifiable', 'live'] as const)( @@ -796,14 +788,18 @@ describe('a structured Claude session over agentSession.*', () => { handle: claudeProviderHandle(PROVIDER_SESSION, 'provider-opened-assistant'), origin: 'resumed' } - expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject( - lastCompletedTurn + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject( + lastCompletedTurn + ) ) // Open, close, open with no turn in between keeps that leaf. const reopened = await ok<{ fence: number }>('agentSession.ensure', ensureParams(resumed.fence)) expect(reopened.fence).toBe(resumed.fence + 1) - expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject( - lastCompletedTurn + await vi.waitFor(() => + expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject( + lastCompletedTurn + ) ) }) diff --git a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts index e6866dcaa420..c315f3740dbb 100644 --- a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts +++ b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts @@ -101,9 +101,8 @@ describe('a Claude start that Orca fails while the CLI is still running', () => ok: true }) const held = await send(host, 'hello') - await vi.waitFor(() => - expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe(false) - ) + await vi.waitFor(() => expect(claude.child(SESSION).initializing).toBe(true)) + expect(await submission(host, held)).toMatchObject({ dispatchState: 'pending' }) expect(claude.child(SESSION).calls).not.toContain('send') claude.child(SESSION).answerInit() diff --git a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts index 9bce63de54da..49ade95333ad 100644 --- a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts @@ -13,6 +13,7 @@ const CALLER = { callerKey: 'client-1' } let claude = createScriptedClaudeRuntime([STALLED, HEALTHY]) afterEach(async () => { + vi.restoreAllMocks() await claude.dispose() claude = createScriptedClaudeRuntime([STALLED, HEALTHY]) }) @@ -32,8 +33,9 @@ describe('a Claude child proving its start', () => { ok: true }) await vi.waitFor(() => expect(claude.child(STALLED).calls).toContain('get_settings')) - // Its `started` reaches the shared chain ahead of the exit below. - await new Promise((resolve) => setImmediate(resolve)) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(STALLED)?.child?.phase).toBe('ready') + ) claude.child(HEALTHY).exit(new Error('claude stream-json exited (code 1): crashed')) await vi.waitFor(() => @@ -42,11 +44,10 @@ describe('a Claude child proving its start', () => { // What the child proved is still recorded, from the startup it already answered. await vi.waitFor(() => - expect(host.deps.store.getRecord(STALLED)?.options).toEqual({ - model: 'claude-sonnet-5', - effort: 'high' - }) + expect(host.collaboratorsForTests().sessions.get(STALLED)?.child?.phase).toBe('ready') ) + expect(host.deps.store.getRecord(STALLED)?.options).toBeUndefined() + expect(host.deps.store.getRecord(STALLED)?.providerHandleChain).toHaveLength(1) expect(claude.child(STALLED).calls).toEqual(['get_settings']) let closed = false @@ -70,44 +71,26 @@ describe('a Claude child proving its start', () => { ok: true }) await vi.waitFor(() => - expect(host.deps.store.getRecord(STALLED)?.options).toEqual({ - model: 'claude-sonnet-5', - effort: 'high' - }) + expect(host.collaboratorsForTests().sessions.get(STALLED)?.child?.phase).toBe('ready') ) // The other start is still where it was: reserved, with no new child. expect(host.deps.store.getRecord(HEALTHY)?.lease.claimStatus).toBe('reserved') expect(claude.children(HEALTHY)).toHaveLength(childrenWhileHung) }) - it('is drained by the runtime before teardown proceeds', async () => { + it('records the proven start without overwriting saved choices with provider reports', async () => { const host = await claude.install() - const store = host.deps.store - const replaceSessionOptions = store.replaceSessionOptions.bind(store) - let landWrite = (): void => {} - const writeHeld = new Promise((resolve) => { - landWrite = resolve - }) - let writing = false - vi.spyOn(store, 'replaceSessionOptions').mockImplementation(async (input) => { - writing = true - await writeHeld - return replaceSessionOptions(input) - }) - await expect(host.attach(CALLER, claude.attachParams(HEALTHY, null))).resolves.toMatchObject({ - ok: true - }) - await vi.waitFor(() => expect(writing).toBe(true)) - - let drained = false - const recovery = waitForStructuredAgentSessionRecovery().then(() => { - drained = true - }) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(drained).toBe(false) + const replaceOptions = vi.spyOn(host.deps.store, 'replaceSessionOptions') + const saved = { model: 'opus', effort: 'low' } + await expect( + host.attach(CALLER, claude.attachParams(HEALTHY, null, { options: saved })) + ).resolves.toMatchObject({ ok: true }) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(HEALTHY)?.child?.phase).toBe('ready') + ) - landWrite() - await recovery - expect(store.getRecord(HEALTHY)?.options).toEqual({ model: 'claude-sonnet-5', effort: 'high' }) + expect(host.deps.store.getRecord(HEALTHY)?.providerHandleChain).toHaveLength(1) + expect(host.deps.store.getRecord(HEALTHY)?.options).toEqual(saved) + expect(replaceOptions).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index b4fd229e3aca..b776140c68df 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -55,6 +55,7 @@ export type ScriptedClaudeChild = { sessionId: string launch: ClaudeStreamJsonLaunch calls: string[] + initializing: boolean handlers: ClaudeStreamJsonConnectionHandlers connection: Omit & { closed: boolean @@ -116,6 +117,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { sessionId, launch, calls: [], + initializing: false, handlers, exit: (error) => { child.connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } @@ -128,6 +130,7 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { closed: false, exitVerdict: { root: 'live', tree: 'unverifiable' }, initializationResult: () => { + child.initializing = true const initialized = { models: [{ value: 'sonnet', displayName: 'Sonnet' }] } const announce = (): void => { if (behavior.sendsNoStartFrame) { 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 df6cc3b580d5..9f7c48f20f1c 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,10 +15,7 @@ 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, - StructuredAgentSessionLifecycleEvent -} from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionAdapter } 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' @@ -692,16 +689,16 @@ describe('cross-version structured agent sessions', () => { /** 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, + open: async () => { + await handshake + return { reportedOptions: { model: '' } } + }, acquire: async ({ fence }) => { starts += 1 await startGate @@ -739,7 +736,7 @@ describe('cross-version structured agent sessions', () => { answerPrompt: async () => undefined, setOption: async () => undefined }, - () => proves + host ) } From 237372a4dea78a4831a91dfbd7e2d14495f3adf0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:00:17 -0700 Subject: [PATCH 40/55] Open Codex protocol sessions before Stop fixture dispatch --- ...ed-agent-session-conversation-stop.test.ts | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) 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 9996e5e5fa36..3e82b745af18 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -33,6 +33,7 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' import { THREAD_ID, + acquireReadyCodexForTest, adapterFor, answerWithOpenedTurn, fakeCodex, @@ -203,7 +204,7 @@ describe('a Stop that names no turn', () => { const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog: new AgentModelCatalogStore() }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-catalog', @@ -215,6 +216,10 @@ describe('a Stop that names no turn', () => { cancelTurn.mockImplementation((input) => adapter.cancelTurn(input)) setOption.mockImplementation((input) => adapter.setOption(input)) + const listing = adapter.readOptions({ sessionId: SESSION, fence: 1 }) + const catalogReads = codex.connections[0].calls.filter( + (call) => call.method === 'model/list' + ).length const first = send('first send') await first.result await eventually(() => @@ -225,7 +230,7 @@ describe('a Stop that names no turn', () => { value: { options: { model: 'gpt-next' } } }) expect(codex.connections[0].calls.filter((call) => call.method === 'model/list')).toHaveLength( - 1 + catalogReads ) const followUp = send('follow-up') @@ -250,6 +255,7 @@ describe('a Stop that names no turn', () => { codex.connections[0].calls.filter((call) => call.method === 'turn/start')[1]?.params ).toMatchObject({ serviceTier: 'priority' }) pending.resolve({ data: [], nextCursor: null }) + await expect(listing).rejects.toThrow('no available models') }) // Codex can abort a turn before it starts: it answers the interrupt, then ends the turn it never @@ -275,7 +281,7 @@ describe('a Stop that names no turn', () => { return {} } const adapter = adapterFor(codex, { codexHome: '/codex/home' }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-unstarted', @@ -317,7 +323,7 @@ describe('a Stop that names no turn', () => { return {} } const adapter = adapterFor(codex, { codexHome: '/codex/home' }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-unstarted-item', @@ -346,7 +352,7 @@ describe('a Stop that names no turn', () => { const codex = fakeCodex() codex.routes['turn/start'] = () => ({ turn: { id: 'turn-1' } }) const adapter = adapterFor(codex, { codexHome: '/codex/home' }) - await adapter.acquire({ + await acquireReadyCodexForTest(adapter, { identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-unstarted-failed', @@ -386,7 +392,11 @@ describe('a Stop that names no turn', () => { const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog: new AgentModelCatalogStore() }) - await adapter.acquire({ identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-bad' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor(SESSION), + fence: 1, + spawnToken: 'spawn-bad' + }) dispatch.mockImplementation((input) => adapter.dispatch(input)) setOption.mockImplementation((input) => adapter.setOption(input)) From 8ea4b4123053342beac8dfc0dd817d944a6c4b00 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:20:13 -0700 Subject: [PATCH 41/55] Wait for the opened Codex child before runtime notifications --- src/main/runtime/structured-codex-child-work-runtime.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts index e292c271e3e9..4056bad66d5e 100644 --- a/src/main/runtime/structured-codex-child-work-runtime.test.ts +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -102,6 +102,9 @@ describe('structured Codex child work through the production runtime', () => { attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams) expect(attached).toMatchObject({ ok: true }) + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('ready') + ) // Creating the session starts its child; nothing else has to keep it running. expect(connections).toHaveLength(1) const notify = (method: string, params: Record) => From 7501b4f2208dc5adeaa9ec3fa83584d2fb5892ca Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:46:20 -0700 Subject: [PATCH 42/55] Order startup picks before later messages and retain flow regressions --- ...gent-model-catalog-saved-only-read.test.ts | 34 --- ...-agent-session-claude-option-queue.test.ts | 39 +-- ...structured-agent-session-host-mutations.ts | 65 +++-- ...uctured-agent-session-options-read.test.ts | 41 --- ...ctured-agent-session-provider-open.test.ts | 272 ------------------ ...gent-session-ready-operation-order.test.ts | 55 ---- ...tructured-agent-session-ready-operation.ts | 9 +- ...ured-agent-session-startup-attempt.test.ts | 49 ---- ...startup-unanswered-control-request.test.ts | 84 ++++++ ...se-native-chat-provisional-launch.test.tsx | 35 +-- ...session-options.start-keeps-picks.test.tsx | 148 ---------- ...-structured-agent-session-options.test.tsx | 45 ++- .../use-structured-agent-session-options.ts | 13 +- 13 files changed, 180 insertions(+), 709 deletions(-) delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.start-keeps-picks.test.tsx 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 43d9148b037a..a481c6632ac4 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 @@ -14,7 +14,6 @@ import { } from './agent-model-catalog-store' import { AgentModelCatalogUnavailableError } from './agent-model-catalog-unavailable' import { agentReadsProjectModelConfig } from './agent-project-model-override' -import { foundAgentSessionRecord } from '../../runtime/agent-session-record-founding' // A client preloads every agent's saved list through `savedOnly`: the answer comes from what the // host saved, and no listing, probe or re-check of a held reason starts. @@ -53,39 +52,6 @@ function rig(agent = 'grok') { } describe('a saved-only catalog read', () => { - it('probes for a brand-new released chat before its first send', async () => { - const record = foundAgentSessionRecord( - { - sessionId: 'new-chat', - provider: 'grok', - accountHome: HOME, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'folder-1', - workspaceKind: 'folder' - } - }, - { claimKeyId: 'key-1', now: 1_000 } - ) - const probe = vi.fn(async () => listing('grok-4.7')) - const service = createAgentModelCatalogService({ - store: new AgentModelCatalogStore(), - getRecord: () => record, - drivesRecord: () => true, - resolveAccountHome: async () => HOME, - probes: { grok: probe } - }) - - expect(record.lease.claimStatus).toBe('released') - expect(record.lease.deathEvidence).toBeNull() - expect(record.providerHandleChain).toEqual([]) - expect( - await service.read({ agent: 'grok', sessionId: record.sessionId, waitForListing: true }) - ).toMatchObject({ origin: 'probe', models: [{ id: 'grok-4.7' }] }) - expect(probe).toHaveBeenCalledExactlyOnceWith(HOME, { signal: expect.any(AbortSignal) }) - }) - it('does not relaunch a released chat to read models after startup failed', async () => { let now = 1_000 const record = { 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 9835f6c32d5b..6b50d0bc03f1 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 @@ -1,7 +1,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { afterEach, beforeEach, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -137,43 +137,6 @@ afterEach(async () => { await rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }) }) -describe('Claude structured session options', () => { - it('writes picks made while opening once the child is ready', async () => { - const pick = (key: string, value: string) => - host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', { key, value }), - key, - value - }) - const effort = pick('effort', 'high') - const model = pick('model', PICKED_MODEL) - let settled = false - void Promise.all([effort, model]).then(() => { - settled = true - }) - try { - await host.collaboratorsForTests().serialize(SESSION, async () => {}) - expect(settled).toBe(false) - expect(setOption).not.toHaveBeenCalled() - } finally { - releaseOpen() - } - expect(await effort).toMatchObject({ ok: true }) - expect(await model).toMatchObject({ ok: true }) - expect(order).toEqual(['effort=high', 'model=opus']) - - const body = hostTestMessage('hello') - expect( - await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - expect(order).toEqual(['effort=high', 'model=opus', 'dispatch']) - expect(activeModel).toBe(PICKED_MODEL) - await host.flushAllStreamedEvents() - expect(store.getRecord(SESSION)?.options).toEqual({ effort: 'high', model: PICKED_MODEL }) - }) -}) - it('applies model and effort picks on a running Claude chat to the next message', async () => { releaseOpen() await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) 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 40ed8a533325..5327b0495a63 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 @@ -47,10 +47,8 @@ import { mutateWithChatStop } from './structured-agent-session-chat-stop' import { runWithReadyStructuredAgentSessionOperation, openWithReadyStructuredAgentSessionChild, - runAfterRewindStart, - operationDidNotRun + runAfterRewindStart } from './structured-agent-session-ready-operation' -import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from './structured-agent-session-send-settlement' export type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { @@ -202,41 +200,50 @@ export async function setStructuredAgentSessionOption( params: { envelope: AgentSessionMutationEnvelope; key: string; value: string } ): Promise> { const recordOnly = params.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID - const child = context.sessions.get(params.envelope.sessionId)?.child - if (!recordOnly && child?.phase === 'starting' && !child.close) { - const readiness = context.startupAttempts.readiness(params.envelope.sessionId, child) - const outcome = (await readiness?.wait(STRUCTURED_AGENT_SESSION_START_WAIT_MS)) ?? 'ended' - if (outcome !== 'ready' && outcome !== 'ended') { - return operationDidNotRun(outcome === 'timeout') - } - } const plan = setOptionPlan(params) const atRest = () => { const child = context.sessions.get(params.envelope.sessionId)?.child return recordOnly || !child || child.phase === 'starting' || child.close !== undefined } - return mutateStructuredAgentSession( + return runWithReadyStructuredAgentSessionOperation( context, - caller, + caller.callerKey, params.envelope, + plan, + (selection) => + runStructuredAgentSessionMutation( + context, + caller, + params.envelope, + { + ...plan, + get conversationWrite() { + return atRest() ? (true as const) : undefined + }, + run: async (ctx) => { + if (atRest()) { + return recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) + } + const wait = context.acquireAborts.begin(params.envelope.sessionId) + try { + return await performSetOption(ctx, params, wait.signal) + } finally { + wait.end() + } + } + }, + (ledger) => + atRest() + ? openForWrite(context, params.envelope)() + : openWithReadyStructuredAgentSessionChild(context, params.envelope, selection)(ledger) + ), { - ...plan, - get conversationWrite() { - return atRest() ? (true as const) : undefined + needsAgent: () => { + const child = context.sessions.get(params.envelope.sessionId)?.child + return !recordOnly && child !== undefined && !child.close }, - run: async (ctx) => { - if (atRest()) { - return recordStructuredAgentSessionOptionIntent(context.deps, ctx, params) - } - const wait = context.acquireAborts.begin(params.envelope.sessionId) - try { - return await performSetOption(ctx, params, wait.signal) - } finally { - wait.end() - } - } - }, - () => (atRest() ? openForWrite : openForProviderWrite)(context, params.envelope)() + runAfterAgentEnds: true + } ) } 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 30ae0f475cb7..ef7d3bfa7d7c 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,47 +25,6 @@ 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', permissionMode: 'ask' }) - 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 }) => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts index de8d13fe7335..b669b0bff5fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -1,6 +1,4 @@ -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { expect, it, vi } from 'vitest' -import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' import type { StructuredAgentSessionProviderOpen, StructuredAgentSessionProviderOpenResult @@ -8,7 +6,6 @@ import type { import { CALLER, attach, - attachParams, envelope, hostTestState } from './structured-agent-session-host-test-harness' @@ -44,206 +41,6 @@ function awaitOpen() { return { ...state, open, close, pending, send } } -it('accepts messages and reads while one host-awaited open holds delivery', async () => { - const { host, open, dispatch, pending, send } = awaitOpen() - await attach() - expect(open).toHaveBeenCalledTimes(1) - expect(await send('first')).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - expect(await send('second')).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(2) - expect(dispatch).not.toHaveBeenCalled() - - pending.resolve(OPENED) - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2)) - expect(open).toHaveBeenCalledTimes(1) -}) - -it('closes a refused open once and records one provider failure for all held messages', async () => { - const { host, open, close, dispatch, pending, send } = awaitOpen() - await attach() - await send('first') - await send('second') - pending.reject(new AgentSessionAcquisitionRefusal('Sign in to the provider.', 'notSignedIn')) - - await vi.waitFor(() => expect(close).toHaveBeenCalledTimes(1)) - const snapshot = await host.journalSnapshot(SESSION) - expect(snapshot.submissions).toHaveLength(2) - expect(snapshot.submissions.every((entry) => entry.dispatchState === 'rejected')).toBe(true) - expect(snapshot.submissions.every((entry) => entry.rejection?.kind === 'notSignedIn')).toBe(true) - expect( - snapshot.items.filter( - (item) => item.body.kind === 'status' && item.body.failure?.kind === 'notSignedIn' - ) - ).toHaveLength(1) - expect(open).toHaveBeenCalledTimes(1) - expect(dispatch).not.toHaveBeenCalled() -}) - -it('records a refused owned open even before a message is accepted', async () => { - const { host, close, pending, dispatch } = awaitOpen() - await attach() - pending.reject(new AgentSessionAcquisitionRefusal('Sign in to the provider.', 'notSignedIn')) - await vi.waitFor(async () => { - const snapshot = await host.journalSnapshot(SESSION) - expect(snapshot.submissions).toEqual([]) - expect( - snapshot.items.filter( - (item) => item.body.kind === 'status' && item.body.failure?.kind === 'notSignedIn' - ) - ).toHaveLength(1) - }) - expect(close).toHaveBeenCalledTimes(1) - expect(dispatch).not.toHaveBeenCalled() -}) - -it('closes a child while its open is pending and refuses its late readiness result', async () => { - const { host, open, close, dispatch, pending, send } = awaitOpen() - await attach() - await send('held') - await host.close(SESSION, 'user-close') - expect(close).toHaveBeenCalledTimes(1) - expect(open.mock.calls[0]?.[0].signal.aborted).toBe(true) - - pending.resolve(OPENED) - await host.flushAllStreamedEvents() - expect(dispatch).not.toHaveBeenCalled() - expect(close).toHaveBeenCalledTimes(1) -}) - -it('ends the host open wait when the matching child exits without answering', async () => { - const { host, open, dispatch, send } = awaitOpen() - const attached = await attach() - if (!attached) { - throw new Error('The spawned child has no owner record.') - } - await send('held') - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - fence: attached.lease.runtimeFence, - acquisitionGeneration: 'awaited-open', - reason: 'The provider exited before answering.', - cause: 'unexpected-exit' - }) - expect(open.mock.calls[0]?.[0].signal.aborted).toBe(true) - await vi.waitFor(async () => { - const snapshot = await host.journalSnapshot(SESSION) - expect(snapshot.submissions[0]?.dispatchState).toBe('rejected') - }) - expect(dispatch).not.toHaveBeenCalled() -}) - -it.each(['close', 'quit'] as const)( - 'cancels the open on %s after a second client attach is refused', - async (ending) => { - const { host, open, close, dispatch, pending, send } = awaitOpen() - await attach() - await send('held') - expect(await host.attach({ callerKey: 'second-client' }, attachParams())).toMatchObject({ - ok: false - }) - const ended = - ending === 'quit' - ? host.flushAllStreamedEvents({ trigger: 'quit' }) - : host.close(SESSION, 'user-close') - try { - await vi.waitFor(() => expect(open.mock.calls[0]?.[0].signal.aborted).toBe(true)) - await ended - expect(close).toHaveBeenCalledTimes(1) - expect(dispatch).not.toHaveBeenCalled() - } finally { - pending.resolve(OPENED) - await ended - } - } -) - -it('runs a goal requested during opening before a message sent later', async () => { - const { host, pending, dispatch, send } = awaitOpen() - await attach() - const order: string[] = [] - host.deps.adapter.changeThreadGoal = vi.fn(async () => { - order.push('goal') - return { ok: true as const } - }) - const originalDispatch = dispatch.getMockImplementation() - dispatch.mockImplementation(async (input) => { - order.push('send') - if (!originalDispatch) { - throw new Error('Missing dispatch implementation') - } - return originalDispatch(input) - }) - const change = { kind: 'set' as const, objective: 'Requested before the message' } - const goal = host.changeThreadGoal(CALLER, { - envelope: envelope('agentSession.threadGoal', { change }), - change - }) - await host.collaboratorsForTests().serialize(SESSION, async () => {}) - const later = send('later message') - let accepted = false - void later.then(() => { - accepted = true - }) - try { - await vi.waitFor(() => expect(accepted).toBe(true)) - expect(dispatch).not.toHaveBeenCalled() - } finally { - pending.resolve(OPENED) - } - expect(await goal).toMatchObject({ ok: true }) - expect(await later).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(order).toHaveLength(2)) - expect(order).toEqual(['goal', 'send']) -}) - -it.each(['stop', 'close'] as const)( - 'cancels a startup rewind history read on %s and ignores its late result', - async (ending) => { - const { host, store, pending, close, dispatch } = awaitOpen() - await attach() - const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch - await store.transitionHandoff(SESSION, (record) => ({ - ...record, - rewind: { - operationId: envelope('agentSession.rewind', {}).clientOperationId, - callerKey: CALLER.callerKey, - itemId: 'codex:thread-1:turn-1:1', - providerItemId: 'codex:thread-1:turn-1:1', - expectedEpoch, - phase: 'prepared', - retained: [] - } - })) - const recovery = Promise.withResolvers<{ ok: true; items: [] }>() - const recover = vi.fn(() => recovery.promise) - host.deps.adapter.recoverRewind = recover - pending.resolve(OPENED) - await vi.waitFor(() => expect(recover).toHaveBeenCalledOnce()) - const ended = - ending === 'close' - ? host.close(SESSION, 'user-close') - : host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) - try { - await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()) - await ended - expect(store.getRecord(SESSION)?.rewind?.phase).toBe('prepared') - } finally { - recovery.resolve({ ok: true, items: [] }) - await ended - await host.flushAllStreamedEvents() - } - expect(dispatch).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.rewind?.phase).toBe('prepared') - } -) - it('writes both startup and running picks to the live child', async () => { const { host, store, pending, setOption } = awaitOpen() await attach() @@ -264,75 +61,6 @@ it('writes both startup and running picks to the live child', async () => { expect(store.getRecord(SESSION)?.options?.model).toBe('model-after-ready') }) -it.each([false, true])( - 'delivers a later message after a startup rewind (success: %s)', - async (succeeds) => { - const { host, open, dispatch, pending, send } = awaitOpen() - await attach() - pending.resolve(OPENED) - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await send('previous turn') - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - await host.flushStreamedEvents(SESSION) - const previous = (await host.journalSnapshot(SESSION)).submissions[0] - if (!previous) { - throw new Error('Missing previous turn') - } - await host.close(SESSION, 'evict') - const reopened = Promise.withResolvers() - open.mockImplementationOnce(() => reopened.promise) - await host.attach(CALLER, attachParams()) - const order: string[] = [] - const originalDispatch = dispatch.getMockImplementation() - dispatch.mockImplementation(async (input) => { - order.push('send') - if (!originalDispatch) { - throw new Error('Missing dispatch implementation') - } - return originalDispatch(input) - }) - host.deps.adapter.rewindSupport = () => ({ supported: true }) - host.deps.adapter.rewind = vi.fn(async () => { - order.push('rewind') - return succeeds - ? { ok: true as const, items: [] } - : { ok: false as const, reason: 'history-not-paginated' as const } - }) - const itemId = agentJournalSubmissionKey(previous.clientMessageId) - const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch - const rewind = host.rewind(CALLER, { - envelope: envelope('agentSession.rewind', { itemId, expectedEpoch }), - itemId, - expectedEpoch - }) - await host.collaboratorsForTests().serialize(SESSION, async () => {}) - const later = send('later message') - let accepted = false - void later.then(() => { - accepted = true - }) - try { - await vi.waitFor(() => expect(accepted).toBe(true)) - expect(order).toEqual([]) - } finally { - reopened.resolve(OPENED) - } - expect(await rewind).toMatchObject( - succeeds - ? { ok: true } - : { - ok: false, - refusal: { rewindReason: 'history-not-paginated' } - } - ) - expect(await later).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(order).toHaveLength(2)) - expect(order).toEqual(['rewind', 'send']) - } -) - it('delivers two earlier sends before a later goal, then the send after that goal', async () => { const { host, pending, dispatch, send } = awaitOpen() await attach() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts index cbf4f840191f..f293632cecfb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts @@ -56,58 +56,3 @@ it.each(['send', 'compact'] as const)( expect(providerWrites).toHaveBeenCalledTimes(1) } ) - -it.each(['goal', 'rewind'] as const)( - 'runs a %s on a ready child before a later send is accepted', - async (operation) => { - const { host, dispatch } = hostTestState() - await attach() - const body = hostTestMessage('first turn') - const first = { envelope: envelope('agentSession.send', { body }), body } - expect(await host.send(CALLER, first)).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) - await host.flushStreamedEvents(SESSION) - const order: string[] = [] - host.deps.adapter.changeThreadGoal = vi.fn(async () => { - order.push('goal') - return { ok: true as const } - }) - host.deps.adapter.rewindSupport = () => ({ supported: true }) - host.deps.adapter.rewind = vi.fn(async () => { - order.push('rewind') - return { ok: false as const, reason: 'history-not-paginated' as const } - }) - const itemId = agentJournalSubmissionKey(first.envelope.clientOperationId) - const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch - const change = { kind: 'set' as const, objective: 'Apply before the message' } - const blocked = Promise.withResolvers() - void host.collaboratorsForTests().serialize(SESSION, () => blocked.promise) - const requested = - operation === 'goal' - ? host.changeThreadGoal(CALLER, { - envelope: envelope('agentSession.threadGoal', { change }), - change - }) - : host.rewind(CALLER, { - envelope: envelope('agentSession.rewind', { itemId, expectedEpoch }), - itemId, - expectedEpoch - }) - const laterBody = hostTestMessage('later message') - const sent = host.send(CALLER, { - envelope: envelope('agentSession.send', { body: laterBody }), - body: laterBody, - beforeRun: () => order.push('send') - }) - blocked.resolve() - - const result = await requested - expect(result).toMatchObject( - operation === 'goal' - ? { ok: true } - : { ok: false, refusal: { rewindReason: 'history-not-paginated' } } - ) - expect(await sent).toMatchObject({ ok: true }) - expect(order).toEqual([operation, 'send']) - } -) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts index 39a663aa299a..de87449c26dd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts @@ -91,6 +91,7 @@ export async function runWithReadyStructuredAgentSessionOperation( record: AgentSessionRecord | null ) => AgentSessionMutationSessionPreparation | null needsAgent?: (record: AgentSessionRecord | null) => boolean + runAfterAgentEnds?: true } = {} ): Promise> { const hostFingerprint = computeAgentSessionPayloadFingerprint({ @@ -179,7 +180,11 @@ export async function runWithReadyStructuredAgentSessionOperation( return { ok: true as const, child: identity, deadline } } const readiness = context.startupAttempts.readiness(envelope.sessionId, identity) - return readiness ? { ok: true as const, child: identity, readiness } : operationDidNotRun() + return readiness + ? { ok: true as const, child: identity, readiness } + : options.runAfterAgentEnds + ? { ok: true as const, child: identity } + : operationDidNotRun() } const child = await select().catch((error: unknown) => { context.deps.logger.warn('selecting an agent for an operation failed', { @@ -218,7 +223,7 @@ export async function runWithReadyStructuredAgentSessionOperation( ), deadline - Date.now() ) - if (outcome !== 'ready') { + if (outcome !== 'ready' && !(outcome === 'ended' && options.runAfterAgentEnds)) { return operationDidNotRun(outcome === 'timeout') } if (Date.now() >= deadline) { 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 cc71a92590aa..841b35825589 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 @@ -62,55 +62,6 @@ afterEach(() => { }) describe('the startup clock', () => { - it('settles a selected caller with readiness and does not retain a completed attempt', async () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - attempts.published(SESSION, attempt.attemptId, CHILD) - const readiness = attempts.readiness(SESSION, CHILD) - expect(readiness).not.toBeNull() - - const waiting = readiness!.wait(120_000) - attempts.ready(SESSION, CHILD) - - await expect(waiting).resolves.toBe('ready') - expect(attempts.readiness(SESSION, CHILD)).toBeNull() - expect(vi.getTimerCount()).toBe(0) - }) - - it('ends a selected caller on child exit, expiry, or disposal', async () => { - for (const end of ['exit', 'expiry', 'dispose'] as const) { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - attempts.published(SESSION, attempt.attemptId, CHILD) - const waiting = attempts.readiness(SESSION, CHILD)!.wait(120_000) - if (end === 'exit') { - attempts.childEnded(SESSION, CHILD) - } - if (end === 'expiry') { - vi.advanceTimersByTime(SILENCE_MS) - } - if (end === 'dispose') { - attempts.dispose() - } - await expect(waiting).resolves.toBe('ended') - } - }) - - it('ends only the caller wait at its budget, leaving the attempt able to start', async () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - attempts.published(SESSION, attempt.attemptId, CHILD) - const waiting = attempts.readiness(SESSION, CHILD)!.wait(500) - const reattached = attempts.readiness(SESSION, { ...CHILD })!.wait(120_000) - - vi.advanceTimersByTime(500) - await expect(waiting).resolves.toBe('timeout') - expect(attempts.isOpen(SESSION)).toBe(true) - attempts.ready(SESSION, CHILD) - await expect(reattached).resolves.toBe('ready') - expect(attempts.isOpen(SESSION)).toBe(false) - }) - it('does not run before the spawn: resolving a launch is not starting', () => { attempts.track(SESSION, mint()) 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 2cba0bdb2c0f..a76046c028df 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 @@ -10,6 +10,7 @@ import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launc import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { StructuredAgentSessionStartupAttempts } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt' const SESSION = 'claude-startup-unanswered-control' const CALLER = { callerKey: 'client-1' } @@ -266,3 +267,86 @@ describe('a Claude start whose CLI answers initialize but not a control request' expect(claude.children(SESSION)).toHaveLength(1) }) }) + +it('uses model and effort picked during startup in the first reply to a later message', async () => { + claude.behave(SESSION, { initHangs: true, sendsNoStartFrame: true }) + const host = await claude.install() + await host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'opus' } })) + await vi.waitFor(() => expect(claude.child(SESSION).initializing).toBe(true)) + const child = claude.child(SESSION) + let model = 'opus' + let effort = 'high' + child.connection.supportedModels = async () => [ + { + value: 'sonnet', + displayName: 'Sonnet', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + }, + { + value: 'opus', + displayName: 'Opus', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + } + ] + const setModel = child.connection.setModel + child.connection.setModel = async (value, options) => { + await setModel(value, options) + model = value + } + const applyFlags = child.connection.applyFlagSettings + child.connection.applyFlagSettings = async (settings, options) => { + await applyFlags(settings, options) + if (typeof settings.effortLevel === 'string') { + effort = settings.effortLevel + } + } + child.connection.getSettings = async () => ({ effective: { effortLevel: effort } }) + child.connection.send = async () => { + child.calls.push('send') + child.handlers.onMessage?.({ + type: 'assistant', + session_id: claudeSessionIdForOrcaSession(SESSION), + uuid: 'first-reply', + message: { role: 'assistant', model, content: [{ type: 'text', text: effort }] } + }) + } + // Let delivery wake before the callers waiting on the same ready child resume. + const readiness = StructuredAgentSessionStartupAttempts.prototype.readiness + const delayed = vi + .spyOn(StructuredAgentSessionStartupAttempts.prototype, 'readiness') + .mockImplementation(function (sessionId, identity) { + const selected = readiness.call(this, sessionId, identity) + return selected + ? { + wait: async (budget) => { + const outcome = await selected.wait(budget) + await new Promise((resolve) => setTimeout(resolve, 0)) + return outcome + } + } + : null + }) + const pickedModel = setOption(host, 'model', 'sonnet') + const pickedEffort = setOption(host, 'effort', 'low') + await send(host, 'reply using these choices') + expect(child.calls).not.toContain('send') + child.answerInit() + await Promise.all([pickedModel, pickedEffort]) + await vi.waitFor(async () => { + const reply = (await host.journalSnapshot(SESSION)).items.find( + (item) => item.body.kind === 'message' && item.body.role === 'assistant' + ) + expect(reply?.body).toMatchObject({ + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'low' }] + }) + }) + expect(child.calls.indexOf('set_model')).toBeLessThan(child.calls.indexOf('send')) + expect(child.calls.indexOf('apply_flag_settings')).toBeLessThan(child.calls.indexOf('send')) + expect((await picker(host)).model).toBe('sonnet') + expect(record(host)?.options).toMatchObject({ model: 'sonnet', effort: 'low' }) + delayed.mockRestore() +}) 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 1b95d7b9a122..d470b723c0ba 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,15 +51,7 @@ 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 { - 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 { resetStructuredAgentLaunchRegistryForTests } from '@/lib/structured-agent-session-launch-registry' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' import { useStructuredAgentSession } from './use-structured-agent-session' import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' @@ -169,31 +161,6 @@ describe('a chat pane over its own launch', () => { ) }) - it('derives launch picks while held and drops them once the host owns the choice', 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).toBeUndefined() - expect(result.current.launch?.heldOptions).toEqual({}) - }) - 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-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 deleted file mode 100644 index babb7b3277cd..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.start-keeps-picks.test.tsx +++ /dev/null @@ -1,148 +0,0 @@ -// @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() - }) -} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx index 1f3089fa26ce..b6c2074e51d3 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx @@ -76,7 +76,7 @@ type RenderProps = { launchSeedOptions?: Record heldOptions?: Record worktree?: string - agent?: 'claude' | 'codex' + agent?: 'claude' | 'codex' | 'pi' | 'grok' providerStarting?: boolean paired?: boolean } @@ -630,3 +630,46 @@ describe('useStructuredAgentSessionOptions', () => { } ) }) + +it.each(['claude', 'codex', 'pi', 'grok'] as const)( + 'accepts both startup picks in a %s chat and keeps showing them across a new fence', + async (agent) => { + const models = ['opus', 'sonnet'].map((id) => ({ + id, + label: id, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + })) + let saved = { model: 'opus', effort: 'high' } + answer({ + modelCatalog: async () => ({ origin: 'live-session', models, fetchedAt: 1_000 }), + options: async () => ({ models, current: saved }) + }) + const pending = Promise.withResolvers() + const { mutate, calls } = mutateWith(() => pending.promise) + const starting = { ...ATTACHED, agent, providerStarting: true, launchSeedOptions: saved } + const { result, rerender, unmount } = renderOptions(starting, mutate) + await waitFor(() => expect(modelChoiceCount(result.current.optionSnapshot)).toBe(2)) + act(() => { + void result.current.setStructuredOption('model', 'sonnet') + void result.current.setStructuredOption('effort', 'low') + }) + expect(setOptionCalls(calls)).toEqual([ + { key: 'model', value: 'sonnet' }, + { key: 'effort', value: 'low' } + ]) + expect(currentValue(result.current.optionSnapshot, 'model')).toBe('sonnet') + expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('low') + rerender({ ...starting, fence: 2 }) + expect(currentValue(result.current.optionSnapshot, 'model')).toBe('sonnet') + expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('low') + saved = { model: 'sonnet', effort: 'low' } + await act(async () => pending.resolve({ key: 'effort', value: 'low', options: saved })) + rerender({ ...starting, fence: 2, providerStarting: false }) + await waitFor(() => expect(currentValue(result.current.optionSnapshot, 'model')).toBe('sonnet')) + expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('low') + unmount() + } +) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index c2021a3008ff..b826462c04a0 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -62,6 +62,7 @@ export function useStructuredAgentSessionOptions(args: { transportEnabled, turnId } = args + const providerStarting = args.providerStarting ?? false const launchSeedOptions = launch?.seedOptions const held = launch?.heldOptions ?? NO_HELD_OPTIONS // Published but not attached: the launch no longer holds picks and there is no fence to send one. @@ -88,7 +89,7 @@ export function useStructuredAgentSessionOptions(args: { ...(launch?.worktree ? { worktree: launch.worktree } : {}), seedsModel: launchSeedOptions?.model !== undefined, providerVisible, - providerStarting: args.providerStarting ?? false, + providerStarting, hostOptionStamp: args.hostOptionStamp ?? '', // A new chat's host knows nothing of its model before the provider starts; a resumed one's // holds the model it ran, so only the former waits rather than show the host's guess. @@ -137,7 +138,7 @@ export function useStructuredAgentSessionOptions(args: { activeOptionRecordRef.current === targetRecord && optionMutationGeneration.current === mutationGeneration // Record-only: shown at once, so the picker stays usable while the write waits behind a start. - const shownAtOnce = id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID + const shownAtOnce = providerStarting || id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID if (shownAtOnce) { updateOptionState((current) => current.record === targetRecord @@ -206,6 +207,7 @@ export function useStructuredAgentSessionOptions(args: { optionMutationGeneration, optionStateRef, pendingOptionRef, + providerStarting, rememberOptionPicks, sessionId, target, @@ -238,11 +240,12 @@ export function useStructuredAgentSessionOptions(args: { const setStructuredOption = useCallback( async (id: string, value: string | boolean): Promise => { const view = structuredAgentSessionOptionView(optionStateRef.current, launchSeedOptions, held) + const pickState = { ...view, pendingId: providerStarting ? null : pendingOptionRef.current } const encoded = encodeStructuredAgentSessionOptionValue(id, value) if ( !optionCatalog || encoded === null || - !canSetStructuredAgentSessionOption(view, id, value) + !canSetStructuredAgentSessionOption(pickState, id, value) ) { return false } @@ -252,9 +255,6 @@ export function useStructuredAgentSessionOptions(args: { void applied?.then(settleLaunchOptionPick) return applied !== null } - if (pendingOptionRef.current !== null) { - return false - } return sendStructuredOption(id, encoded) }, [ @@ -264,6 +264,7 @@ export function useStructuredAgentSessionOptions(args: { optionCatalog, optionStateRef, pendingOptionRef, + providerStarting, sendStructuredOption, sessionId, settleLaunchOptionPick, From 29c4a81addf5e35ad9e95b4e6fcc00807479d67e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:49:43 -0700 Subject: [PATCH 43/55] Keep reviewed lifecycle contracts while pruning duplicate tests --- ...gent-model-catalog-saved-only-read.test.ts | 3 -- ...ctured-agent-session-provider-open.test.ts | 40 +++++++------- ...gent-session-ready-operation-order.test.ts | 54 +++++++++++++++++++ .../orchestration/send-agent-turn.test.ts | 11 ---- 4 files changed, 74 insertions(+), 34 deletions(-) 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 a481c6632ac4..bd40aa9690ed 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 @@ -80,9 +80,6 @@ describe('a saved-only catalog read', () => { 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({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts index b669b0bff5fb..235dd1761cee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -6,6 +6,7 @@ import type { import { CALLER, attach, + attachParams, envelope, hostTestState } from './structured-agent-session-host-test-harness' @@ -41,26 +42,6 @@ function awaitOpen() { return { ...state, open, close, pending, send } } -it('writes both startup and running picks to the live child', async () => { - const { host, store, pending, setOption } = awaitOpen() - await attach() - for (const model of ['model-before-ready', 'model-after-ready']) { - const picked = host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', { key: 'model', value: model }), - key: 'model', - value: model - }) - pending.resolve(OPENED) - expect(await picked).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options?.model).toBe(model) - expect(setOption).toHaveBeenCalledTimes(model === 'model-before-ready' ? 1 : 2) - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - } - expect(store.getRecord(SESSION)?.options?.model).toBe('model-after-ready') -}) - it('delivers two earlier sends before a later goal, then the send after that goal', async () => { const { host, pending, dispatch, send } = awaitOpen() await attach() @@ -93,3 +74,22 @@ it('delivers two earlier sends before a later goal, then the send after that goa await vi.waitFor(() => expect(order).toHaveLength(4)) expect(order).toEqual(['first', 'second', 'goal', 'third']) }) + +it('cancels the open on quit after a second client attach is refused', async () => { + const { host, open, close, dispatch, pending, send } = awaitOpen() + await attach() + await send('held') + expect(await host.attach({ callerKey: 'second-client' }, attachParams())).toMatchObject({ + ok: false + }) + const ended = host.flushAllStreamedEvents({ trigger: 'quit' }) + try { + await vi.waitFor(() => expect(open.mock.calls[0]?.[0].signal.aborted).toBe(true)) + await ended + expect(close).toHaveBeenCalledTimes(1) + expect(dispatch).not.toHaveBeenCalled() + } finally { + pending.resolve(OPENED) + await ended + } +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts index f293632cecfb..4ac8944ff497 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts @@ -56,3 +56,57 @@ it.each(['send', 'compact'] as const)( expect(providerWrites).toHaveBeenCalledTimes(1) } ) +it.each(['goal', 'rewind'] as const)( + 'runs a %s on a ready child before a later send is accepted', + async (operation) => { + const { host, dispatch } = hostTestState() + await attach() + const body = hostTestMessage('first turn') + const first = { envelope: envelope('agentSession.send', { body }), body } + expect(await host.send(CALLER, first)).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) + await host.flushStreamedEvents(SESSION) + const order: string[] = [] + host.deps.adapter.changeThreadGoal = vi.fn(async () => { + order.push('goal') + return { ok: true as const } + }) + host.deps.adapter.rewindSupport = () => ({ supported: true }) + host.deps.adapter.rewind = vi.fn(async () => { + order.push('rewind') + return { ok: false as const, reason: 'history-not-paginated' as const } + }) + const itemId = agentJournalSubmissionKey(first.envelope.clientOperationId) + const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch + const change = { kind: 'set' as const, objective: 'Apply before the message' } + const blocked = Promise.withResolvers() + void host.collaboratorsForTests().serialize(SESSION, () => blocked.promise) + const requested = + operation === 'goal' + ? host.changeThreadGoal(CALLER, { + envelope: envelope('agentSession.threadGoal', { change }), + change + }) + : host.rewind(CALLER, { + envelope: envelope('agentSession.rewind', { itemId, expectedEpoch }), + itemId, + expectedEpoch + }) + const laterBody = hostTestMessage('later message') + const sent = host.send(CALLER, { + envelope: envelope('agentSession.send', { body: laterBody }), + body: laterBody, + beforeRun: () => order.push('send') + }) + blocked.resolve() + + const result = await requested + expect(result).toMatchObject( + operation === 'goal' + ? { ok: true } + : { ok: false, refusal: { rewindReason: 'history-not-paginated' } } + ) + expect(await sent).toMatchObject({ ok: true }) + expect(order).toEqual([operation, 'send']) + } +) diff --git a/src/main/runtime/orchestration/send-agent-turn.test.ts b/src/main/runtime/orchestration/send-agent-turn.test.ts index bc67c7a31779..bd90030a8bc4 100644 --- a/src/main/runtime/orchestration/send-agent-turn.test.ts +++ b/src/main/runtime/orchestration/send-agent-turn.test.ts @@ -135,17 +135,6 @@ 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( From ad1e0d8aad78ce85d42611b453de3c92e712496b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:53:31 -0700 Subject: [PATCH 44/55] Save picks after a proven exit without requiring a child --- ...structured-agent-session-host-mutations.ts | 2 +- ...ctured-agent-session-provider-open.test.ts | 38 +------------------ 2 files changed, 2 insertions(+), 38 deletions(-) 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 5327b0495a63..abf82c9e604b 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 @@ -240,7 +240,7 @@ export async function setStructuredAgentSessionOption( { needsAgent: () => { const child = context.sessions.get(params.envelope.sessionId)?.child - return !recordOnly && child !== undefined && !child.close + return !recordOnly && Boolean(child && !child.close) }, runAfterAgentEnds: true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts index 235dd1761cee..fbc8ba797193 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -10,10 +10,7 @@ import { envelope, hostTestState } from './structured-agent-session-host-test-harness' -import { - HOST_TEST_SESSION as SESSION, - hostTestMessage -} from './structured-agent-session-host-test-data' +import { hostTestMessage } from './structured-agent-session-host-test-data' const OPENED: StructuredAgentSessionProviderOpenResult = { reportedOptions: { model: 'gpt-test' } @@ -42,39 +39,6 @@ function awaitOpen() { return { ...state, open, close, pending, send } } -it('delivers two earlier sends before a later goal, then the send after that goal', async () => { - const { host, pending, dispatch, send } = awaitOpen() - await attach() - const order: string[] = [] - const originalDispatch = dispatch.getMockImplementation() - if (!originalDispatch) { - throw new Error('Missing dispatch implementation') - } - dispatch.mockImplementation(async (input) => { - const { body } = input - order.push(body.blocks[0]?.type === 'text' ? body.blocks[0].text : 'send') - return originalDispatch(input) - }) - host.deps.adapter.changeThreadGoal = vi.fn(async () => { - order.push('goal') - return { ok: true as const } - }) - await send('first') - await send('second') - const change = { kind: 'set' as const, objective: 'After the first two messages' } - const goal = host.changeThreadGoal(CALLER, { - envelope: envelope('agentSession.threadGoal', { change }), - change - }) - await host.collaboratorsForTests().serialize(SESSION, async () => {}) - const later = send('third') - pending.resolve(OPENED) - expect(await goal).toMatchObject({ ok: true }) - expect(await later).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(order).toHaveLength(4)) - expect(order).toEqual(['first', 'second', 'goal', 'third']) -}) - it('cancels the open on quit after a second client attach is refused', async () => { const { host, open, close, dispatch, pending, send } = awaitOpen() await attach() From 776a0dbb652aa25cd9f6c3b41a3b67f40ae989a1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:57:25 -0700 Subject: [PATCH 45/55] Type the startup reply fixture against the Claude control contract --- ...-structured-startup-unanswered-control-request.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 a76046c028df..84822776b8a1 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 @@ -293,7 +293,7 @@ it('uses model and effort picked during startup in the first reply to a later me const setModel = child.connection.setModel child.connection.setModel = async (value, options) => { await setModel(value, options) - model = value + model = value ?? 'opus' } const applyFlags = child.connection.applyFlagSettings child.connection.applyFlagSettings = async (settings, options) => { @@ -316,7 +316,11 @@ it('uses model and effort picked during startup in the first reply to a later me const readiness = StructuredAgentSessionStartupAttempts.prototype.readiness const delayed = vi .spyOn(StructuredAgentSessionStartupAttempts.prototype, 'readiness') - .mockImplementation(function (sessionId, identity) { + .mockImplementation(function ( + this: StructuredAgentSessionStartupAttempts, + sessionId, + identity + ) { const selected = readiness.call(this, sessionId, identity) return selected ? { From f23de3025fdcca1ef638c0b0a6ab7af947f8ffe7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:05:17 -0700 Subject: [PATCH 46/55] Keep Codex startup flow contracts and remove overlapping adapter tests --- pnpm-lock.yaml | 25 +++ ...codex-app-server-spawn-publication.test.ts | 40 ---- .../codex-structured-publish-at-spawn.test.ts | 194 ------------------ .../codex-structured-session-adapter.test.ts | 133 ------------ ...agent-session-codex-scripted-child.test.ts | 11 +- ...agent-session-codex-start-deadline.test.ts | 14 -- 6 files changed, 32 insertions(+), 385 deletions(-) delete mode 100644 src/main/codex/codex-app-server-spawn-publication.test.ts delete mode 100644 src/main/codex/codex-structured-publish-at-spawn.test.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8b7d4f438ae..fccec4f297fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ importers: .: configDependencies: {} packageManagerDependencies: + '@pnpm/exe': + specifier: 12.8.1 + version: 12.8.1 pnpm: specifier: 12.8.1 version: 12.8.1 @@ -89,6 +92,11 @@ packages: cpu: [x64] os: [win32] + '@pnpm/exe@12.8.1': + resolution: {integrity: sha512-ju5MeefhEEXI7NO3u6oj54uGBOkN0pVhI+tcmuZDkTTqVdRZvEVEo+zhjwAoe8vEXguQCvsa6cTDhmFPxpOXeQ==} + engines: {node: '>=18.*'} + hasBin: true + pnpm@12.8.1: resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} engines: {node: '>=18.*'} @@ -138,6 +146,23 @@ snapshots: '@pnpm/exe.win32-x64@12.8.1': optional: true + '@pnpm/exe@12.8.1': + optionalDependencies: + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 + pnpm@12.8.1: optionalDependencies: '@pnpm/exe.android-arm64': 12.8.1 diff --git a/src/main/codex/codex-app-server-spawn-publication.test.ts b/src/main/codex/codex-app-server-spawn-publication.test.ts deleted file mode 100644 index 639692cad2c9..000000000000 --- a/src/main/codex/codex-app-server-spawn-publication.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { EventEmitter } from 'node:events' -import { PassThrough } from 'node:stream' -import { expect, it, vi } from 'vitest' -import type { PipedProcessSpawner } from '@orca/process-host/process-spec' -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 PipedProcessSpawner - 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-structured-publish-at-spawn.test.ts b/src/main/codex/codex-structured-publish-at-spawn.test.ts deleted file mode 100644 index 581092306e51..000000000000 --- a/src/main/codex/codex-structured-publish-at-spawn.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -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 { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -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 } -} - -async function open( - adapter: ReturnType, - acquisitionGeneration: string | undefined, - signal = new AbortController().signal -) { - return adapter.open({ - sessionId: 'session-1', - fence: 7, - acquisitionGeneration: acquisitionGeneration ?? null, - signal - }) -} - -describe('Codex host-awaited open', () => { - it('publishes its process before initialize and returns protocol facts only after thread/start', async () => { - const initialized = Promise.withResolvers() - const threaded = Promise.withResolvers() - const { adapter, codex, events } = startup({ - initialize: () => initialized.promise, - 'thread/start': () => threaded.promise - }) - const committed = Promise.withResolvers() - const onSpawned = vi.fn(async () => committed.promise) - const acquiring = adapter.acquire({ ...INPUT, onSpawned, options: { model: 'gpt-saved' } }) - await vi.waitFor(() => expect(onSpawned).toHaveBeenCalledOnce()) - expect(codex.connections[0].calls).toEqual([]) - committed.resolve() - const acquired = await acquiring - expect(acquired.process.pid).toBe(4321) - expect(acquired.link).toBeUndefined() - expect(codex.connections[0].calls).toEqual([]) - const opening = open(adapter, acquired.acquisitionGeneration) - await vi.waitFor(() => expect(codex.connections[0].calls[0]?.method).toBe('initialize')) - expect(events).toEqual([]) - initialized.resolve({}) - await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('thread/start')) - expect(events).toEqual([]) - threaded.resolve({ thread: { id: THREAD_ID }, model: 'gpt-live' }) - expect(await opening).toMatchObject({ - link: { handle: { nativeId: THREAD_ID } }, - reportedOptions: { model: 'gpt-saved' } - }) - expect(events.some((event) => event.type === 'started')).toBe(false) - await adapter.closeAll() - }) - - it.each(['initialize', 'thread/start'])( - 'rejects a failed %s without closing or settling it inside the adapter', - async (method) => { - const gate = Promise.withResolvers() - const { adapter, codex, events } = startup({ [method]: () => gate.promise }) - const acquired = await adapter.acquire(INPUT) - const opening = open(adapter, acquired.acquisitionGeneration) - 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 expect(opening).rejects.toThrow('not signed in') - expect(codex.connections[0].closeCount).toBe(0) - expect(events).toEqual([]) - expect(await adapter.closeSession('session-1')).toBe(true) - expect(codex.connections[0].closeCount).toBe(1) - expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) - } - ) - - it.each(['initialize', 'thread/start'])( - 'aborts a held %s on Stop with one close and no later ready result', - async (method) => { - const gate = Promise.withResolvers() - const { adapter, codex } = startup({ [method]: () => gate.promise }) - const acquired = await adapter.acquire(INPUT) - const controller = new AbortController() - const opening = open(adapter, acquired.acquisitionGeneration, controller.signal) - await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe(method)) - controller.abort(new Error('stopped')) - await expect(opening).rejects.toThrow('stopped') - expect(await adapter.closeSession('session-1')).toBe(true) - gate.resolve(method === 'thread/start' ? { thread: { id: THREAD_ID } } : {}) - await new Promise((resolve) => setImmediate(resolve)) - expect(codex.connections[0].closeCount).toBe(1) - expect(codex.connections[0].calls.some((call) => call.method === 'turn/start')).toBe(false) - } - ) - - it('returns a resumed link only after thread/resume answers', async () => { - const gate = Promise.withResolvers() - const codex = fakeCodex({ 'thread/resume': () => gate.promise }) - const adapter = adapterFor(codex, { resumeThreadId: THREAD_ID }) - const acquired = await adapter.acquire(INPUT) - const opening = open(adapter, acquired.acquisitionGeneration) - await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('thread/resume')) - gate.resolve({ thread: { id: THREAD_ID } }) - expect(await opening).toMatchObject({ link: { origin: 'resumed' } }) - await adapter.closeAll() - }) - - it('does not start a fallback thread after Stop during a held resume', async () => { - const gate = Promise.withResolvers() - const codex = fakeCodex({ 'thread/resume': () => gate.promise }) - const adapter = adapterFor(codex, { - resumeThreadId: THREAD_ID, - supersedeIfUnsaved: true - }) - const acquired = await adapter.acquire(INPUT) - const controller = new AbortController() - const opening = open(adapter, acquired.acquisitionGeneration, controller.signal) - await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('thread/resume')) - controller.abort(new Error('stopped')) - await expect(opening).rejects.toThrow('stopped') - gate.reject( - new CodexAppServerRequestError( - 'thread/resume', - -32600, - `no rollout found for thread id ${THREAD_ID}` - ) - ) - await new Promise((resolve) => setImmediate(resolve)) - expect(codex.connections[0].calls.some((call) => call.method === 'thread/start')).toBe(false) - expect(await adapter.closeSession('session-1')).toBe(true) - }) - - it('rejects oversized history without importing part of it', async () => { - const { adapter, codex } = 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() - } - const acquired = await adapter.acquire({ ...INPUT, events: sink }) - await expect(open(adapter, acquired.acquisitionGeneration)).rejects.toMatchObject({ - name: AgentSessionAcquisitionRefusal.name, - reason: 'historyTooLarge' - }) - expect(sink.appendItem).not.toHaveBeenCalled() - expect(codex.connections[0].closeCount).toBe(0) - expect(await adapter.closeSession('session-1')).toBe(true) - expect(codex.connections[0].closeCount).toBe(1) - }) - - it('retains natural exit evidence during thread open', async () => { - const gate = Promise.withResolvers() - const { adapter, codex, events } = startup({ 'thread/start': () => gate.promise }) - const acquired = await adapter.acquire(INPUT) - const opening = open(adapter, acquired.acquisitionGeneration) - 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 expect(opening).rejects.toThrow() - expect(events).toContainEqual( - expect.objectContaining({ - type: 'ended', - cause: 'unexpected-exit' - }) - ) - expect(codex.connections[0].closeCount).toBe(0) - }) -}) diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index fcac5dba95db..5c47eed51fb7 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -16,15 +16,12 @@ import { type openCodexAppServerConnection } from './codex-app-server-connection' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env' import { CodexStructuredSessionAdapter, type CodexStructuredLaunch, type CodexStructuredSessionEvent } from './codex-structured-session-adapter' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' -import { createCodexStructuredLaunchResolver } from '../runtime/structured-agent-launch-composition.test-support' -import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture' import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' describe('CodexStructuredSessionAdapter.acquire', () => { @@ -51,19 +48,6 @@ describe('CodexStructuredSessionAdapter.acquire', () => { ]) }) - it('persists saved model and effort as the next turn uses them', async () => { - const codex = fakeCodex() - const adapter = adapterFor(codex) - await acquireReadyCodexForTest(adapter, { - identity: identityFor('session-1'), - fence: 7, - spawnToken: 'spawn-9', - options: { 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' } }) @@ -129,87 +113,6 @@ describe('CodexStructuredSessionAdapter.acquire', () => { expect(codex.connections[0].closeCount).toBe(0) }) - it('starts a new thread and reports the process and link the lease will prove', async () => { - const codex = fakeCodex() - const record = agentSessionRecordFixture({ - sessionId: 'session-1', - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/codex/home' } - }) - // The launch its registration composes: the shared seal names the child, not this adapter. - const adapter = adapterFor(codex, {}, [], { - resolveLaunch: createCodexStructuredLaunchResolver({ - store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, - resolveWorkspacePath: async () => '/work/repo', - resolveCommand: () => 'codex', - resolveLaunchArgs: () => [] - }) - }) - - const acquisition = await acquireReadyCodexForTest(adapter, { - identity: identityFor('session-1'), - fence: 7, - spawnToken: 'spawn-9' - }) - - expect(codex.connections[0].launch.env).toEqual({ - [ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV]: 'spawn-9', - CODEX_HOME: '/codex/home', - ORCA_AGENT_SESSION_ID: 'session-1', - ORCA_STRUCTURED_SESSION: '1', - ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/), - ...(process.platform !== 'win32' - ? { ORCA_CLI_BIN_DIR: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin$/) } - : {}), - ORCA_USER_DATA_PATH: expect.any(String), - // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. - PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) - }) - expect(codex.connections[0].launch.cwd).toBe('/work/repo') - expect(codex.connections[0].calls[1]).toEqual({ - method: 'thread/start', - // A chat with no mode asks, stated outright. - params: { - cwd: '/work/repo', - approvalPolicy: 'on-request', - sandbox: 'workspace-write', - approvalsReviewer: 'user' - } - }) - expect(acquisition.process).toEqual({ - hostId: 'host-1', - pid: 4321, - processStartTimeMs: 1_700_000_000_000, - spawnToken: 'spawn-9' - }) - expect(codexStartedLink(adapter)).toEqual({ - linkId: `codex-7-${THREAD_ID}`, - handle: codexProviderHandle(THREAD_ID), - origin: 'created', - mintedAtFence: 7, - observedAt: 1_700_000_000_500 - }) - expect(acquisition.acquisitionGeneration).toBe('generation-1') - }) - - // A thread opened on Codex's configured default and then given a turn on the chosen model - // reads to Codex as a model switch, and it injects the chosen model's whole prompt again. - it('opens the thread on the model the session chose, not on the configured default', async () => { - const codex = fakeCodex() - const adapter = adapterFor(codex, { model: 'gpt-chosen' }) - - await acquireReadyCodexForTest(adapter, { - identity: identityFor('session-1'), - fence: 7, - spawnToken: 'spawn-9' - }) - - expect(codex.connections[0].calls[1]).toEqual({ - method: 'thread/start', - params: { cwd: '/work/repo', model: 'gpt-chosen' } - }) - }) - it('resumes the thread the durable handle chain names, not the client one', async () => { const codex = fakeCodex() const adapter = adapterFor(codex, { @@ -292,42 +195,6 @@ describe('CodexStructuredSessionAdapter.acquire', () => { expect(codex.connections[0].closeCount).toBe(1) }) - it('rejects a thread Codex never named; the host closes once', async () => { - const codex = fakeCodex({ 'thread/start': () => ({}) }) - const adapter = adapterFor(codex) - const acquired = await adapter.acquire({ - identity: identityFor('session-1'), - fence: 1, - spawnToken: 'spawn-9' - }) - await expect( - adapter.open({ - sessionId: 'session-1', - fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, - signal: new AbortController().signal - }) - ).rejects.toThrow('did not name the thread') - expect(codex.connections[0].closeCount).toBe(0) - expect(await adapter.closeSession('session-1')).toBe(true) - expect(codex.connections[0].closeCount).toBe(1) - }) - - it('closes the previous child before re-acquiring at a new fence', async () => { - const codex = fakeCodex() - const adapter = await acquired(codex) - - await acquireReadyCodexForTest(adapter, { - identity: identityFor('session-1'), - fence: 8, - spawnToken: 'spawn-10' - }) - - expect(codex.connections).toHaveLength(2) - expect(codex.connections[0].closeCount).toBe(1) - expect(codex.connections[1].closeCount).toBe(0) - }) - it('keeps the traffic Codex sends before the session is published', async () => { const codex = fakeCodex() const events: CodexStructuredSessionEvent[] = [] 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 index 7619e7fbf346..f31fdad35f4c 100644 --- a/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts +++ b/src/main/runtime/structured-agent-session-codex-scripted-child.test.ts @@ -70,6 +70,10 @@ it('attaches at spawn and holds sends until the scripted Codex handshake answers }) expect(sent.ok).toBe(true) expect(child.spawns()).toBe(1) + expect(host.deps.store.getRecord(HOST_TEST_SESSION)?.lease.ownerProcess).toMatchObject({ + pid: expect.any(Number), + spawnToken: expect.any(String) + }) expect(child.prompts()).toEqual([]) child.releaseHandshake() await vi.waitFor(() => expect(child.prompts()).toEqual(['held until Codex is ready'])) @@ -113,15 +117,14 @@ it('hands Codex a model and effort picked while it started before the first mess ...fields }) }) + const body = hostTestMessage('first message') + const sent = host.send(caller, { envelope: envelope('agentSession.send', fence, { body }), body }) child.releaseHandshake() expect(await Promise.all(picks)).toEqual([ expect.objectContaining({ ok: true }), expect.objectContaining({ ok: true }) ]) - const body = hostTestMessage('first message') - expect( - (await host.send(caller, { envelope: envelope('agentSession.send', fence, { body }), body })).ok - ).toBe(true) + expect((await sent).ok).toBe(true) await vi.waitFor(() => expect(child.prompts()).toEqual(['first message'])) const turn = child.requests().find(({ method }) => method === 'turn/start') 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 index bc5842fa0e0c..99bf996bc874 100644 --- a/src/main/runtime/structured-agent-session-codex-start-deadline.test.ts +++ b/src/main/runtime/structured-agent-session-codex-start-deadline.test.ts @@ -13,7 +13,6 @@ import { 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 { @@ -102,16 +101,3 @@ it('delivers the held message to a Codex that answers initialize after 20 s', as 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 d7e4f14cbc9726a431259cbd12a87d01213ce831 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:08:03 -0700 Subject: [PATCH 47/55] Restore the committed dependency lockfile after test tooling --- pnpm-lock.yaml | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fccec4f297fc..b8b7d4f438ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,9 +6,6 @@ importers: .: configDependencies: {} packageManagerDependencies: - '@pnpm/exe': - specifier: 12.8.1 - version: 12.8.1 pnpm: specifier: 12.8.1 version: 12.8.1 @@ -92,11 +89,6 @@ packages: cpu: [x64] os: [win32] - '@pnpm/exe@12.8.1': - resolution: {integrity: sha512-ju5MeefhEEXI7NO3u6oj54uGBOkN0pVhI+tcmuZDkTTqVdRZvEVEo+zhjwAoe8vEXguQCvsa6cTDhmFPxpOXeQ==} - engines: {node: '>=18.*'} - hasBin: true - pnpm@12.8.1: resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} engines: {node: '>=18.*'} @@ -146,23 +138,6 @@ snapshots: '@pnpm/exe.win32-x64@12.8.1': optional: true - '@pnpm/exe@12.8.1': - optionalDependencies: - '@pnpm/exe.android-arm64': 12.8.1 - '@pnpm/exe.android-x64': 12.8.1 - '@pnpm/exe.darwin-arm64': 12.8.1 - '@pnpm/exe.darwin-x64': 12.8.1 - '@pnpm/exe.freebsd-x64': 12.8.1 - '@pnpm/exe.linux-arm64': 12.8.1 - '@pnpm/exe.linux-arm64-musl': 12.8.1 - '@pnpm/exe.linux-ppc64': 12.8.1 - '@pnpm/exe.linux-riscv64': 12.8.1 - '@pnpm/exe.linux-s390x': 12.8.1 - '@pnpm/exe.linux-x64': 12.8.1 - '@pnpm/exe.linux-x64-musl': 12.8.1 - '@pnpm/exe.win32-arm64': 12.8.1 - '@pnpm/exe.win32-x64': 12.8.1 - pnpm@12.8.1: optionalDependencies: '@pnpm/exe.android-arm64': 12.8.1 From 70dbe147c70afb5170d41b01dc6e3c3a6c792ee9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:44:48 -0700 Subject: [PATCH 48/55] Honor the before-input callback in the startup reply fixture --- ...laude-structured-startup-unanswered-control-request.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 84822776b8a1..0ffe6492dbe7 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 @@ -303,7 +303,8 @@ it('uses model and effort picked during startup in the first reply to a later me } } child.connection.getSettings = async () => ({ effective: { effortLevel: effort } }) - child.connection.send = async () => { + child.connection.send = async (_message, beforeDispatch) => { + await beforeDispatch?.() child.calls.push('send') child.handlers.onMessage?.({ type: 'assistant', From 85908e2d1f1f4d312e33c798743bc6c2028ef0ba Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:53:16 -0700 Subject: [PATCH 49/55] Keep startup option menus independent and show the saved choice --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + ...uctured-agent-session-options-read.test.ts | 12 +- .../structured-agent-session-options-read.ts | 21 ++- ...startup-unanswered-control-request.test.ts | 96 +----------- .../NativeChatSessionOptionPickers.test.tsx | 146 +++++++++++++++++- .../NativeChatSessionOptionPickers.tsx | 28 +++- .../structured-agent-session-options.ts | 2 +- 7 files changed, 198 insertions(+), 108 deletions(-) diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 3ea9d857ba66..c91dbddfe51b 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -2,6 +2,7 @@ import { SQLITE_DATABASE_RUNTIME_INCLUDE } from './vitest-sqlite-database-runtim // Real SQLite fixtures keep Node publication and close semantics. export const SQLITE_RUNTIME_INCLUDE = [ + 'src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx', ...SQLITE_DATABASE_RUNTIME_INCLUDE, 'src/main/acp/acp-structured-host-live-listing.test.ts', 'src/main/acp/acp-structured-host-configured-default.test.ts', 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 ef7d3bfa7d7c..647c08ca9758 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 @@ -120,13 +120,14 @@ describe('options at rest', () => { }) describe('live Codex option reads', () => { - it('leaves the session lane free while a cold listing waits, then applies it under the child fence', async () => { + it('keeps newer saved picks after a cold live listing waits without holding the session lane', async () => { const listing = Promise.withResolvers() const queue = new StructuredAgentSessionTaskQueue() const child = { fence: 7 } + const record = restingRecord() const apply = vi.fn(() => ({ models: [{ id: 'gpt-live', label: 'GPT Live', efforts: [] }], - current: { model: 'gpt-live' } + current: { model: 'gpt-live', effort: 'high', confirmed: ['model', 'effort'] as const } })) const prepareReadOptions = vi.fn(async () => { await listing.promise @@ -142,7 +143,7 @@ describe('live Codex option reads', () => { deps: { adapter: { prepareReadOptions }, agents: NO_STRUCTURED_AGENTS, - store: { getRecord: () => restingRecord() } + store: { getRecord: () => record } }, serialize: (sessionId: string, task: () => Promise) => queue.serialize(sessionId, task), @@ -158,8 +159,11 @@ describe('live Codex option reads', () => { 'delivery can proceed' ) expect(apply).not.toHaveBeenCalled() + record.options = { model: 'gpt-next', effort: 'low' } listing.resolve() - await expect(reading).resolves.toMatchObject({ current: { model: 'gpt-live' } }) + await expect(reading).resolves.toMatchObject({ + current: { model: 'gpt-next', effort: 'low', confirmed: [] } + }) expect(apply).toHaveBeenCalledOnce() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 765203b69564..d269c2f37d51 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 @@ -236,9 +236,28 @@ export async function readStructuredAgentSessionOptions( const permissionMode = record ? await structuredAgentSessionLaunchPermission(context.deps, record) : null + const saved = record?.options + const selected = + live && (saved?.model !== undefined || saved?.effort !== undefined) + ? await readStructuredAgentSessionOptionsAtRest(context.deps, sessionId, reader) + : options + const current = { + ...options.current, + ...(saved?.model !== undefined ? { model: selected.current.model } : {}), + ...(saved?.effort !== undefined ? { effort: selected.current.effort } : {}) + } + const confirmed = current.confirmed?.filter((key) => + key === 'model' + ? current.model === options.current.model + : key !== 'effort' || current.effort === options.current.effort + ) return { ...options, - ...(permissionMode ? { current: { ...options.current, permissionMode } } : {}), + current: { + ...current, + ...(confirmed ? { confirmed } : {}), + ...(permissionMode ? { permissionMode } : {}) + }, rewind: phase === 'prepared' || phase === 'provider-succeeded' ? { supported: false, reason: 'outcome-unknown' } 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 0ffe6492dbe7..bb958dfa745c 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 @@ -10,7 +10,6 @@ import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launc import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' -import { StructuredAgentSessionStartupAttempts } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt' const SESSION = 'claude-startup-unanswered-control' const CALLER = { callerKey: 'client-1' } @@ -174,13 +173,12 @@ describe('a Claude start whose CLI answers initialize but not a control request' // The picker offers the saved model, which nothing has vouched for yet. expect(await picker(host)).toEqual({ model: 'sonnet', effort: 'high', confirmed: ['effort'] }) - // A turn shows the child running the CLI's own model: the picker follows it, the record - // keeps what the user chose. + // A turn reports its own model; the picker and record keep the next message's saved choice. turnReportsModel('claude-sonnet-5') expect(await picker(host)).toEqual({ - model: 'claude-sonnet-5', + model: 'sonnet', effort: 'high', - confirmed: ['model', 'effort'] + confirmed: ['effort'] }) expect(record(host)?.options).toEqual({ model: 'sonnet' }) @@ -267,91 +265,3 @@ describe('a Claude start whose CLI answers initialize but not a control request' expect(claude.children(SESSION)).toHaveLength(1) }) }) - -it('uses model and effort picked during startup in the first reply to a later message', async () => { - claude.behave(SESSION, { initHangs: true, sendsNoStartFrame: true }) - const host = await claude.install() - await host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'opus' } })) - await vi.waitFor(() => expect(claude.child(SESSION).initializing).toBe(true)) - const child = claude.child(SESSION) - let model = 'opus' - let effort = 'high' - child.connection.supportedModels = async () => [ - { - value: 'sonnet', - displayName: 'Sonnet', - supportsEffort: true, - supportedEffortLevels: ['low', 'high'] - }, - { - value: 'opus', - displayName: 'Opus', - supportsEffort: true, - supportedEffortLevels: ['low', 'high'] - } - ] - const setModel = child.connection.setModel - child.connection.setModel = async (value, options) => { - await setModel(value, options) - model = value ?? 'opus' - } - const applyFlags = child.connection.applyFlagSettings - child.connection.applyFlagSettings = async (settings, options) => { - await applyFlags(settings, options) - if (typeof settings.effortLevel === 'string') { - effort = settings.effortLevel - } - } - child.connection.getSettings = async () => ({ effective: { effortLevel: effort } }) - child.connection.send = async (_message, beforeDispatch) => { - await beforeDispatch?.() - child.calls.push('send') - child.handlers.onMessage?.({ - type: 'assistant', - session_id: claudeSessionIdForOrcaSession(SESSION), - uuid: 'first-reply', - message: { role: 'assistant', model, content: [{ type: 'text', text: effort }] } - }) - } - // Let delivery wake before the callers waiting on the same ready child resume. - const readiness = StructuredAgentSessionStartupAttempts.prototype.readiness - const delayed = vi - .spyOn(StructuredAgentSessionStartupAttempts.prototype, 'readiness') - .mockImplementation(function ( - this: StructuredAgentSessionStartupAttempts, - sessionId, - identity - ) { - const selected = readiness.call(this, sessionId, identity) - return selected - ? { - wait: async (budget) => { - const outcome = await selected.wait(budget) - await new Promise((resolve) => setTimeout(resolve, 0)) - return outcome - } - } - : null - }) - const pickedModel = setOption(host, 'model', 'sonnet') - const pickedEffort = setOption(host, 'effort', 'low') - await send(host, 'reply using these choices') - expect(child.calls).not.toContain('send') - child.answerInit() - await Promise.all([pickedModel, pickedEffort]) - await vi.waitFor(async () => { - const reply = (await host.journalSnapshot(SESSION)).items.find( - (item) => item.body.kind === 'message' && item.body.role === 'assistant' - ) - expect(reply?.body).toMatchObject({ - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'low' }] - }) - }) - expect(child.calls.indexOf('set_model')).toBeLessThan(child.calls.indexOf('send')) - expect(child.calls.indexOf('apply_flag_settings')).toBeLessThan(child.calls.indexOf('send')) - expect((await picker(host)).model).toBe('sonnet') - expect(record(host)?.options).toMatchObject({ model: 'sonnet', effort: 'low' }) - delayed.mockRestore() -}) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index db6b463b5061..f71134e9530e 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import type * as ReactModule from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import { OMP_SESSION_OPTION_CATALOG } from '../../../../shared/agent-session-option-catalog-omp' @@ -12,6 +12,12 @@ import { import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' +import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' +import { claudeSessionIdForOrcaSession } from '../../../../main/claude/claude-structured-launch-resolution' +import { hostTestMessage } from '../../../../main/native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { StructuredAgentSessionStartupAttempts } from '../../../../main/native-chat/agent-session-wire/structured-agent-session-startup-attempt' +import { createScriptedClaudeRuntime } from '../../../../main/runtime/structured-claude-scripted-runtime-test-support' + const toastError = vi.hoisted(() => vi.fn()) vi.mock('sonner', () => ({ toast: { error: toastError } })) @@ -693,4 +699,142 @@ describe('NativeChatSessionOptionPickers', () => { expect(screen.getAllByText('Thinking').length).toBeGreaterThan(0) expect(screen.getAllByText('Sent to the agent — not confirmed').length).toBeGreaterThan(0) }) + + it('picks model then effort through the menus during startup and uses both in the first reply', async () => { + const SESSION = 'picker-startup-pair' + const CALLER = { callerKey: 'picker-client' } + const claude = createScriptedClaudeRuntime([SESSION]) + const picked: Promise[] = [] + let operation = 0 + claude.behave(SESSION, { initHangs: true, sendsNoStartFrame: true }) + try { + const host = await claude.install() + await host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'opus' } })) + await vi.waitFor(() => expect(claude.child(SESSION).initializing).toBe(true)) + const child = claude.child(SESSION) + let replyModel = 'opus' + let replyEffort = 'high' + let firstReply: { model: string; effort: string } | undefined + child.connection.supportedModels = async () => [ + { + value: 'sonnet', + displayName: 'Sonnet', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + }, + { + value: 'opus', + displayName: 'Opus', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + } + ] + const setModel = child.connection.setModel + child.connection.setModel = async (value, options) => { + await setModel(value, options) + replyModel = value ?? 'opus' + } + const applyFlags = child.connection.applyFlagSettings + child.connection.applyFlagSettings = async (settings, options) => { + await applyFlags(settings, options) + if (typeof settings.effortLevel === 'string') { + replyEffort = settings.effortLevel + } + } + child.connection.getSettings = async () => ({ effective: { effortLevel: replyEffort } }) + child.connection.send = async (_message, beforeDispatch) => { + await beforeDispatch?.() + child.calls.push('send') + firstReply = { model: replyModel, effort: replyEffort } + child.handlers.onMessage?.({ + type: 'assistant', + session_id: claudeSessionIdForOrcaSession(SESSION), + uuid: 'first-reply', + message: { + role: 'assistant', + model: replyModel, + content: [{ type: 'text', text: firstReply.effort }] + } + }) + } + // Let delivery wake before the callers waiting on the same ready child resume. + const readiness = StructuredAgentSessionStartupAttempts.prototype.readiness + const delayed = vi + .spyOn(StructuredAgentSessionStartupAttempts.prototype, 'readiness') + .mockImplementation(function ( + this: StructuredAgentSessionStartupAttempts, + sessionId, + identity + ) { + const selected = readiness.call(this, sessionId, identity) + return selected + ? { + wait: async (budget) => { + const outcome = await selected.wait(budget) + await new Promise((resolve) => setTimeout(resolve, 0)) + return outcome + } + } + : null + }) + const envelope = (method: string, fields: Record) => ({ + sessionId: SESSION, + clientOperationId: `${Date.now()}-${(++operation).toString(16).padStart(32, '0')}`, + expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + }) + const setOption = (key: string, value: string | boolean) => { + if (typeof value !== 'string') { + throw new Error('This flow picks select options.') + } + const write = host + .setOption(CALLER, { + envelope: envelope('agentSession.setOption', { key, value }), + key, + value + }) + .then((result) => { + expect(result).toMatchObject({ ok: true }) + return { snapshot: [] } + }) + picked.push(write) + return write + } + render( + + ) + fireEvent.click(screen.getByRole('option', { name: 'Sonnet 5' })) + await waitFor(() => + expect(screen.getByRole('radio', { name: 'Low' }).getAttribute('disabled')).toBeNull() + ) + fireEvent.click(screen.getByRole('radio', { name: 'Low' })) + const body = hostTestMessage('reply using these choices') + await expect( + host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).resolves.toMatchObject({ ok: true }) + child.answerInit() + await Promise.all(picked) + await vi.waitFor(async () => { + const reply = (await host.journalSnapshot(SESSION)).items.find( + (item) => item.body.kind === 'message' && item.body.role === 'assistant' + ) + expect(reply?.body).toMatchObject({ + blocks: [{ type: 'text', text: 'low' }] + }) + }) + expect(firstReply).toEqual({ model: 'sonnet', effort: 'low' }) + delayed.mockRestore() + } finally { + await claude.dispose() + await Promise.allSettled(picked) + } + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx index 18d99fc98b92..4e3ce97f6aa5 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx @@ -203,7 +203,7 @@ function NativeChatSessionOptionPickersInner({ pickerRequest, focusComposer }: NativeChatSessionOptionPickersProps): React.JSX.Element | null { - const [pendingId, setPendingId] = useState(null) + const [pendingIds, setPendingIds] = useState>(() => new Set()) const modelMenuFocus = useNativeChatPickerFocusReturn(focusComposer) const optionsMenuFocus = useNativeChatPickerFocusReturn(focusComposer) const model = snapshot.find((descriptor) => descriptor.category === 'model') @@ -211,18 +211,30 @@ function NativeChatSessionOptionPickersInner({ if (!surface || !model) { return null } + const modelPending = pendingIds.has(model.id) const requestedModelSequence = pickerRequest?.id === model.id ? pickerRequest.sequence : null const requestedOptionsSequence = options.some((descriptor) => descriptor.id === pickerRequest?.id) ? (pickerRequest?.sequence ?? null) : null + const markPending = (id: string) => (pendingId: string | null) => { + setPendingIds((current) => { + const next = new Set(current) + if (pendingId === null) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } const setOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => { - runSessionOptionSurfaceCall(descriptor.id, setPendingId, () => + runSessionOptionSurfaceCall(descriptor.id, markPending(descriptor.id), () => surface.setOption(descriptor.id, value) ) } const invokeAction = (descriptor: SessionOptionDescriptor): void => { - runSessionOptionSurfaceCall(descriptor.id, setPendingId, () => + runSessionOptionSurfaceCall(descriptor.id, markPending(descriptor.id), () => surface.invokeAction(descriptor.id) ) } @@ -234,7 +246,7 @@ function NativeChatSessionOptionPickersInner({ const modelTrigger = { label: nativeChatModelPillLabel(model), tooltipLabel: translate('components.native-chat.composer.model', 'Model'), - disabled: isWorking || pendingId !== null, + disabled: isWorking || modelPending, disabledReason: modelReason, dispatched: sessionOptionDispatchUnconfirmed(model) } @@ -252,7 +264,7 @@ function NativeChatSessionOptionPickersInner({ defaultOpen={modelPickerDefaultOpen} choices={model.kind.choices} currentValue={model.kind.currentValue} - readOnly={!model.settable || pendingId !== null} + readOnly={!model.settable || modelPending} readOnlyReason={modelReason} onSelect={(value) => setOption(model, value)} focusComposer={focusComposer} @@ -275,7 +287,7 @@ function NativeChatSessionOptionPickersInner({ ) : null} setOption(model, value)} invokeAction={() => invokeAction(model)} notePick={modelMenuFocus.notePick} @@ -292,7 +304,7 @@ function NativeChatSessionOptionPickersInner({ Trigger={DropdownMenuTrigger} label={nativeChatOptionsPillLabel(options)} tooltipLabel={optionsTooltip} - disabled={isWorking || pendingId !== null} + disabled={isWorking || options.every((descriptor) => pendingIds.has(descriptor.id))} disabledReason={optionsReason} dispatched={options.some(sessionOptionDispatchUnconfirmed)} /> @@ -318,7 +330,7 @@ function NativeChatSessionOptionPickersInner({ ) : null} setOption(descriptor, value)} invokeAction={() => invokeAction(descriptor)} notePick={optionsMenuFocus.notePick} diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 5920e68ae173..e8ad52068e65 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -213,7 +213,7 @@ export function canSetStructuredAgentSessionOption( const descriptor = structuredAgentSessionOptionSnapshot(state).find((entry) => entry.id === id) return Boolean( state.catalog && - state.pendingId === null && + state.pendingId !== id && ((typeof value === 'string' && descriptor?.kind.type === 'select' && descriptor.kind.choices.some((choice) => choice.value === value)) || From 7a4103d75ef9507a82053c84b693c67ced33d7e5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:30:36 -0700 Subject: [PATCH 50/55] Exercise startup picks across real menus and the runtime --- .../scripts/vitest-sqlite-runtime-files.mjs | 2 +- .../NativeChatSessionOptionPickers.test.tsx | 146 +------------- ...-structured-agent-session-options.test.tsx | 43 ---- ...e-chat-startup-option-pickers.unit.test.ts | 188 ++++++++++++++++++ 4 files changed, 190 insertions(+), 189 deletions(-) create mode 100644 tests/e2e/native-chat-startup-option-pickers.unit.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index c91dbddfe51b..22221406ee27 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -2,7 +2,7 @@ import { SQLITE_DATABASE_RUNTIME_INCLUDE } from './vitest-sqlite-database-runtim // Real SQLite fixtures keep Node publication and close semantics. export const SQLITE_RUNTIME_INCLUDE = [ - 'src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx', + 'tests/e2e/native-chat-startup-option-pickers.unit.test.ts', ...SQLITE_DATABASE_RUNTIME_INCLUDE, 'src/main/acp/acp-structured-host-live-listing.test.ts', 'src/main/acp/acp-structured-host-configured-default.test.ts', diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index f71134e9530e..db6b463b5061 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { cleanup, render, screen, waitFor } from '@testing-library/react' import type * as ReactModule from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import { OMP_SESSION_OPTION_CATALOG } from '../../../../shared/agent-session-option-catalog-omp' @@ -12,12 +12,6 @@ import { import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' -import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' -import { claudeSessionIdForOrcaSession } from '../../../../main/claude/claude-structured-launch-resolution' -import { hostTestMessage } from '../../../../main/native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { StructuredAgentSessionStartupAttempts } from '../../../../main/native-chat/agent-session-wire/structured-agent-session-startup-attempt' -import { createScriptedClaudeRuntime } from '../../../../main/runtime/structured-claude-scripted-runtime-test-support' - const toastError = vi.hoisted(() => vi.fn()) vi.mock('sonner', () => ({ toast: { error: toastError } })) @@ -699,142 +693,4 @@ describe('NativeChatSessionOptionPickers', () => { expect(screen.getAllByText('Thinking').length).toBeGreaterThan(0) expect(screen.getAllByText('Sent to the agent — not confirmed').length).toBeGreaterThan(0) }) - - it('picks model then effort through the menus during startup and uses both in the first reply', async () => { - const SESSION = 'picker-startup-pair' - const CALLER = { callerKey: 'picker-client' } - const claude = createScriptedClaudeRuntime([SESSION]) - const picked: Promise[] = [] - let operation = 0 - claude.behave(SESSION, { initHangs: true, sendsNoStartFrame: true }) - try { - const host = await claude.install() - await host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'opus' } })) - await vi.waitFor(() => expect(claude.child(SESSION).initializing).toBe(true)) - const child = claude.child(SESSION) - let replyModel = 'opus' - let replyEffort = 'high' - let firstReply: { model: string; effort: string } | undefined - child.connection.supportedModels = async () => [ - { - value: 'sonnet', - displayName: 'Sonnet', - supportsEffort: true, - supportedEffortLevels: ['low', 'high'] - }, - { - value: 'opus', - displayName: 'Opus', - supportsEffort: true, - supportedEffortLevels: ['low', 'high'] - } - ] - const setModel = child.connection.setModel - child.connection.setModel = async (value, options) => { - await setModel(value, options) - replyModel = value ?? 'opus' - } - const applyFlags = child.connection.applyFlagSettings - child.connection.applyFlagSettings = async (settings, options) => { - await applyFlags(settings, options) - if (typeof settings.effortLevel === 'string') { - replyEffort = settings.effortLevel - } - } - child.connection.getSettings = async () => ({ effective: { effortLevel: replyEffort } }) - child.connection.send = async (_message, beforeDispatch) => { - await beforeDispatch?.() - child.calls.push('send') - firstReply = { model: replyModel, effort: replyEffort } - child.handlers.onMessage?.({ - type: 'assistant', - session_id: claudeSessionIdForOrcaSession(SESSION), - uuid: 'first-reply', - message: { - role: 'assistant', - model: replyModel, - content: [{ type: 'text', text: firstReply.effort }] - } - }) - } - // Let delivery wake before the callers waiting on the same ready child resume. - const readiness = StructuredAgentSessionStartupAttempts.prototype.readiness - const delayed = vi - .spyOn(StructuredAgentSessionStartupAttempts.prototype, 'readiness') - .mockImplementation(function ( - this: StructuredAgentSessionStartupAttempts, - sessionId, - identity - ) { - const selected = readiness.call(this, sessionId, identity) - return selected - ? { - wait: async (budget) => { - const outcome = await selected.wait(budget) - await new Promise((resolve) => setTimeout(resolve, 0)) - return outcome - } - } - : null - }) - const envelope = (method: string, fields: Record) => ({ - sessionId: SESSION, - clientOperationId: `${Date.now()}-${(++operation).toString(16).padStart(32, '0')}`, - expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - }) - const setOption = (key: string, value: string | boolean) => { - if (typeof value !== 'string') { - throw new Error('This flow picks select options.') - } - const write = host - .setOption(CALLER, { - envelope: envelope('agentSession.setOption', { key, value }), - key, - value - }) - .then((result) => { - expect(result).toMatchObject({ ok: true }) - return { snapshot: [] } - }) - picked.push(write) - return write - } - render( - - ) - fireEvent.click(screen.getByRole('option', { name: 'Sonnet 5' })) - await waitFor(() => - expect(screen.getByRole('radio', { name: 'Low' }).getAttribute('disabled')).toBeNull() - ) - fireEvent.click(screen.getByRole('radio', { name: 'Low' })) - const body = hostTestMessage('reply using these choices') - await expect( - host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).resolves.toMatchObject({ ok: true }) - child.answerInit() - await Promise.all(picked) - await vi.waitFor(async () => { - const reply = (await host.journalSnapshot(SESSION)).items.find( - (item) => item.body.kind === 'message' && item.body.role === 'assistant' - ) - expect(reply?.body).toMatchObject({ - blocks: [{ type: 'text', text: 'low' }] - }) - }) - expect(firstReply).toEqual({ model: 'sonnet', effort: 'low' }) - delayed.mockRestore() - } finally { - await claude.dispose() - await Promise.allSettled(picked) - } - }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx index b6c2074e51d3..1e42ca73ae4a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx @@ -630,46 +630,3 @@ describe('useStructuredAgentSessionOptions', () => { } ) }) - -it.each(['claude', 'codex', 'pi', 'grok'] as const)( - 'accepts both startup picks in a %s chat and keeps showing them across a new fence', - async (agent) => { - const models = ['opus', 'sonnet'].map((id) => ({ - id, - label: id, - efforts: [ - { value: 'low', label: 'Low' }, - { value: 'high', label: 'High' } - ] - })) - let saved = { model: 'opus', effort: 'high' } - answer({ - modelCatalog: async () => ({ origin: 'live-session', models, fetchedAt: 1_000 }), - options: async () => ({ models, current: saved }) - }) - const pending = Promise.withResolvers() - const { mutate, calls } = mutateWith(() => pending.promise) - const starting = { ...ATTACHED, agent, providerStarting: true, launchSeedOptions: saved } - const { result, rerender, unmount } = renderOptions(starting, mutate) - await waitFor(() => expect(modelChoiceCount(result.current.optionSnapshot)).toBe(2)) - act(() => { - void result.current.setStructuredOption('model', 'sonnet') - void result.current.setStructuredOption('effort', 'low') - }) - expect(setOptionCalls(calls)).toEqual([ - { key: 'model', value: 'sonnet' }, - { key: 'effort', value: 'low' } - ]) - expect(currentValue(result.current.optionSnapshot, 'model')).toBe('sonnet') - expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('low') - rerender({ ...starting, fence: 2 }) - expect(currentValue(result.current.optionSnapshot, 'model')).toBe('sonnet') - expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('low') - saved = { model: 'sonnet', effort: 'low' } - await act(async () => pending.resolve({ key: 'effort', value: 'low', options: saved })) - rerender({ ...starting, fence: 2, providerStarting: false }) - await waitFor(() => expect(currentValue(result.current.optionSnapshot, 'model')).toBe('sonnet')) - expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('low') - unmount() - } -) diff --git a/tests/e2e/native-chat-startup-option-pickers.unit.test.ts b/tests/e2e/native-chat-startup-option-pickers.unit.test.ts new file mode 100644 index 000000000000..61c8e7bf3cb6 --- /dev/null +++ b/tests/e2e/native-chat-startup-option-pickers.unit.test.ts @@ -0,0 +1,188 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { createElement } from 'react' +import { afterEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../src/shared/agent-session-mutation-envelope' +import { claudeSessionIdForOrcaSession } from '../../src/main/claude/claude-structured-launch-resolution' +import { hostTestMessage } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { StructuredAgentSessionStartupAttempts } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt' +import { createScriptedClaudeRuntime } from '../../src/main/runtime/structured-claude-scripted-runtime-test-support' +import { TooltipProvider } from '../../src/renderer/src/components/ui/tooltip' +import { NativeChatSessionOptionPickers } from '../../src/renderer/src/components/native-chat/NativeChatSessionOptionPickers' +import { structuredAgentSessionSeedCatalog } from '../../src/shared/structured-agent-session-seed-catalog' +import { + applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../src/shared/structured-agent-session-options' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (text, [key, value]) => text.replaceAll(`{{${key}}}`, String(value)), + fallback + ) +})) +afterEach(() => cleanup()) + +it('picks model then effort through the menus during startup and uses both in the first reply', async () => { + const SESSION = 'picker-startup-pair' + const CALLER = { callerKey: 'picker-client' } + const claude = createScriptedClaudeRuntime([SESSION]) + const picked: Promise[] = [] + let operation = 0 + claude.behave(SESSION, { initHangs: true, sendsNoStartFrame: true }) + try { + const host = await claude.install() + await host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'opus' } })) + await vi.waitFor(() => expect(claude.child(SESSION).initializing).toBe(true)) + const child = claude.child(SESSION) + let replyModel = 'opus' + let replyEffort = 'high' + let firstReply: { model: string; effort: string } | undefined + child.connection.supportedModels = async () => [ + { + value: 'sonnet', + displayName: 'Sonnet', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + }, + { + value: 'opus', + displayName: 'Opus', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + } + ] + child.connection.setModel = async (value) => { + replyModel = value ?? 'opus' + } + child.connection.applyFlagSettings = async (settings) => { + if (typeof settings.effortLevel === 'string') { + replyEffort = settings.effortLevel + } + } + child.connection.getSettings = async () => ({ effective: { effortLevel: replyEffort } }) + child.connection.send = async (_message, beforeDispatch) => { + await beforeDispatch?.() + child.calls.push('send') + firstReply = { model: replyModel, effort: replyEffort } + child.handlers.onMessage?.({ + type: 'assistant', + session_id: claudeSessionIdForOrcaSession(SESSION), + uuid: 'first-reply', + message: { + role: 'assistant', + model: replyModel, + content: [{ type: 'text', text: firstReply.effort }] + } + }) + } + // Let delivery wake before the callers waiting on the same ready child resume. + const readiness = StructuredAgentSessionStartupAttempts.prototype.readiness + const delayed = vi + .spyOn(StructuredAgentSessionStartupAttempts.prototype, 'readiness') + .mockImplementation(function ( + this: StructuredAgentSessionStartupAttempts, + sessionId, + identity + ) { + const selected = readiness.call(this, sessionId, identity) + return selected + ? { + wait: async (budget) => { + const outcome = await selected.wait(budget) + await new Promise((resolve) => setTimeout(resolve, 0)) + return outcome + } + } + : null + }) + const envelope = (method: string, fields: Record) => ({ + sessionId: SESSION, + clientOperationId: `${Date.now()}-${(++operation).toString(16).padStart(32, '0')}`, + expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + }) + const setOption = (key: string, value: string | boolean) => { + if (typeof value !== 'string') { + throw new Error('This flow picks select options.') + } + const write = host + .setOption(CALLER, { + envelope: envelope('agentSession.setOption', { key, value }), + key, + value + }) + .then((result) => { + expect(result).toMatchObject({ ok: true }) + return { snapshot: [] } + }) + picked.push(write) + return write + } + const seed = structuredAgentSessionSeedCatalog('claude') + if (!seed) { + throw new Error('Claude options are not registered.') + } + const snapshot = structuredAgentSessionOptionSnapshot( + applyStructuredAgentSessionOptions(createStructuredAgentSessionOptionState('claude'), seed, { + models: ['opus', 'sonnet'].map((id) => ({ + id, + label: id, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + })), + current: { model: 'opus', effort: 'high' } + }) + ) + render( + createElement( + TooltipProvider, + {}, + createElement(NativeChatSessionOptionPickers, { + surface: { + getSnapshot: () => snapshot, + setOption, + invokeAction: async () => ({ snapshot }), + subscribe: () => () => {} + }, + snapshot, + isWorking: false + }) + ) + ) + fireEvent.click(screen.getByRole('button', { name: 'Model opus' })) + fireEvent.click(await screen.findByRole('option', { name: 'sonnet' })) + const options = await screen.findByRole('button', { name: /effort High/i }) + await waitFor(() => expect(options.getAttribute('disabled')).toBeNull()) + fireEvent.pointerDown(options, { button: 0, ctrlKey: false, pointerType: 'mouse' }) + fireEvent.click(await screen.findByRole('menuitemradio', { name: 'Low' })) + const body = hostTestMessage('reply using these choices') + await expect( + host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).resolves.toMatchObject({ ok: true }) + child.answerInit() + await Promise.all(picked) + await vi.waitFor(async () => { + const reply = (await host.journalSnapshot(SESSION)).items.find( + (item) => item.body.kind === 'message' && item.body.role === 'assistant' + ) + expect(reply?.body).toMatchObject({ + blocks: [{ type: 'text', text: 'low' }] + }) + }) + expect(firstReply).toEqual({ model: 'sonnet', effort: 'low' }) + delayed.mockRestore() + } finally { + await claude.dispose() + await Promise.allSettled(picked) + } +}) From a4c51a1593b4075aff739a645a8916ca8298ffe1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:22:16 -0700 Subject: [PATCH 51/55] Keep saved effort visible when picking a model during startup --- .../acp-structured-host-option-hang.test.ts | 9 +++- ...gent-session-ready-operation-order.test.ts | 54 ------------------- ...-structured-agent-session-options.test.tsx | 30 ++++++++++- .../structured-agent-session-options.ts | 7 +++ ...e-chat-startup-option-pickers.unit.test.ts | 17 ++---- 5 files changed, 48 insertions(+), 69 deletions(-) diff --git a/src/main/acp/acp-structured-host-option-hang.test.ts b/src/main/acp/acp-structured-host-option-hang.test.ts index 32f0a6df64f6..3a3352ce562b 100644 --- a/src/main/acp/acp-structured-host-option-hang.test.ts +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -68,21 +68,26 @@ describe('a Grok model pick Grok never answers', () => { }) it('fails at its bound, frees the chat, and keeps what Grok answers late', async () => { - const { rig, host } = await openAttachedHostRig({ optionWriteTimeoutMs: 100 }) + const { rig, host, store, fence } = await openAttachedHostRig({ optionWriteTimeoutMs: 100 }) const picking = pick(host, 'grok-4.6') await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) expect(failed(await within(picking, 2_000))).toBe(true) // The chat's queue moved on: a later operation runs. expect(await within(host.readOptions(SESSION), 1_000)).not.toBe('pending') const [frame] = framesOf(rig.child(), 'session/set_config_option') + const savedModel = store.getRecord(SESSION)?.options?.model rig.child().agent.reply(frame!, { configOptions: GROK_CONFIG_OPTIONS.map((option) => option.id === 'model' ? { ...option, currentValue: 'grok-4.6' } : option ) }) await waitFor(async () => - expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' }) + expect( + (await rig.adapter.readOptions({ sessionId: SESSION, fence: fence() })).current.model + ).toBe('grok-4.6') ) + expect((await host.readOptions(SESSION)).current.model).toBe(savedModel) + expect(store.getRecord(SESSION)?.options?.model).toBe(savedModel) await host.close(SESSION, 'user-close') }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts index 4ac8944ff497..f293632cecfb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts @@ -56,57 +56,3 @@ it.each(['send', 'compact'] as const)( expect(providerWrites).toHaveBeenCalledTimes(1) } ) -it.each(['goal', 'rewind'] as const)( - 'runs a %s on a ready child before a later send is accepted', - async (operation) => { - const { host, dispatch } = hostTestState() - await attach() - const body = hostTestMessage('first turn') - const first = { envelope: envelope('agentSession.send', { body }), body } - expect(await host.send(CALLER, first)).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) - await host.flushStreamedEvents(SESSION) - const order: string[] = [] - host.deps.adapter.changeThreadGoal = vi.fn(async () => { - order.push('goal') - return { ok: true as const } - }) - host.deps.adapter.rewindSupport = () => ({ supported: true }) - host.deps.adapter.rewind = vi.fn(async () => { - order.push('rewind') - return { ok: false as const, reason: 'history-not-paginated' as const } - }) - const itemId = agentJournalSubmissionKey(first.envelope.clientOperationId) - const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch - const change = { kind: 'set' as const, objective: 'Apply before the message' } - const blocked = Promise.withResolvers() - void host.collaboratorsForTests().serialize(SESSION, () => blocked.promise) - const requested = - operation === 'goal' - ? host.changeThreadGoal(CALLER, { - envelope: envelope('agentSession.threadGoal', { change }), - change - }) - : host.rewind(CALLER, { - envelope: envelope('agentSession.rewind', { itemId, expectedEpoch }), - itemId, - expectedEpoch - }) - const laterBody = hostTestMessage('later message') - const sent = host.send(CALLER, { - envelope: envelope('agentSession.send', { body: laterBody }), - body: laterBody, - beforeRun: () => order.push('send') - }) - blocked.resolve() - - const result = await requested - expect(result).toMatchObject( - operation === 'goal' - ? { ok: true } - : { ok: false, refusal: { rewindReason: 'history-not-paginated' } } - ) - expect(await sent).toMatchObject({ ok: true }) - expect(order).toEqual([operation, 'send']) - } -) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx index 1e42ca73ae4a..f86b60f488c2 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.test.tsx @@ -76,7 +76,7 @@ type RenderProps = { launchSeedOptions?: Record heldOptions?: Record worktree?: string - agent?: 'claude' | 'codex' | 'pi' | 'grok' + agent?: 'claude' | 'codex' providerStarting?: boolean paired?: boolean } @@ -497,6 +497,34 @@ describe('useStructuredAgentSessionOptions', () => { }) describe('before the provider starts', () => { + it('keeps a picked effort visible after a model pick during startup', async () => { + answer({ + options: async () => ({ + models: ['opus', 'sonnet'].map((id) => ({ + id, + label: id, + efforts: ['low', 'high'].map((value) => ({ value, label: value })) + })), + current: { model: 'opus' } + }) + }) + const { result, unmount } = renderOptions( + { ...REOPENED, agent: 'claude', providerStarting: true }, + mutateWith(async () => new Promise(() => {})).mutate + ) + await waitFor(() => expect(currentValue(result.current.optionSnapshot, 'model')).toBe('opus')) + act(() => { + void result.current.setStructuredOption('effort', 'low') + }) + expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('low') + act(() => { + void result.current.setStructuredOption('model', 'sonnet') + }) + expect(currentValue(result.current.optionSnapshot, 'model')).toBe('sonnet') + expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('low') + unmount() + }) + // What a started Claude host reports: the model its settings or env make Claude run. const CLAUDE_STARTED = { models: [ diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index e8ad52068e65..4c7627354ca7 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -9,6 +9,7 @@ import { applyNativeChatReportedSessionOptions, clearTrackedSessionOption, createNativeChatSessionOptionRecord, + getTrackedSessionOption, setTrackedSessionOption, type NativeChatSessionOptionRecord } from './native-chat-session-option-state' @@ -241,7 +242,13 @@ export function commitStructuredAgentSessionOption( if (decoded === null) { return { ...state, pendingId: null } } + const effort = + id === 'model' ? getTrackedSessionOption(state.record, effectiveModel, 'effort') : undefined setTrackedSessionOption(state.record, id, decoded, 'dispatched', effectiveModel) + // The host saves effort for the conversation, including across model picks. + if (effort) { + setTrackedSessionOption(state.record, 'effort', effort.value, 'dispatched') + } return { ...state, pendingId: null } } diff --git a/tests/e2e/native-chat-startup-option-pickers.unit.test.ts b/tests/e2e/native-chat-startup-option-pickers.unit.test.ts index 61c8e7bf3cb6..c82ffcc9a6c3 100644 --- a/tests/e2e/native-chat-startup-option-pickers.unit.test.ts +++ b/tests/e2e/native-chat-startup-option-pickers.unit.test.ts @@ -41,20 +41,13 @@ it('picks model then effort through the menus during startup and uses both in th let replyModel = 'opus' let replyEffort = 'high' let firstReply: { model: string; effort: string } | undefined - child.connection.supportedModels = async () => [ - { - value: 'sonnet', - displayName: 'Sonnet', + child.connection.supportedModels = async () => + ['sonnet', 'opus'].map((value) => ({ + value, + displayName: value, supportsEffort: true, supportedEffortLevels: ['low', 'high'] - }, - { - value: 'opus', - displayName: 'Opus', - supportsEffort: true, - supportedEffortLevels: ['low', 'high'] - } - ] + })) child.connection.setModel = async (value) => { replyModel = value ?? 'opus' } From d467ebd5e76eebfa41eb58a8c4cab9c57338f823 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:53:37 -0700 Subject: [PATCH 52/55] Include the running-turn scope in repeated Stop fixture --- .../structured-agent-session-repeat-press.test.ts | 1 + 1 file changed, 1 insertion(+) 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 72f57394c0bd..f7e6388f92e2 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 @@ -6,6 +6,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { agentSessionRefusalOperationState } from '../../../shared/agent-session-refusal-retry' import type { AgentSessionThreadGoalChange } from '../../../shared/agent-session-wire' From df2a98425a07830af233fd0f839c96a6936f41b6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:00:09 -0700 Subject: [PATCH 53/55] Use the current attach contract in import fixtures --- .../structured-agent-session-adopted-import.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index d1116094d2d1..7b478ff10a54 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -148,7 +148,6 @@ async function attach( probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params, now: () => NOW, onAttached From 957cd10e69c97c443974a5a7e2e679a9196c48bd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:42:29 -0700 Subject: [PATCH 54/55] Keep the Codex journal translator within its line limit --- src/main/codex/codex-structured-journal-translation.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index 6b61aa64c95c..547764d426c4 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -209,11 +209,8 @@ export function createCodexJournalTranslator( return suppressionAdmission.accepted ? prompts.handle(event) : suppressionAdmission } if (event.type === 'server-request') { - return genericFrames.appendUnhandled( - `request:${event.method}`, - event.params, - event.threadId - ) + const requestKey = `request:${event.method}` + return genericFrames.appendUnhandled(requestKey, event.params, event.threadId) } if (event.type === 'provider-frame') { return genericFrames.appendUnhandled(event.kind, event.payload, event.threadId) From 8f6c730a09b6f03a2391ab99ba93c3e2cb31b6d2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:01:46 -0700 Subject: [PATCH 55/55] Match ACP startup fixtures to saved picks and late Stop behavior --- .../acp/acp-structured-host-lifecycle.test.ts | 7 ++++--- .../acp-structured-host-option-hang.test.ts | 21 ++++++++++++------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index 1d188c564984..d0e667548008 100644 --- a/src/main/acp/acp-structured-host-lifecycle.test.ts +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -247,7 +247,7 @@ describe('closing or stopping a Grok chat while it starts', () => { await host.close(SESSION, 'user-close') }) - it('stops a starting child even when Stop names an earlier turn', async () => { + it('leaves a starting child alone when Stop names an earlier turn', async () => { const { rig, host } = await openHostRig({ script: (agent) => agent.on('initialize', () => {}) }) @@ -257,11 +257,12 @@ describe('closing or stopping a Grok chat while it starts', () => { envelope: envelope('agentSession.cancel', { turnId: 'turn-of-an-old-child' }), turnId: 'turn-of-an-old-child' }) + expect(await stopping).toMatchObject({ ok: true, value: { cancelled: false } }) await rig.settle() - expect(rig.child().closes).toBeGreaterThan(0) + expect(rig.child().closes).toBe(0) + expect(rig.child().exited).toBe(false) await host.close(SESSION, 'user-close') await attaching - await stopping }) it('never spawns a child for a start closed while its launch was still resolving', async () => { diff --git a/src/main/acp/acp-structured-host-option-hang.test.ts b/src/main/acp/acp-structured-host-option-hang.test.ts index 2401d9764883..86db37f29252 100644 --- a/src/main/acp/acp-structured-host-option-hang.test.ts +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -73,25 +73,30 @@ describe('a Grok model pick Grok never answers', () => { it('fails at its bound, frees the chat, and keeps what Grok answers late', async () => { const { rig, host, store, fence } = await openAttachedHostRig({ - optionWriteTimeoutMs: 100, - script: (agent) => agent.on('session/set_config_option', () => {}) + optionWriteTimeoutMs: 100 }) - const picking = pick(host, 'grok-4.6') - await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) + expect(await pick(host, 'grok-4.6')).toMatchObject({ ok: true }) + const savedModel = store.getRecord(SESSION)?.options?.model + expect(savedModel).toBe('grok-4.6') + const priorWrites = framesOf(rig.child(), 'session/set_config_option').length + rig.child().agent.on('session/set_config_option', () => {}) + const picking = pick(host, 'grok-4.7') + await waitFor(() => + expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(priorWrites + 1) + ) expect(failed(await within(picking, 2_000))).toBe(true) // The chat's queue moved on: a later operation runs. expect(await within(host.readOptions(SESSION), 1_000)).not.toBe('pending') - const [frame] = framesOf(rig.child(), 'session/set_config_option') - const savedModel = store.getRecord(SESSION)?.options?.model + const frame = framesOf(rig.child(), 'session/set_config_option').at(-1) rig.child().agent.reply(frame!, { configOptions: GROK_CONFIG_OPTIONS.map((option) => - option.id === 'model' ? { ...option, currentValue: 'grok-4.6' } : option + option.id === 'model' ? { ...option, currentValue: 'grok-4.7' } : option ) }) await waitFor(async () => expect( (await rig.adapter.readOptions({ sessionId: SESSION, fence: fence() })).current.model - ).toBe('grok-4.6') + ).toBe('grok-4.7') ) expect((await host.readOptions(SESSION)).current.model).toBe(savedModel) expect(store.getRecord(SESSION)?.options?.model).toBe(savedModel)