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 001/127] 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 002/127] 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 003/127] 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 6ba01b3f1ac5872a58c6e4fe212632be03d9aafd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:55:52 -0700 Subject: [PATCH 004/127] Publish Pi at spawn and settle signed-out starts once --- .../scripts/vitest-sqlite-runtime-files.mjs | 3 + src/main/jsonl-rpc/agent-connection.ts | 13 +- .../structured-agent-session-host-delivery.ts | 4 +- ...red-agent-session-signed-out-child.test.ts | 22 +- ...ructured-agent-session-signed-out-child.ts | 10 +- src/main/pi/pi-scripted-child.test-fixture.ts | 261 ++++++++++++++ src/main/pi/rpc-configured-default.test.ts | 25 +- src/main/pi/rpc-options.test.ts | 4 +- src/main/pi/rpc-options.ts | 7 +- src/main/pi/rpc-runtime-registration.ts | 12 +- .../pi/rpc-session-adapter.test-fixture.ts | 221 ++++++++++++ src/main/pi/rpc-session-adapter.test.ts | 222 ++---------- src/main/pi/rpc-session-adapter.ts | 18 +- src/main/pi/rpc-session-startup.test.ts | 248 ++++++++++++++ src/main/pi/rpc-session-startup.ts | 174 ++++++++++ src/main/pi/rpc-session.ts | 109 +++--- ...-agent-session-pi-signed-out-start.test.ts | 94 +++++ ...tructured-agent-session-pi-startup.test.ts | 188 ++++++++++ ...provider-operations-while-starting.test.ts | 323 ++++++++++++++++++ ...tured-agent-session-transport-overrides.ts | 3 + 20 files changed, 1683 insertions(+), 278 deletions(-) create mode 100644 src/main/pi/pi-scripted-child.test-fixture.ts create mode 100644 src/main/pi/rpc-session-adapter.test-fixture.ts create mode 100644 src/main/pi/rpc-session-startup.test.ts create mode 100644 src/main/pi/rpc-session-startup.ts create mode 100644 src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts create mode 100644 src/main/runtime/structured-agent-session-pi-startup.test.ts create mode 100644 src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 4c2e4878a847..dd21b83a3451 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -242,6 +242,9 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts', 'src/main/runtime/structured-agent-session-codex-scripted-child.test.ts', 'src/main/runtime/structured-agent-session-codex-start-deadline.test.ts', + 'src/main/runtime/structured-agent-session-pi-startup.test.ts', + 'src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts', + 'src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts', 'src/main/runtime/structured-agent-session-codex-settlement-order.test.ts', 'src/main/runtime/structured-agent-session-integration-replay.test.ts', 'src/main/runtime/structured-agent-session-integration.test.ts', diff --git a/src/main/jsonl-rpc/agent-connection.ts b/src/main/jsonl-rpc/agent-connection.ts index 551beb40dec6..a194660f7b70 100644 --- a/src/main/jsonl-rpc/agent-connection.ts +++ b/src/main/jsonl-rpc/agent-connection.ts @@ -1,5 +1,6 @@ import type { PipedProcessSpawner } from '@orca/process-host/process-spec' import { spawnProcess } from '@orca/process-host' +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import { spawnManagedProviderProcess, type ManagedProviderProcess, @@ -81,10 +82,14 @@ export class JsonlRpcAgentConnection { managed.child.stderr.on('error', this.onError) managed.onExit((exit) => { clearTimeout(this.streamExitTimer) - const error = new Error( - exit.processless - ? 'Agent process could not start' - : `Agent process exited (code ${exit.code ?? 'none'}, signal ${exit.signal ?? 'none'})` + // The agent's last stderr lines are what a person can act on when it dies. + const error = withProviderDiagnostic( + new Error( + exit.processless + ? 'Agent process could not start' + : `Agent process exited (code ${exit.code ?? 'none'}, signal ${exit.signal ?? 'none'})` + ), + providerDiagnostic(managed.stderrTail(), 'person') ) this.peer.finishOnInputEnd(error) const stdout = managed.child.stdout diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 9ff8543d514e..bd9102421ed3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -76,7 +76,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { const session = input.clientDelivery.readStopping(sessionId) ? undefined : sessions.get(sessionId) - await retireSignedOutStructuredAgentSessionChild(sessionId, session, { + await retireSignedOutStructuredAgentSessionChild(sessionId, session, startedFor, { work: { childWork: () => input.clientDelivery.readChildWork(sessionId), hasOpenDispatch: () => { @@ -189,7 +189,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { * A rewind found prepared when the conversation opens was started under a child this process no * longer has — the open runs only when none is indexed — so nothing will finish it, and left alone * it refuses every send until a view attaches. Settled here instead of by a start inside - * acceptance. A Codex rewind only its provider can prove stays for the attach. + * acceptance. A Codex rewind only its provider can prove stays for the next proven start. */ async function settleInterruptedCommands( deps: StructuredAgentSessionHostDeps, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts index ae09e13d8782..13fa794a4eb1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts @@ -205,10 +205,11 @@ describe('retireSignedOutStructuredAgentSessionChild', () => { const retire = async ( input: Parameters[0], work: Partial = {}, - stopAgent = vi.fn(async () => undefined) + stopAgent = vi.fn(async () => undefined), + startedFor = 'next-send' ) => { const warn = vi.fn() - await retireSignedOutStructuredAgentSessionChild('session', conversation(input), { + await retireSignedOutStructuredAgentSessionChild('session', conversation(input), startedFor, { work: { ...idle, ...work }, stopAgent, logger: { warn, error: vi.fn() } @@ -220,6 +221,23 @@ describe('retireSignedOutStructuredAgentSessionChild', () => { expect((await retire(signedOut)).stopAgent).toHaveBeenCalledWith('session') }) + it('keeps a child started for the send it is about to take', async () => { + const own = await retire( + { ...signedOut, child: childAt({ startedFor: 'next-send' }) }, + {}, + undefined, + 'next-send' + ) + expect(own.stopAgent).not.toHaveBeenCalled() + const earlier = await retire( + { ...signedOut, child: childAt({ startedFor: 'earlier-send' }) }, + {}, + undefined, + 'next-send' + ) + expect(earlier.stopAgent).toHaveBeenCalledWith('session') + }) + it('keeps it while it owes work the idle sweep also protects', async () => { // A pending prompt is the shared check's too; the idle sweep's tests cover that case. for (const kept of [ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts index 40ea59a69eb8..c9c891bc3791 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.ts @@ -78,12 +78,15 @@ function owesWakeUp( ) } -/** For a caller inside the session's serialize, before it hands the next send to the child. Work - * the child still owes keeps it, as it keeps an idle one from the sweep. A stop that fails leaves - * the close begun, which the start joins. */ +/** For a caller inside the session's serialize, before it hands the next send (`startedFor`) to + * the child. A child started for that same send keeps it: the send has not reached any child yet, + * and the agent's own refusal settles it once, where a new child would refuse it again forever. + * Work the child still owes keeps it, as it keeps an idle one from the sweep. A stop that fails + * leaves the close begun, which the start joins. */ export async function retireSignedOutStructuredAgentSessionChild( sessionId: string, session: SignedOutReading | undefined, + startedFor: string, deps: { work: StructuredAgentSessionChildWorkReads startUnavailable?: () => AgentSessionUnavailable | undefined @@ -93,6 +96,7 @@ export async function retireSignedOutStructuredAgentSessionChild( ): Promise { if ( !session || + session.child?.startedFor === startedFor || !structuredAgentSessionChildReportedSignedOut(session, deps.startUnavailable?.()) || structuredAgentSessionChildHasOpenWork(session.journal, deps.work) || owesWakeUp(session.journal, deps.work.childWork()) diff --git a/src/main/pi/pi-scripted-child.test-fixture.ts b/src/main/pi/pi-scripted-child.test-fixture.ts new file mode 100644 index 000000000000..635c30bc8a75 --- /dev/null +++ b/src/main/pi/pi-scripted-child.test-fixture.ts @@ -0,0 +1,261 @@ +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' +import type { JsonlRpcAgentConnectionOptions } from '../jsonl-rpc/agent-connection' +import { JsonlRpcResponseError, type JsonlRpcRecord } from '../jsonl-rpc/peer' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import type { ScriptedAgentChild } from '../runtime/structured-agent-scripted-child.test-fixture' +import type { PiRpcConnection } from './rpc-session' + +class ScriptedPiConnection implements PiRpcConnection { + closed = false + rootVerdict: PiRpcConnection['rootVerdict'] = 'live' + processless = false + lastCloseResult: PiRpcConnection['lastCloseResult'] = null + readonly pid: number + readonly file: string + private readonly handshake = Promise.withResolvers() + private readonly exitListeners = new Set<() => void>() + private firstStateRead = true + private streaming = false + private model = { provider: 'scripted', id: 'model-1', reasoning: true } + private effort = 'medium' + + constructor( + launch: ProviderProcessLaunch, + private readonly handlers: JsonlRpcAgentConnectionOptions, + private readonly script: ScriptedAgentChild, + private readonly received: string[], + private readonly wire: string[], + private readonly onClosed: () => void, + number: number, + /** A Pi with no login: it lists no model and fails every prompt as not signed in. */ + private readonly signedOut = false + ) { + this.pid = 42_000 + number + const sessionIndex = launch.args.indexOf('--session') + this.file = + sessionIndex !== -1 + ? launch.args[sessionIndex + 1] + : join(tmpdir(), 'orca-scripted-pi', `session-${number}.jsonl`) + void this.handshake.promise.catch(() => undefined) + } + + async request(command: string, params: Record = {}): Promise { + if (this.closed) { + throw new Error('Scripted Pi connection closed') + } + this.wire.push(command) + if (command === 'get_state') { + if (this.firstStateRead) { + this.firstStateRead = false + if (!this.script.holdHandshakes) { + this.releaseHandshake() + } + return this.handshake.promise + } + return this.state() + } + if (command === 'get_available_models') { + return { models: this.signedOut ? [] : [this.model] } + } + if (command === 'get_commands') { + return { commands: [] } + } + if ( + command === 'set_model' && + typeof params.provider === 'string' && + typeof params.modelId === 'string' + ) { + this.model = { ...this.model, provider: params.provider, id: params.modelId } + return this.model + } + if (command === 'set_thinking_level' && typeof params.level === 'string') { + this.effort = params.level + return {} + } + if (command === 'abort') { + this.endTurn('aborted') + return {} + } + throw new Error(`Unscripted Pi request: ${command}`) + } + + async send(frame: JsonlRpcRecord): Promise { + if (this.closed || frame.type !== 'prompt' || typeof frame.message !== 'string') { + throw new Error('Scripted Pi requires a live prompt') + } + this.received.push(frame.message) + this.wire.push(`prompt:${frame.message}`) + if (this.signedOut) { + this.emit({ + type: 'response', + command: 'prompt', + success: false, + error: 'No API key found for scripted' + }) + return + } + if (!this.streaming) { + this.streaming = true + this.emit({ type: 'agent_start' }) + } + this.emit({ type: 'message_end', message: { role: 'user', content: frame.message } }) + this.emit({ + type: 'response', + command: 'prompt', + success: true, + data: { disposition: 'started' } + }) + if (this.script.completeTurns) { + this.endTurn('stop') + } + } + + releaseHandshake(): void { + if (!this.closed) { + this.handshake.resolve(this.state()) + } + } + + failHandshake(message: string): void { + const error = withProviderDiagnostic( + new JsonlRpcResponseError('get_state', message), + providerDiagnostic(message, 'person') + ) + this.handshake.reject(error) + this.exit(error) + } + + async close(): Promise>> { + if (!this.closed) { + this.onClosed() + const error = new Error('Scripted Pi closed') + this.handshake.reject(error) + this.exit(error) + } + return { root: 'exited', tree: 'exited' } + } + + onExit(listener: () => void): void { + if (this.rootVerdict === 'exited') { + listener() + } else { + this.exitListeners.add(listener) + } + } + + pauseReading(): void {} + resumeReading(): void {} + + private exit(error: Error): void { + if (this.closed) { + return + } + this.closed = true + this.rootVerdict = 'exited' + this.lastCloseResult = { root: 'exited', tree: 'exited' } + this.exitListeners.forEach((listener) => listener()) + this.exitListeners.clear() + this.handlers.onExit?.(error, { + expected: false, + exit: { code: 0, signal: null, processless: false } + }) + } + + private endTurn(stopReason: string): void { + if (!this.streaming) { + return + } + this.streaming = false + this.emit({ + type: 'message_end', + message: { role: 'assistant', content: 'Scripted Pi reply', stopReason } + }) + this.emit({ type: 'agent_end' }) + this.emit({ type: 'agent_settled' }) + } + + private emit(frame: JsonlRpcRecord): void { + this.handlers.onRecord?.(frame) + } + + private state() { + return { + sessionFile: this.file, + model: this.signedOut ? null : this.model, + thinkingLevel: this.effort, + isStreaming: this.streaming, + isCompacting: false, + pendingMessageCount: 0 + } + } +} + +export type ScriptedPiChild = ScriptedAgentChild & { + /** Every request command and prompt any spawn received, in order. */ + wire(): readonly string[] +} + +export const piScriptedChild = (options: { signedOut?: boolean } = {}): ScriptedPiChild => { + const children: ScriptedPiConnection[] = [] + const received: string[] = [] + const wire: string[] = [] + let resumed = 0 + let closed = 0 + const script: ScriptedPiChild = { + holdHandshakes: true, + completeTurns: true, + deps: { + resolvePiLaunch: async ({ identity }) => { + const handle = identity.providerHandle + if (handle && (handle.transport !== 'jsonl-rpc' || handle.agent !== 'pi')) { + throw new Error('Scripted Pi received another provider handle') + } + return { + command: join(tmpdir(), 'orca-scripted-pi', 'fake-pi'), + cwd: tmpdir(), + fullAccess: true, + previous: handle + ? { + linkId: 'scripted-previous', + handle, + origin: 'created', + mintedAtFence: 0, + observedAt: Date.now() + } + : null, + ...(handle ? { sessionFile: handle.nativeId } : {}) + } + }, + openPiConnection: (launch, handlers) => { + if (launch.args.includes('--session')) { + resumed += 1 + } + const child = new ScriptedPiConnection( + launch, + handlers, + script, + received, + wire, + () => { + closed += 1 + }, + children.length + 1, + options.signedOut + ) + children.push(child) + return child + }, + readProcessStartTime: async () => 1_700_000_000_000 + }, + releaseHandshake: () => children.at(-1)?.releaseHandshake(), + failHandshake: (message) => children.at(-1)?.failHandshake(message), + prompts: () => received, + spawns: () => children.length, + resumes: () => resumed, + closes: () => closed, + wire: () => wire + } + return script +} diff --git a/src/main/pi/rpc-configured-default.test.ts b/src/main/pi/rpc-configured-default.test.ts index 467417c8eadd..7ba6289084a4 100644 --- a/src/main/pi/rpc-configured-default.test.ts +++ b/src/main/pi/rpc-configured-default.test.ts @@ -28,6 +28,10 @@ import { agentModelCatalogFingerprint } from '../native-chat/agent-model-catalog import { createPiModelCatalogProbe, piModelCatalogFromListing } from './rpc-model-catalog-probe' import type { PiRpcConnection } from './rpc-session' import { PiRpcSessionAdapter } from './rpc-session-adapter' +import type { + StructuredAgentSessionLifecycleEvent, + StructuredAgentSessionStartedEvent +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' const PI_HOME = { variable: 'PI_CODING_AGENT_DIR', path: '/homes/pi' } // What `pi --list-models` prints: the first row is not the model Pi starts on. @@ -176,6 +180,7 @@ async function piHost() { } } as AgentSessionRecord) const rig = await openProviderTimelineRig({ agent: 'pi', sessionId }) + const onLifecycle = vi.fn<(event: StructuredAgentSessionLifecycleEvent) => void>() const adapter = new PiRpcSessionAdapter({ resolveLaunch: async () => ({ command: '/opt/pi/bin/pi', @@ -191,13 +196,13 @@ async function piHost() { child = piChild(opts.available ?? AVAILABLE, opts.thinking ?? 'high', opts.startsOn ?? '') return child.connection }, - onLifecycle: vi.fn(), + onLifecycle, onSettled: vi.fn(), onIdle: vi.fn(), logger: { warn: vi.fn(), error: vi.fn() } }) adapters.push(adapter) - const acquired = await adapter.acquire({ + await adapter.acquire({ identity: { sessionId, workspaceId: 'ws-1', @@ -210,10 +215,20 @@ async function piHost() { options: opts.saved ?? {}, events: rig.eventSink }) - if (!acquired.catalogListing) { + // The acquire answers at spawn; the start hands the host its listing once it is proven. + const started = await vi.waitFor(() => { + const event = onLifecycle.mock.calls + .map(([call]) => call) + .find((call): call is StructuredAgentSessionStartedEvent => call.type === 'started') + if (!event) { + throw new Error('Pi has not started') + } + return event + }) + if (!started.catalogListing) { throw new Error('a Pi start hands the host its listing') } - catalog.recordLiveListing(sessionId, acquired.catalogListing) + catalog.recordLiveListing(sessionId, started.catalogListing) if (opts.afterStart && child) { await opts.afterStart({ child, adapter, sessionId }) } @@ -224,7 +239,7 @@ async function piHost() { } } await read() - return { listing: acquired.catalogListing, read } + return { listing: started.catalogListing, read } } return { catalog, chat, store, clock } } diff --git a/src/main/pi/rpc-options.test.ts b/src/main/pi/rpc-options.test.ts index 5beaefb54ca1..d15c602cf550 100644 --- a/src/main/pi/rpc-options.test.ts +++ b/src/main/pi/rpc-options.test.ts @@ -70,8 +70,8 @@ describe('Pi RPC session options', () => { effort: 'high' }) expect(rpc.request.mock.calls).toEqual([ - ['set_model', { provider: 'openai-codex', modelId: 'gpt-6.1-sol' }], - ['set_thinking_level', { level: 'high' }] + ['set_model', { provider: 'openai-codex', modelId: 'gpt-6.1-sol' }, {}], + ['set_thinking_level', { level: 'high' }, {}] ]) expect(() => parsePiModelOptionId('openai-codex')).toThrow() await expect(applyPiRpcSessionOption(rpc, selected, 'model', '/gpt-6.1-sol')).rejects.toThrow() diff --git a/src/main/pi/rpc-options.ts b/src/main/pi/rpc-options.ts index 4f75e064c6e1..b881d31f1729 100644 --- a/src/main/pi/rpc-options.ts +++ b/src/main/pi/rpc-options.ts @@ -92,17 +92,18 @@ export async function applyPiRpcSessionOption( rpc: PiRpcRequester, selected: Map, key: string, - value: string + value: string, + options: { timeoutMs?: number | null } = {} ): Promise>> { if (key === 'model') { const model = parsePiModelOptionId(value) modelSchema.parse({ provider: model.provider, id: model.modelId }) - await rpc.request('set_model', model) + await rpc.request('set_model', model, options) } else if (key === 'effort') { if (!THINKING_LEVELS.some((level) => level === value)) { throw new Error(`Unsupported Pi thinking level: ${value}`) } - await rpc.request('set_thinking_level', { level: value }) + await rpc.request('set_thinking_level', { level: value }, options) } else { throw new Error(`Unsupported Pi option: ${key}`) } diff --git a/src/main/pi/rpc-runtime-registration.ts b/src/main/pi/rpc-runtime-registration.ts index a8e97e5e3081..8bb3c1a91f47 100644 --- a/src/main/pi/rpc-runtime-registration.ts +++ b/src/main/pi/rpc-runtime-registration.ts @@ -24,11 +24,13 @@ import { createPiModelCatalogProbe } from './rpc-model-catalog-probe' function createPiRpcAdapter(context: StructuredAgentAdapterContext): StructuredAgentRuntimeAdapter { const { deps } = context return new PiRpcSessionAdapter({ - resolveLaunch: composeStructuredLaunch( - PI_RPC_AGENT, - structuredAgentLaunchSources(context), - piRpcLaunchPart({}) - ), + resolveLaunch: + deps.resolvePiLaunch ?? + composeStructuredLaunch( + PI_RPC_AGENT, + structuredAgentLaunchSources(context), + piRpcLaunchPart({}) + ), ...(deps.openPiConnection ? { openConnection: deps.openPiConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), onLifecycle: context.deliverLifecycle, diff --git a/src/main/pi/rpc-session-adapter.test-fixture.ts b/src/main/pi/rpc-session-adapter.test-fixture.ts new file mode 100644 index 000000000000..573d28d9a678 --- /dev/null +++ b/src/main/pi/rpc-session-adapter.test-fixture.ts @@ -0,0 +1,221 @@ +import { expect, vi, type Mock } from 'vitest' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + closeProviderTimelineRigs, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { JsonlRpcAgentConnectionOptions } from '../jsonl-rpc/agent-connection' +import type { JsonlRpcRecord } from '../jsonl-rpc/peer' +import { JsonlRpcResponseError } from '../jsonl-rpc/peer' +import type { PiRpcConnection } from './rpc-session' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { PiRpcSessionAdapter, type PiRpcSessionAdapterDeps } from './rpc-session-adapter' +import type { PiRpcResolvedLaunch } from './rpc-launch-resolution' + +export const sessionId = 'session-timeline' +export const file = '/host/account/sessions/session.jsonl' +export const state = { + sessionFile: file, + isStreaming: false, + isCompacting: false, + model: { provider: 'anthropic', id: 'model-1' } +} +const cleanups: (() => Promise)[] = [] +export async function closePiRpcAdapterFixtures(): Promise { + for (const cleanup of cleanups.splice(0)) { + await cleanup() + } + await closeProviderTimelineRigs() +} + +export class FakeConnection implements PiRpcConnection { + pid = 4123 + closed = false + rootVerdict: 'live' | 'unverifiable' | 'exited' = 'live' + processless = false + deferExit = false + closeError?: Error + lastCloseResult: PiRpcConnection['lastCloseResult'] = null + closeResult: Awaited> = { root: 'exited', tree: 'exited' } + readonly sent: JsonlRpcRecord[] = [] + readonly requests: string[] = [] + private readonly exitListeners: (() => void)[] = [] + requestOverride?: (command: string) => Promise | undefined + abortRequest?: () => void + constructor(readonly handlers: JsonlRpcAgentConnectionOptions) {} + async request(command: string): Promise { + this.requests.push(command) + const override = this.requestOverride?.(command) + if (override) { + return override + } + if (command === 'get_state') { + return state + } + if (command === 'get_available_models') { + return { models: [state.model] } + } + if (command === 'get_commands') { + return { commands: [{ name: 'help' }] } + } + if (command === 'set_thinking_level') { + throw new JsonlRpcResponseError(command, 'saved effort unavailable') + } + return {} + } + async send(frame: JsonlRpcRecord): Promise { + this.sent.push(frame) + } + async close(): Promise>> { + this.abortRequest?.() + this.closed = true + this.rootVerdict = this.closeResult.root + this.lastCloseResult = this.closeResult + if (this.closeResult.root === 'exited' && !this.deferExit) { + this.exit(this.closeError) + } + return this.closeResult + } + pauseReading(): void {} + resumeReading(): void {} + onExit(listener: () => void): void { + if (this.rootVerdict === 'exited') { + listener() + } else { + this.exitListeners.push(listener) + } + } + receive(frame: JsonlRpcRecord): void { + this.handlers.onRecord?.(frame) + } + exit(error = new Error('child exited')): void { + this.closed = true + this.rootVerdict = 'exited' + for (const listener of this.exitListeners.splice(0)) { + listener() + } + this.handlers.onExit?.(error, { + expected: false, + exit: { code: 1, signal: null, processless: false } + }) + } +} + +type PiLifecycleEvent = Parameters[0] + +/** The first `type` event the adapter reported; a missing one fails the test. */ +export function reportedEvent( + lifecycle: Mock, + type: T +): Extract { + const isType = (event: PiLifecycleEvent): event is Extract => + event.type === type + const found = lifecycle.mock.calls.map(([event]) => event).find(isType) + if (!found) { + throw new Error(`no ${type} event was reported`) + } + return found +} + +export async function setup( + options: Readonly> = {}, + eventSink?: (sink: StructuredAgentSessionEventSink) => StructuredAgentSessionEventSink +) { + const rig = await openProviderTimelineRig({ agent: 'pi', sessionId }) + const connections: FakeConnection[] = [] + const lifecycle = vi.fn() + const settled = vi.fn() + const idle = vi.fn() + const onSpawned = vi.fn(async (_process: AgentSessionProcessIdentity) => { + expect(connections.at(-1)?.requests).toEqual([]) + }) + const input: StructuredAgentSessionAcquireInput = { + identity: { + sessionId, + workspaceId: 'folder-1', + hostId: 'local', + agent: 'pi', + providerHandle: null + }, + fence: 7, + spawnToken: 'spawn-token', + options, + events: eventSink?.(rig.eventSink) ?? rig.eventSink, + onSpawned + } + const resolveLaunch = vi.fn(async (): Promise => ({ + command: '/host/bin/pi', + cwd: '/host/folder', + fullAccess: true, + previous: null + })) + const openConnection = vi.fn( + (_launch: ProviderProcessLaunch, handlers: JsonlRpcAgentConnectionOptions) => { + const connection = new FakeConnection(handlers) + connections.push(connection) + return connection + } + ) + const adapter = new PiRpcSessionAdapter({ + resolveLaunch, + readProcessStartTime: async () => 12345, + openConnection, + onLifecycle: lifecycle, + onSettled: settled, + onIdle: idle, + logger: { warn: vi.fn(), error: vi.fn() } + }) + const acquired = await adapter.acquire(input) + // Commands are read after the start; waiting for them leaves nothing of it in flight. + await vi.waitFor(() => expect(adapter.readCommands(sessionId)).toBeDefined()) + const started = reportedEvent(lifecycle, 'started') + lifecycle.mockClear() + const connection = connections[0] + if (!connection) { + throw new Error('connection missing') + } + cleanups.push(async () => { + await adapter.closeAll().catch(() => {}) + await adapter.drainObservedExits() + adapter.acknowledgeSessionRelease(sessionId) + }) + return { + adapter, + connection, + rig, + acquired, + started, + onSpawned, + lifecycle, + settled, + idle, + input, + resolveLaunch, + openConnection, + connections + } +} + +export async function restart( + h: Awaited>, + configure: (connection: FakeConnection) => void, + input: Partial = {} +) { + await h.adapter.closeSession(sessionId) + await h.adapter.drainObservedExits() + h.lifecycle.mockClear() + h.openConnection.mockImplementationOnce((_launch, handlers) => { + const connection = new FakeConnection(handlers) + configure(connection) + h.connections.push(connection) + return connection + }) + const acquired = await h.adapter.acquire({ ...h.input, fence: 8, ...input }) + const child = h.connections.at(-1) + if (!child) { + throw new Error('child missing') + } + return { acquired, child } +} diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 8c74e65eea1c..48a0b9bc5bce 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -1,185 +1,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' -import { - AgentSessionAcquisitionRootExitObservedError, - type StructuredAgentSessionAcquireInput -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import { - closeProviderTimelineRigs, - openProviderTimelineRig -} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import type { JsonlRpcAgentConnectionOptions } from '../jsonl-rpc/agent-connection' -import type { JsonlRpcRecord } from '../jsonl-rpc/peer' +import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { JsonlRpcResponseError } from '../jsonl-rpc/peer' -import type { PiRpcConnection } from './rpc-session' -import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + closePiRpcAdapterFixtures, + FakeConnection, + file, + sessionId, + setup +} from './rpc-session-adapter.test-fixture' import { agentSessionLaunchFolderMissing } from '../runtime/agent-session-launch-directory' -import { PiRpcSessionAdapter } from './rpc-session-adapter' - -const sessionId = 'session-timeline' -const file = '/host/account/sessions/session.jsonl' -const state = { - sessionFile: file, - isStreaming: false, - isCompacting: false, - model: { provider: 'anthropic', id: 'model-1' } -} -const cleanups: (() => Promise)[] = [] -afterEach(async () => { - for (const cleanup of cleanups.splice(0)) { - await cleanup() - } - await closeProviderTimelineRigs() -}) - -class FakeConnection implements PiRpcConnection { - pid = 4123 - closed = false - rootVerdict: 'live' | 'unverifiable' | 'exited' = 'live' - processless = false - deferExit = false - lastCloseResult: PiRpcConnection['lastCloseResult'] = null - closeResult: Awaited> = { root: 'exited', tree: 'exited' } - readonly sent: JsonlRpcRecord[] = [] - readonly requests: string[] = [] - private readonly exitListeners: (() => void)[] = [] - requestOverride?: (command: string) => Promise | undefined - abortRequest?: () => void - constructor(readonly handlers: JsonlRpcAgentConnectionOptions) {} - async request(command: string): Promise { - this.requests.push(command) - const override = this.requestOverride?.(command) - if (override) { - return override - } - if (command === 'get_state') { - return state - } - if (command === 'get_available_models') { - return { models: [state.model] } - } - if (command === 'get_commands') { - return { commands: [{ name: 'help' }] } - } - if (command === 'set_thinking_level') { - throw new Error('saved effort unavailable') - } - return {} - } - async send(frame: JsonlRpcRecord): Promise { - this.sent.push(frame) - } - async close(): Promise>> { - this.abortRequest?.() - this.closed = true - this.rootVerdict = this.closeResult.root - this.lastCloseResult = this.closeResult - if (this.closeResult.root === 'exited' && !this.deferExit) { - this.exit() - } - return this.closeResult - } - pauseReading(): void {} - resumeReading(): void {} - onExit(listener: () => void): void { - if (this.rootVerdict === 'exited') { - listener() - } else { - this.exitListeners.push(listener) - } - } - receive(frame: JsonlRpcRecord): void { - this.handlers.onRecord?.(frame) - } - exit(error = new Error('child exited')): void { - this.closed = true - this.rootVerdict = 'exited' - for (const listener of this.exitListeners.splice(0)) { - listener() - } - this.handlers.onExit?.(error, { - expected: false, - exit: { code: 1, signal: null, processless: false } - }) - } -} -async function setup( - options: Readonly> = {}, - eventSink?: (sink: StructuredAgentSessionEventSink) => StructuredAgentSessionEventSink -) { - const rig = await openProviderTimelineRig({ agent: 'pi', sessionId }) - const connections: FakeConnection[] = [] - const lifecycle = vi.fn(), - settled = vi.fn(), - idle = vi.fn() - const onSpawned = vi.fn(async (_process: AgentSessionProcessIdentity) => { - expect(connections.at(-1)?.requests).toEqual([]) - }) - const input: StructuredAgentSessionAcquireInput = { - identity: { - sessionId, - workspaceId: 'folder-1', - hostId: 'local', - agent: 'pi', - providerHandle: null - }, - fence: 7, - spawnToken: 'spawn-token', - options, - events: eventSink?.(rig.eventSink) ?? rig.eventSink, - onSpawned - } - const resolveLaunch = vi.fn(async () => ({ - command: '/host/bin/pi', - cwd: '/host/folder', - fullAccess: true, - previous: null - })) - const openConnection = vi.fn( - (_launch: ProviderProcessLaunch, handlers: JsonlRpcAgentConnectionOptions) => { - const connection = new FakeConnection(handlers) - connections.push(connection) - return connection - } - ) - const adapter = new PiRpcSessionAdapter({ - resolveLaunch, - readProcessStartTime: async () => 12345, - openConnection, - onLifecycle: lifecycle, - onSettled: settled, - onIdle: idle, - logger: { warn: vi.fn(), error: vi.fn() } - }) - const acquired = await adapter.acquire(input) - const connection = connections[0] - if (!connection) { - throw new Error('connection missing') - } - cleanups.push(async () => { - await adapter.closeAll().catch(() => {}) - await adapter.drainObservedExits() - adapter.acknowledgeSessionRelease(sessionId) - }) - return { - adapter, - connection, - rig, - acquired, - onSpawned, - lifecycle, - settled, - idle, - input, - resolveLaunch, - openConnection, - connections - } -} +afterEach(closePiRpcAdapterFixtures) describe('Pi RPC session ownership and delivery', () => { it('refuses a floating chat whose folder is gone with the reason the person reads', async () => { @@ -206,17 +39,16 @@ describe('Pi RPC session ownership and delivery', () => { await expect(started).rejects.toThrow('closed while starting') expect(h.connections).toHaveLength(1) await expect(h.adapter.acquire({ ...h.input, fence: 9 })).resolves.toMatchObject({ - link: { origin: 'created' } + process: { pid: 4123 } }) resolving.resolve(launch!) await Promise.resolve() expect(h.connections).toHaveLength(2) }) - it('kills the child and rejects a stalled startup when the host aborts it', async () => { + it('closes a published child during a stalled handshake without reporting started', async () => { const h = await setup() await h.adapter.closeSession(sessionId) - const controller = new AbortController() const opened = Promise.withResolvers() const reply = Promise.withResolvers() h.openConnection.mockImplementationOnce((_launch, handlers) => { @@ -228,11 +60,26 @@ describe('Pi RPC session ownership and delivery', () => { opened.resolve(connection) return connection }) - const started = h.adapter.acquire({ ...h.input, fence: 8, signal: controller.signal }) + const acquired = h.adapter.acquire({ ...h.input, fence: 8 }) const child = await opened.promise await Promise.resolve() - controller.abort(new Error('Pi closed while starting')) - await expect(started).rejects.toThrow('closed while starting') + await expect(acquired).resolves.toMatchObject({ process: { pid: 4123 } }) + h.lifecycle.mockClear() + expect(h.adapter.readCommands(sessionId)).toBeUndefined() + expect(h.adapter.holdsDispatch(sessionId)).toBe(false) + await h.adapter.closeSession(sessionId) + await h.adapter.drainObservedExits() + expect(h.lifecycle).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'ended', + startupUnproven: true, + startupUnanswered: true, + cause: 'requested-close' + }) + ) + expect(h.lifecycle).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'started', fence: 8 }) + ) expect(child.rootVerdict).toBe('exited') }) @@ -278,7 +125,7 @@ describe('Pi RPC session ownership and delivery', () => { await h.adapter.closeSession(sessionId) h.adapter.acknowledgeSessionRelease(sessionId) await expect(h.adapter.acquire({ ...h.input, fence: 8 })).resolves.toMatchObject({ - link: { origin: 'created' } + process: { pid: 4123 } }) let drained = false const delivery = h.adapter.drainObservedExits().then(() => { @@ -310,12 +157,13 @@ describe('Pi RPC session ownership and delivery', () => { spawnToken: 'spawn-token' }) expect(h.acquired.process).toEqual(h.onSpawned.mock.calls[0]?.[0]) - expect(h.acquired.link).toMatchObject({ + expect(h.acquired.link).toBeUndefined() + expect(h.started.link).toMatchObject({ handle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: file }, origin: 'created', mintedAtFence: 7 }) - expect(h.adapter.readOptionRestoreFailures(sessionId)).toEqual(['effort', 'unknown']) + expect(h.started.restoreSkippedOptions).toEqual(['unknown', 'effort']) expect(h.connection.requests).toContain('get_commands') }) @@ -587,6 +435,10 @@ describe('Pi RPC start without a model', () => { return connection }) await h.adapter.acquire({ ...h.input, fence: 8 }) + // Known by the time the start is proven: the listing is part of Pi's start. + await vi.waitFor(() => + expect(h.lifecycle).toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) + ) expect(h.adapter.startUnavailable(sessionId)).toEqual({ reason: 'notSignedIn' }) // Its root gone while its output still drains: that child says nothing more. const child = h.connections.at(-1)! diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index 139268fe0a78..181cd802d7ce 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -16,7 +16,7 @@ import { providerSpawnedProcessIdentity } from '../provider-process/provider-spa import { ProviderAcquisitionStarts } from '../provider-process/provider-acquisition-starts' import { compactPiRpcSession } from './rpc-compaction' import { buildPiRpcLaunch } from './rpc-launch' -import { piRpcProviderLink, type PiRpcResolvedLaunch } from './rpc-launch-resolution' +import type { PiRpcResolvedLaunch } from './rpc-launch-resolution' import { PiRpcSession, type PiRpcSessionDeps, type PiRpcConnection } from './rpc-session' import { PiRpcPromptError, preparePiRpcPrompt } from './rpc-prompt' import { applyPiRpcSessionOption } from './rpc-options' @@ -93,15 +93,15 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { await spawned.onSpawned(session.connection.pid) } const process = await spawned.read(session.connection.pid) - const file = await waitForPromiseWithSignal(session.start(), attempt.signal) if (session.connection.closed || attempt.signal.aborted) { throw new Error('Pi exited while starting') } + const child = session + void child.start(launch, () => this.starts.retainFailed(id, child.connection)) return { process, acquisitionGeneration: session.generation, - link: piRpcProviderLink(launch, file, input.fence, randomUUID(), Date.now()), - ...(session.startListing ? { catalogListing: session.startListing } : {}) + providerChildPhase: 'starting' } } catch (error) { if (!session) { @@ -227,10 +227,10 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { } readOptions: NonNullable = async (input) => this.session(input.sessionId, input.fence).readOptions() - readCommands = (id: string) => this.sessions.get(id)?.commands readOptionRestoreFailures(id: string): readonly string[] { - return this.sessions.get(id)?.skipped ?? [] + return this.sessions.get(id)?.startup.skipped ?? [] } + readCommands = (id: string) => this.sessions.get(id)?.startup.commands holdsDispatch(id: string): boolean { return this.sessions.get(id)?.turns.holdsDispatch ?? false } @@ -241,10 +241,14 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { return session && !session.connection.closed && session.connection.rootVerdict !== 'exited' && - session.options?.models.length === 0 + session.startup.options?.models.length === 0 ? { reason: 'notSignedIn' } : undefined } + holdsLiveProviderProcess(id: string, generation: string): boolean { + const session = this.sessions.get(id) + return session?.generation === generation && !session.connection.closed + } async closeSession(id: string, requested = true): Promise { const session = this.sessions.get(id) diff --git a/src/main/pi/rpc-session-startup.test.ts b/src/main/pi/rpc-session-startup.test.ts new file mode 100644 index 000000000000..e463ed9cb621 --- /dev/null +++ b/src/main/pi/rpc-session-startup.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' +import { AgentSessionAcquisitionExitUnprovenError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { JsonlRpcResponseError } from '../jsonl-rpc/peer' +import { + closePiRpcAdapterFixtures, + file, + restart, + reportedEvent, + sessionId, + setup, + state +} from './rpc-session-adapter.test-fixture' + +afterEach(closePiRpcAdapterFixtures) + +describe('Pi background startup', () => { + it('publishes at spawn, restores model before effort, lists models in its start and reads commands after', async () => { + const h = await setup() + const handshake = Promise.withResolvers() + const listing = Promise.withResolvers() + const commands = Promise.withResolvers() + let revision = 3 + const { acquired, child } = await restart( + h, + (connection) => { + connection.requestOverride = (command) => { + if ( + command === 'get_state' && + !h.lifecycle.mock.calls.some(([event]) => event.type === 'started') + ) { + // The listing's own state read sees what the restore set. + return connection.requests.includes('get_available_models') + ? Promise.resolve({ + ...state, + model: { provider: 'anthropic', id: 'model-2' }, + thinkingLevel: 'high' + }) + : handshake.promise + } + if (command === 'get_available_models') { + return listing.promise + } + if (command === 'get_commands') { + return commands.promise + } + return command === 'set_thinking_level' ? Promise.resolve({}) : undefined + } + }, + { options: { effort: 'high', model: 'anthropic/model-2' }, optionRevision: () => revision } + ) + expect(acquired.link).toBeUndefined() + expect(child.requests).toEqual(['get_state']) + expect(h.lifecycle).not.toHaveBeenCalled() + if (!acquired.acquisitionGeneration) { + throw new Error('acquisition generation missing') + } + expect(h.adapter.holdsLiveProviderProcess(sessionId, acquired.acquisitionGeneration)).toBe(true) + revision = 5 + handshake.resolve(state) + await vi.waitFor(() => expect(child.requests).toContain('get_available_models')) + expect(child.requests.slice(0, 4)).toEqual([ + 'get_state', + 'set_model', + 'set_thinking_level', + 'get_available_models' + ]) + // The listing is part of the start: no `started` before it answers. + expect(h.lifecycle).not.toHaveBeenCalled() + listing.resolve({ models: [{ provider: 'anthropic', id: 'model-2', reasoning: true }] }) + await vi.waitFor(() => + expect(h.lifecycle).toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) + ) + expect(h.lifecycle.mock.calls[0]?.[0]).toMatchObject({ + type: 'started', + fence: 8, + acquisitionGeneration: acquired.acquisitionGeneration, + link: { handle: { nativeId: file }, origin: 'created' }, + reportedOptions: { + model: 'anthropic/model-2', + effort: 'high', + confirmed: ['model', 'effort'] + }, + restoreSkippedOptions: [], + optionRevision: 3 + }) + // Commands never hold the start. + expect(h.adapter.readCommands(sessionId)).toBeUndefined() + commands.resolve({ commands: [{ name: 'help' }] }) + await vi.waitFor(() => expect(h.adapter.readCommands(sessionId)).toBeDefined()) + expect(h.lifecycle).toHaveBeenCalledTimes(1) + }) + + it.each(['closeSession', 'disposeSession', 'forceCloseSession', 'releaseAcquisition'] as const)( + '%s stops a held handshake without a late started event', + async (method) => { + const h = await setup() + const handshake = Promise.withResolvers() + const { child } = await restart(h, (connection) => { + connection.requestOverride = (command) => + command === 'get_state' ? handshake.promise : undefined + }) + await (method === 'releaseAcquisition' + ? h.adapter.releaseAcquisition({ sessionId }) + : h.adapter[method](sessionId)) + handshake.resolve(state) + await h.adapter.drainObservedExits() + await Promise.resolve() + expect(child.rootVerdict).toBe('exited') + expect(h.lifecycle).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ended', startupUnproven: true, startupUnanswered: true }) + ) + expect(h.lifecycle).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) + } + ) + + it.each([ + ['get_state', new JsonlRpcResponseError('get_state', 'Pi is not signed in'), true], + ['set_model', new Error('Agent RPC request timed out: set_model'), false], + ['set_model', new Error('Agent JSON-lines RPC stream closed'), false] + ] as const)( + 'fails startup on %s failure %s and keeps its diagnostic', + async (command, error, unanswered) => { + const h = await setup() + const { child } = await restart( + h, + (connection) => { + connection.requestOverride = (name) => + name === command ? Promise.reject(error) : undefined + }, + { options: { model: 'anthropic/model-2' } } + ) + await vi.waitFor(() => + expect(h.lifecycle).toHaveBeenCalledWith(expect.objectContaining({ type: 'ended' })) + ) + expect(child.rootVerdict).toBe('exited') + const ended = reportedEvent(h.lifecycle, 'ended') + expect(ended).toMatchObject({ + cause: 'unexpected-exit', + startupUnproven: true, + reason: error.message + }) + expect(ended.startupUnanswered).toBe(unanswered ? true : undefined) + if (command === 'get_state') { + expect(ended.failure?.detail).toEqual({ text: 'Pi is not signed in', audience: 'person' }) + } + expect(h.lifecycle).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) + } + ) + + it('does not retry acquisition while a failed start still owns an unproven process', async () => { + const h = await setup() + const { child } = await restart(h, (connection) => { + connection.closeResult = { root: 'unverifiable', tree: null } + connection.requestOverride = () => Promise.reject(new Error('get_state failed')) + }) + await vi.waitFor(() => expect(child.closed).toBe(true)) + await expect(h.adapter.acquire({ ...h.input, fence: 9 })).rejects.toBeInstanceOf( + AgentSessionAcquisitionExitUnprovenError + ) + expect(h.connections).toHaveLength(2) + child.closeResult = { root: 'exited', tree: 'exited' } + await expect(h.adapter.acquire({ ...h.input, fence: 10 })).resolves.toMatchObject({ + process: { pid: 4123 } + }) + }) + + it('preserves the provider exit diagnostic when transport closure failed startup first', async () => { + const h = await setup() + await restart(h, (connection) => { + connection.closeError = withProviderDiagnostic( + new Error('provider exited'), + providerDiagnostic('Pi is not signed in', 'log') + ) + connection.requestOverride = () => + Promise.reject(new Error('Agent JSON-lines RPC stream closed')) + }) + await vi.waitFor(() => + expect(h.lifecycle).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'ended', + startupUnproven: true, + failure: { + kind: 'providerStartFailed', + detail: { text: 'Pi is not signed in', audience: 'log' } + } + }) + ) + ) + }) + + it('ends a child that exits while its handshake is held', async () => { + const h = await setup() + const { child } = await restart(h, (connection) => { + connection.requestOverride = () => new Promise(() => {}) + }) + child.exit(new Error('provider died during get_state')) + await h.adapter.drainObservedExits() + expect(h.lifecycle).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'ended', + cause: 'unexpected-exit', + startupUnproven: true, + startupUnanswered: true, + reason: 'provider died during get_state' + }) + ) + expect(h.lifecycle).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) + }) + + it('rejects an invalid session file and reports a proved resumed file from the launch', async () => { + const h = await setup() + const { child } = await restart(h, (connection) => { + connection.requestOverride = (name) => + name === 'get_state' + ? Promise.resolve({ ...state, sessionFile: 'relative.jsonl' }) + : undefined + }) + await vi.waitFor(() => + expect(h.lifecycle).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ended', startupUnproven: true }) + ) + ) + expect(child.rootVerdict).toBe('exited') + const launch = await h.resolveLaunch.mock.results[0]?.value + if (!launch) { + throw new Error('launch missing') + } + h.resolveLaunch.mockResolvedValueOnce({ + ...launch, + sessionFile: file, + previous: h.started.link + }) + await restart(h, () => {}) + await vi.waitFor(() => + expect(h.lifecycle).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'started', + link: expect.objectContaining({ origin: 'resumed' }) + }) + ) + ) + expect(h.openConnection.mock.calls.at(-1)?.[0].args).toEqual( + expect.arrayContaining(['--session', file]) + ) + expect(reportedEvent(h.lifecycle, 'started').link?.handle.nativeId).toBe(file) + }) +}) diff --git a/src/main/pi/rpc-session-startup.ts b/src/main/pi/rpc-session-startup.ts new file mode 100644 index 000000000000..d24f5d5691ae --- /dev/null +++ b/src/main/pi/rpc-session-startup.ts @@ -0,0 +1,174 @@ +import { randomUUID } from 'node:crypto' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import type { + AgentSessionOptionsResult, + AgentSessionSlashCommand +} from '../../shared/agent-session-wire' +import { JsonlRpcResponseError } from '../jsonl-rpc/peer' +import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + unpickedSessionConfiguredChoice, + withLiveCatalogListing, + type AgentModelCatalogLiveListing +} from '../native-chat/agent-model-catalog/agent-model-catalog-entry' +import { piRpcProviderLink, type PiRpcResolvedLaunch } from './rpc-launch-resolution' +import { + applyPiRpcSessionOption, + piModelOptionId, + readPiRpcCommands, + readPiRpcSessionOptions +} from './rpc-options' +import { piRpcStateSchema, type PiRpcState } from './rpc-protocol' +import type { PiRpcConnection, PiRpcSessionDeps } from './rpc-session' +import { STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' + +/** What the start cannot go on without waits as long as the host's startup attempt does, its only + * deadline; the optional reads keep their own bound and never fail the start. */ +const START_REQUEST = { timeoutMs: STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } + +export class PiRpcSessionStartup { + started = false + answered = false + skipped: readonly string[] = [] + commands?: AgentSessionSlashCommand[] + /** What the start listed; a signed-out Pi lists no model. */ + options?: AgentSessionOptionsResult + private readonly controller = new AbortController() + + constructor( + private readonly input: StructuredAgentSessionAcquireInput, + private readonly generation: string, + private readonly connection: PiRpcConnection, + private readonly selected: Map, + private readonly deps: PiRpcSessionDeps, + /** A new Pi session starts on its config's model; a resumed or forked one keeps its own. */ + private readonly resolvesConfig: boolean, + private readonly setState: (state: PiRpcState) => void + ) {} + + stop(): void { + this.controller.abort(new Error('Pi closed while starting')) + } + + async run(launch: PiRpcResolvedLaunch): Promise { + const optionRevision = this.input.optionRevision?.() ?? 0 + const answer = await this.wait(this.connection.request('get_state', {}, START_REQUEST)) + this.answered = true + this.assertLive() + const state = piRpcStateSchema.parse(answer) + const link = piRpcProviderLink( + launch, + state.sessionFile, + this.input.fence, + randomUUID(), + Date.now() + ) + this.setState(state) + const current: AgentSessionOptionsResult['current'] = { + model: state.model ? piModelOptionId(state.model.provider, state.model.id) : '', + ...(state.thinkingLevel ? { effort: state.thinkingLevel } : {}) + } + const saved = this.input.options ?? {} + const skipped = Object.keys(saved).filter((key) => key !== 'model' && key !== 'effort') + // Keys a restore sent, whether or not Pi took them. + const picked = new Set() + for (const key of ['model', 'effort'] as const) { + const value = saved[key] + if (value === undefined) { + continue + } + picked.add(key) + try { + this.assertLive() + await this.wait( + applyPiRpcSessionOption(this.connection, this.selected, key, value, START_REQUEST) + ) + current[key] = value + } catch (error) { + if (!(error instanceof JsonlRpcResponseError)) { + throw error + } + skipped.push(key) + this.warn('Pi rejected a saved option', 'pi-option-restore', error, { key }) + } + } + this.assertLive() + current.confirmed = [...(current.model ? ['model'] : []), ...(current.effort ? ['effort'] : [])] + // Part of the start, as other agents' handshakes answer with theirs: a Pi that lists no model + // is signed out, and what it started on names its config's default. + const options = await this.readListing() + this.assertLive() + // What it started on names its config's default once; a later switch teaches nothing. + let catalogListing: AgentModelCatalogLiveListing | undefined + if (options) { + this.options = options + catalogListing = withLiveCatalogListing( + options, + unpickedSessionConfiguredChoice({ resolvesConfig: this.resolvesConfig, picked, ...options }) + ).catalogListing + } + this.skipped = skipped + this.started = true + this.deps.onLifecycle({ + type: 'started', + ...this.identity(), + link, + reportedOptions: options?.current ?? current, + restoreSkippedOptions: skipped, + optionRevision, + ...(catalogListing ? { catalogListing } : {}) + }) + await this.readCommands() + } + + /** Never fails the start: without it the chat only lacks the listing and the sign-in verdict. */ + private async readListing(): Promise { + try { + return await this.wait(readPiRpcSessionOptions(this.connection)) + } catch (error) { + this.assertLive() + this.warn('Pi options could not be read', 'pi-options', error) + return undefined + } + } + + private async readCommands(): Promise { + try { + this.assertLive() + const result = await this.wait(readPiRpcCommands(this.connection)) + this.assertLive() + this.commands = result.commands + } catch (error) { + this.warn('Pi commands could not be read', 'pi-commands', error) + } + } + + private assertLive(): void { + if (this.controller.signal.aborted || this.connection.closed) { + throw new Error('Pi closed while starting') + } + } + + private wait(promise: Promise): Promise { + return waitForPromiseWithSignal(promise, this.controller.signal) + } + + private identity() { + return { + sessionId: this.input.identity.sessionId, + fence: this.input.fence, + acquisitionGeneration: this.generation + } + } + + private warn(message: string, scope: string, error: unknown, fields = {}): void { + if (!this.controller.signal.aborted) { + this.deps.logger.warn(message, { + scope, + sessionId: this.input.identity.sessionId, + error, + ...fields + }) + } + } +} diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts index ae693653df4c..925a1bbf727e 100644 --- a/src/main/pi/rpc-session.ts +++ b/src/main/pi/rpc-session.ts @@ -1,7 +1,8 @@ -import type { - AgentSessionOptionsResult, - AgentSessionSlashCommand -} from '../../shared/agent-session-wire' +import { + agentSessionFailureFact, + providerDiagnostic, + providerDiagnosticOf +} from '../../shared/agent-session-failure' import type { StructuredAgentSessionAcquireInput, StructuredAgentSessionLifecycleEvent, @@ -15,15 +16,13 @@ import { type JsonlRpcAgentConnectionOptions } from '../jsonl-rpc/agent-connection' import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import { JsonlRpcResponseError } from '../jsonl-rpc/peer' import { PiRpcTurns } from './rpc-turns' import { PiRpcDialogCallbacks } from './rpc-dialog-callbacks' -import { piRpcStateSchema } from './rpc-protocol' -import { applyPiRpcSessionOption, readPiRpcCommands, readPiRpcSessionOptions } from './rpc-options' -import { - unpickedSessionConfiguredChoice, - withLiveCatalogListing, - type AgentModelCatalogLiveListing -} from '../native-chat/agent-model-catalog/agent-model-catalog-entry' +import { readPiRpcSessionOptions } from './rpc-options' +import { withLiveCatalogListing } from '../native-chat/agent-model-catalog/agent-model-catalog-entry' +import type { PiRpcResolvedLaunch } from './rpc-launch-resolution' +import { PiRpcSessionStartup } from './rpc-session-startup' export type PiRpcConnection = Pick< JsonlRpcAgentConnection, @@ -62,10 +61,7 @@ export class PiRpcSession { readonly turns: PiRpcTurns readonly dialogs: PiRpcDialogCallbacks readonly selected = new Map() - private startCatalogListing?: AgentModelCatalogLiveListing - readonly skipped: string[] = [] - commands?: AgentSessionSlashCommand[] - options?: AgentSessionOptionsResult + readonly startup: PiRpcSessionStartup requestedClose = false private publishedExit = false private failedCause?: Error @@ -145,55 +141,31 @@ export class PiRpcSession { .finally(() => this.releaseAfterExit?.()) } }) + this.startup = new PiRpcSessionStartup( + input, + generation, + this.connection, + this.selected, + deps, + resolvesConfig, + (state) => { + this.lane.apply(this.turns.context.setModel(state.model ?? undefined, Date.now())) + } + ) } - async start(): Promise { - const state = piRpcStateSchema.parse(await this.connection.request('get_state')) - this.lane.apply(this.turns.context.setModel(state.model ?? undefined, Date.now())) - // Keys a restore sent, whether or not Pi took them. - const picked = new Set() - for (const [key, value] of Object.entries(this.input.options ?? {})) { - if (!['model', 'effort'].includes(key)) { - this.skipped.push(key) - continue - } - picked.add(key) - try { - await applyPiRpcSessionOption(this.connection, this.selected, key, value) - } catch (error) { - this.skipped.push(key) - this.deps.logger.warn('Pi rejected a saved option', { - scope: 'pi-option-restore', - sessionId: this.input.identity.sessionId, - key, - error - }) - } - } - this.options = await readPiRpcSessionOptions(this.connection) - this.startCatalogListing = withLiveCatalogListing( - this.options, - unpickedSessionConfiguredChoice({ - resolvesConfig: this.resolvesConfig, - picked, - ...this.options - }) - ).catalogListing + async start(launch: PiRpcResolvedLaunch, retainFailed: () => void): Promise { try { - this.commands = (await readPiRpcCommands(this.connection)).commands + await this.startup.run(launch) } catch (error) { - this.deps.logger.warn('Pi commands could not be read', { - scope: 'pi-commands', - sessionId: this.input.identity.sessionId, - error - }) + if (!this.requestedClose) { + this.failedCause ??= error instanceof Error ? error : new Error(String(error)) + } + const result = await this.close(false).catch(() => null) + if (result?.root !== 'exited') { + retainFailed() + } } - return state.sessionFile - } - - /** What this child listed at its start, with what its config resolved; the host saves it once. */ - get startListing(): AgentModelCatalogLiveListing | undefined { - return this.startCatalogListing } /** What the child reports now; only the start says what its config resolved. */ @@ -203,6 +175,7 @@ export class PiRpcSession { async close(requested = true): ReturnType { this.requestedClose ||= requested + this.startup.stop() this.dialogs.cancelAll() return this.connection.close() } @@ -256,6 +229,8 @@ export class PiRpcSession { return } this.publishedExit = true + this.failedCause ??= error + this.startup?.stop() this.dialogs.cancelAll() this.turns.end() this.lane.finalize() @@ -281,6 +256,7 @@ export class PiRpcSession { } finally { clearTimeout(timer) } + const cause = this.failedCause ?? error this.deps.onLifecycle({ type: 'ended', sessionId: this.input.identity.sessionId, @@ -288,7 +264,20 @@ export class PiRpcSession { acquisitionGeneration: this.generation, observedAt: Date.now(), cause: this.requestedClose ? 'requested-close' : 'unexpected-exit', - reason: (this.failedCause ?? error).message + reason: cause.message, + failure: agentSessionFailureFact( + this.startup.started ? 'providerExited' : 'providerStartFailed', + { + detail: + providerDiagnosticOf(cause) ?? + providerDiagnosticOf(error) ?? + (cause instanceof JsonlRpcResponseError + ? providerDiagnostic(cause.message, 'person') + : undefined) + } + ), + ...(this.startup.started ? {} : { startupUnproven: true }), + ...(this.startup.answered ? {} : { startupUnanswered: true }) }) } diff --git a/src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts b/src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts new file mode 100644 index 000000000000..f35cd7b4657c --- /dev/null +++ b/src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts @@ -0,0 +1,94 @@ +// A message that starts a signed-out Pi reaches it once and is settled by Pi's own refusal; the +// start made for it is never replaced as a signed-out child, which would start Pi for it forever. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { readWholeAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { + hostTestMessage, + HOST_TEST_SESSION as SESSION +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createTestParams } from '../native-chat/agent-session-wire/structured-agent-session-create-test-fixture' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { piScriptedChild } from '../pi/pi-scripted-child.test-fixture' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +let directory: string | undefined +let operations = 0 +const operationId = () => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` +const caller = { callerKey: 'pi-signed-out-start-test' } + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (directory) { + await rm(directory, { recursive: true, force: true }) + directory = undefined + } +}) + +it('settles the first message of a signed-out Pi as not signed in, and starts Pi once', async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-pi-signed-out-')) + const root = directory + const script = piScriptedChild({ signedOut: true }) + const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), + resolveLaunchArgs: () => [], + resolveEnvironment: async () => ({}), + ...script.deps + }) + const firstMessage = { + clientMessageId: operationId(), + body: hostTestMessage('hello, signed-out Pi') + } + // As a new chat sends its first message: the start is made for it. + const params = createTestParams(firstMessage, { + provider: 'pi', + agent: 'pi', + accountHome: { variable: 'PI_CODING_AGENT_DIR', path: root }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-1', + workspaceKind: 'folder' + } + }) + params.envelope.clientOperationId = operationId() + const created = await host.create(caller, params, { firstMessage }) + if (!created.ok) { + throw new Error(JSON.stringify(created.refusal)) + } + // Pi answers its start after the host has published it, as a real Pi does. + await vi.waitFor(() => + expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') + ) + await new Promise((resolve) => setTimeout(resolve, 50)) + script.releaseHandshake() + const submission = async () => { + await host.flushStreamedEvents(SESSION) + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === firstMessage.clientMessageId + ) + } + + await vi.waitFor(async () => expect((await submission())?.dispatchState).toBe('rejected'), { + timeout: 5_000 + }) + expect(readWholeAgentSessionFailureFact((await submission())?.rejection)?.kind).toBe( + 'notSignedIn' + ) + // Nothing starts Pi again for a message it already refused. + await new Promise((resolve) => setTimeout(resolve, 500)) + expect(script.spawns()).toBe(1) + expect(script.closes()).toBe(0) + expect(new Set(script.prompts())).toEqual(new Set(['hello, signed-out Pi'])) +}) diff --git a/src/main/runtime/structured-agent-session-pi-startup.test.ts b/src/main/runtime/structured-agent-session-pi-startup.test.ts new file mode 100644 index 000000000000..19403cfa5aa8 --- /dev/null +++ b/src/main/runtime/structured-agent-session-pi-startup.test.ts @@ -0,0 +1,188 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + hostTestAttachParams, + hostTestMessage, + HOST_TEST_SESSION +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { piScriptedChild } from '../pi/pi-scripted-child.test-fixture' +import { peekOpenedAgentSessionRecordStore } from './agent-session-record-store-slot' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +let directory: string | undefined +let operations = 0 +const operationId = () => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (directory) { + await rm(directory, { recursive: true, force: true }) + directory = undefined + } +}) + +async function setup() { + directory = await mkdtemp(join(tmpdir(), 'orca-pi-startup-')) + const root = directory + const script = piScriptedChild() + const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), + resolveLaunchArgs: () => [], + resolveEnvironment: async () => ({}), + ...script.deps + }) + const caller = { callerKey: 'pi-startup-test' } + const params = hostTestAttachParams(null, { + provider: 'pi', + agent: 'pi', + providerHandle: undefined, + options: { unknown: 'unavailable-saved-option' }, + accountHome: { variable: 'PI_CODING_AGENT_DIR', path: root }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-1', + workspaceKind: 'folder' + } + }) + params.envelope.clientOperationId = operationId() + const attached = await host.attach(caller, params) + expect(attached.ok).toBe(true) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + return { script, host, caller, fence: attached.value.fence } +} + +function envelope(method: string, fields: Record) { + return { + sessionId: HOST_TEST_SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: + peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: HOST_TEST_SESSION, + fields + }) + } +} + +it('attaches Pi at spawn and holds the opening send until the provider answers its handshake', async () => { + const { script, host, caller, fence } = await setup() + expect(script.spawns()).toBe(1) + const body = hostTestMessage('hello while Pi is starting') + const sent = await host.send(caller, { + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: HOST_TEST_SESSION, + fields: { body } + }) + }, + body + }) + expect(sent.ok).toBe(true) + expect(script.prompts()).toEqual([]) + script.releaseHandshake() + await vi.waitFor(() => expect(script.prompts()).toEqual(['hello while Pi is starting'])) + await vi.waitFor(() => + expect(peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.options).toEqual({ + model: 'scripted/model-1', + effort: 'medium' + }) + ) + await stopStructuredAgentSessionRuntime() + expect(script.closes()).toBe(1) +}) + +it('keeps a model picked during startup when the optional state read predates applying the pick', async () => { + const { script, host, caller, fence } = await setup() + const fields = { key: 'model', value: 'scripted/model-2' } + const picked = await host.setOption(caller, { + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.setOption', + sessionId: HOST_TEST_SESSION, + fields + }) + }, + ...fields + }) + expect(picked.ok).toBe(true) + const body = hostTestMessage('first message') + const sent = await host.send(caller, { + envelope: envelope('agentSession.send', { body }), + body + }) + expect(sent.ok).toBe(true) + script.releaseHandshake() + // The pick reaches Pi before the message held for its start. + await vi.waitFor(() => expect(script.prompts()).toEqual(['first message'])) + const wire = script.wire() + expect(wire.indexOf('set_model')).toBeGreaterThan(-1) + expect(wire.indexOf('set_model')).toBeLessThan(wire.indexOf('prompt:first message')) + const { runtimeState, sessions, serialize } = host.collaboratorsForTests() + await vi.waitFor(() => expect(sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe('ready')) + await vi.waitFor(() => + expect(runtimeState.optionRevisions.current(HOST_TEST_SESSION)).toBeGreaterThan(1) + ) + await serialize(HOST_TEST_SESSION, async () => {}) + expect(peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.options?.model).toBe( + fields.value + ) +}) + +it('ends Pi on Stop and resumes its real session file when the next send starts a child', async () => { + const { script, host, caller } = await setup() + script.completeTurns = false + script.releaseHandshake() + const first = hostTestMessage('start a long turn') + expect( + ( + await host.send(caller, { + envelope: envelope('agentSession.send', { body: first }), + body: first + }) + ).ok + ).toBe(true) + await vi.waitFor(() => expect(script.prompts()).toEqual(['start a long turn'])) + expect((await host.cancel(caller, { envelope: envelope('agentSession.cancel', {}) })).ok).toBe( + true + ) + await vi.waitFor(() => expect(script.closes()).toBe(1)) + const second = hostTestMessage('resume after Stop') + expect( + ( + await host.send(caller, { + envelope: envelope('agentSession.send', { body: second }), + body: second + }) + ).ok + ).toBe(true) + await vi.waitFor(() => expect(script.spawns()).toBe(2)) + expect(script.resumes()).toBe(1) + expect(script.prompts()).toEqual(['start a long turn']) + script.releaseHandshake() + await vi.waitFor(() => + expect(script.prompts()).toEqual(['start a long turn', 'resume after Stop']) + ) +}) diff --git a/src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts b/src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts new file mode 100644 index 000000000000..66c7fd735e72 --- /dev/null +++ b/src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts @@ -0,0 +1,323 @@ +// An operation only the agent's protocol session can perform (a rewind, a goal, the recovery of a +// rewind left in doubt) never reaches a child still proving its start, for any agent. Driven through +// the real Codex and ACP adapters with only the provider child scripted. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentSessionMutationEnvelope } from '../../shared/agent-session-wire' +import { ACP_LAUNCH_SPECS } from '../acp/acp-launch-specs' +import { acpScriptedChild } from '../acp/acp-scripted-child.test-fixture' +import { acpStructuredAgentDefinition } from '../acp/acp-structured-agent-definitions' +import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' +import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' +import { + CODEX_SCRIPTED_THREAD, + codexScriptedChild +} from '../codex/codex-scripted-child.test-fixture' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import type { ScriptedAgentChild } from './structured-agent-scripted-child.test-fixture' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const CALLER = { callerKey: 'provider-operations-test' } +let root: string | undefined +let operations = 0 +/** The attach that opened the chat; a reconnecting view replays it. */ +let openedWith: Parameters[1] | undefined + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (root) { + await rm(root, { recursive: true, force: true }) + root = undefined + } +}) + +const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` + +const eventually = (assertion: () => void | Promise) => + vi.waitFor(assertion, { timeout: 10_000 }) + +function envelope( + host: StructuredAgentSessionHost, + method: string, + fields: Record +): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function openChat( + child: ScriptedAgentChild, + definition: StructuredAgentDefinition = CODEX_STRUCTURED_AGENT +): Promise { + const { agent } = definition + root = await mkdtemp(join(tmpdir(), `orca-provider-operations-${agent}-`)) + const directory = root + const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: directory, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => directory, + resolveLaunchArgs: () => [], + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), + ...child.deps + }) + const params = hostTestAttachParams(null, { + provider: agent, + agent, + accountHome: + definition.accountLocatorKind === 'opencode' + ? { kind: 'opencode', locator: { kind: 'unmanaged' } } + : { variable: definition.accountHomeVariable, path: join(directory, agent) }, + providerHandle: undefined + }) + // The runtime admits operation ids against its own clock. + params.envelope.clientOperationId = operationId() + const attached = await host.attach(CALLER, params) + expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true }) + openedWith = params + return host +} + +async function send(host: StructuredAgentSessionHost, text: string) { + const body = hostTestMessage(text) + return host.send(CALLER, { envelope: envelope(host, 'agentSession.send', { body }), body }) +} + +/** A first turn the scripted Codex recorded, then the chat put to rest with a turn to target. */ +async function codexChatAtRestWithATurn(child: ReturnType) { + child.holdHandshakes = false + const host = await openChat(child) + child.releaseHandshake() + const first = await send(host, 'first turn') + if (!first.ok) { + throw new Error(JSON.stringify(first.refusal)) + } + const { clientMessageId } = first.value + let providerItemId: string | null = null + await eventually(async () => { + const submission = (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) + expect(submission?.providerItemId).toEqual(expect.any(String)) + providerItemId = submission?.providerItemId ?? null + }) + await host.close(SESSION, 'evict') + expect(child.closes()).toBe(1) + child.holdHandshakes = true + return { + host, + itemId: agentJournalSubmissionKey(clientMessageId), + providerItemId: providerItemId! + } +} + +/** Resolves to whether `pending` settled within a short wait. */ +async function settlesSoon(pending: Promise): Promise { + let settled = false + void pending.finally(() => (settled = true)) + await new Promise((resolve) => setTimeout(resolve, 50)) + return settled +} + +it('recovers a Codex rewind left in doubt once the restarted thread is open, then delivers the send that started it', async () => { + const child = codexScriptedChild({ + 'thread/read': () => ({ thread: { id: CODEX_SCRIPTED_THREAD, status: { type: 'idle' } } }), + // The rewound turn is gone and nothing earlier is retained: the provider applied the rewind. + 'thread/turns/list': () => ({ data: [], nextCursor: null }), + 'thread/items/list': () => ({ data: [], nextCursor: null }) + }) + const { host, itemId, providerItemId } = await codexChatAtRestWithATurn(child) + const { store } = host.deps + const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch + const request = envelope(host, 'agentSession.rewind', { itemId, expectedEpoch }) + await store.admitMutationOperation({ + callerKey: CALLER.callerKey, + envelope: request, + hostFingerprint: request.payloadFingerprint, + now: Date.now() + }) + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + rewind: { + operationId: request.clientOperationId, + callerKey: CALLER.callerKey, + itemId, + providerItemId, + expectedEpoch, + phase: 'prepared', + retained: [] + } + })) + + const sent = send(host, 'after the rewind') + await eventually(() => expect(child.spawns()).toBe(2)) + expect(await settlesSoon(sent)).toBe(false) + child.releaseHandshake() + + expect(await sent).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.rewind?.phase).toBe('completed') + await eventually(() => expect(child.prompts()).toEqual(['first turn', 'after the rewind'])) +}) + +it('settles a rewind that goes in doubt while a send waits in the queue, by waiting out the start the send needs', async () => { + const child = codexScriptedChild({ + 'thread/read': () => ({ thread: { id: CODEX_SCRIPTED_THREAD, status: { type: 'idle' } } }), + 'thread/turns/list': () => ({ data: [], nextCursor: null }), + 'thread/items/list': () => ({ data: [], nextCursor: null }) + }) + const { host, itemId, providerItemId } = await codexChatAtRestWithATurn(child) + const { store } = host.deps + const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch + const queueHeld = Promise.withResolvers() + void host.collaboratorsForTests().serialize(SESSION, () => queueHeld.promise) + + // Nothing is in doubt when the send arrives; it waits in the queue. + const sent = send(host, 'after the rewind') + expect(await settlesSoon(sent)).toBe(false) + const request = envelope(host, 'agentSession.rewind', { itemId, expectedEpoch }) + await store.admitMutationOperation({ + callerKey: CALLER.callerKey, + envelope: request, + hostFingerprint: request.payloadFingerprint, + now: Date.now() + }) + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + rewind: { + operationId: request.clientOperationId, + callerKey: CALLER.callerKey, + itemId, + providerItemId, + expectedEpoch, + phase: 'prepared', + retained: [] + } + })) + queueHeld.resolve() + + // The send starts the agent to settle the rewind, and waits that start out. + await eventually(() => expect(child.spawns()).toBe(2)) + expect(await settlesSoon(sent)).toBe(false) + child.releaseHandshake() + + expect(await sent).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.rewind?.phase).toBe('completed') + await eventually(() => expect(child.prompts()).toEqual(['first turn', 'after the rewind'])) +}) + +it('answers a rewind asked of a resting Codex chat from the thread it opens, leaving nothing in doubt', async () => { + const child = codexScriptedChild({ + 'thread/resume': (params) => ({ + thread: { id: params?.threadId, historyMode: 'legacy' }, + model: 'scripted-model' + }) + }) + const { host, itemId } = await codexChatAtRestWithATurn(child) + const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch + + const rewound = host.rewind(CALLER, { + itemId, + expectedEpoch, + envelope: envelope(host, 'agentSession.rewind', { itemId, expectedEpoch }) + }) + await eventually(() => expect(child.spawns()).toBe(2)) + expect(await settlesSoon(rewound)).toBe(false) + child.releaseHandshake() + + // Only the opened thread says its history cannot be rewound. + expect(await rewound).toMatchObject({ + ok: false, + refusal: { rewindReason: 'history-not-paginated' } + }) + expect(host.deps.store.getRecord(SESSION)?.rewind).toBeUndefined() + expect(await send(host, 'still sends')).toMatchObject({ ok: true }) + await eventually(() => expect(child.prompts()).toEqual(['first turn', 'still sends'])) +}) + +it('sets a goal asked of a starting Codex chat on the thread once it is open', async () => { + const child = codexScriptedChild() + const host = await openChat(child) + const change = { kind: 'set' as const, objective: 'Ship the release' } + + const goal = host.changeThreadGoal(CALLER, { + envelope: envelope(host, 'agentSession.threadGoal', { change }), + change + }) + expect(await settlesSoon(goal)).toBe(false) + child.releaseHandshake() + + expect(await goal).toMatchObject({ ok: true }) + expect(child.requests().filter(({ method }) => method.startsWith('thread/goal'))).toEqual([ + { + method: 'thread/goal/set', + params: { threadId: CODEX_SCRIPTED_THREAD, objective: 'Ship the release', status: 'active' } + } + ]) +}) + +it('keeps a goal waiting on a starting Codex chat through a re-attach, then sets it once the thread is open', async () => { + const child = codexScriptedChild() + const host = await openChat(child) + const change = { kind: 'set' as const, objective: 'Ship the release' } + + const goal = host.changeThreadGoal(CALLER, { + envelope: envelope(host, 'agentSession.threadGoal', { change }), + change + }) + expect(await settlesSoon(goal)).toBe(false) + // A view reconnects while the chat is still starting: its replayed attach keeps the live child. + if (!openedWith) { + throw new Error('the chat was not opened') + } + expect(await host.attach(CALLER, openedWith)).toMatchObject({ ok: true, replayed: true }) + expect(child.spawns()).toBe(1) + expect(await settlesSoon(goal)).toBe(false) + child.releaseHandshake() + + expect(await goal).toMatchObject({ ok: true }) + expect(child.requests().some(({ method }) => method === 'thread/goal/set')).toBe(true) +}) + +it('answers a goal asked of an ACP agent, which has none, without waiting out its start', async () => { + const spec = ACP_LAUNCH_SPECS[0]! + const child = acpScriptedChild(spec.agent)() + const host = await openChat(child, acpStructuredAgentDefinition(spec)) + const change = { kind: 'set' as const, objective: 'Ship the release' } + + const goal = host.changeThreadGoal(CALLER, { + envelope: envelope(host, 'agentSession.threadGoal', { change }), + change + }) + expect(await settlesSoon(goal)).toBe(true) + expect(await goal).toMatchObject({ + ok: false, + refusal: { details: { reason: 'goalsUnsupported' } } + }) + child.releaseHandshake() + + expect(await send(host, 'after the goal')).toMatchObject({ ok: true }) + await eventually(() => expect(child.prompts()).toEqual(['after the goal'])) +}) diff --git a/src/main/runtime/structured-agent-session-transport-overrides.ts b/src/main/runtime/structured-agent-session-transport-overrides.ts index 550760f812c8..249f5057ebfb 100644 --- a/src/main/runtime/structured-agent-session-transport-overrides.ts +++ b/src/main/runtime/structured-agent-session-transport-overrides.ts @@ -4,10 +4,13 @@ import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-struct import type { CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' import type { StructuredAgentSessionStartupLimits } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' import type { PiRpcSessionDeps } from '../pi/rpc-session' +import type { PiRpcSessionAdapterDeps } from '../pi/rpc-session-adapter' /** Provider transports are overridden only to drive the runtime against scripted children. */ export type StructuredAgentSessionTransportOverrides = { openPiConnection?: PiRpcSessionDeps['openConnection'] + /** Scripted Pi children resolve their launch here, so no test reaches a real Pi binary. */ + resolvePiLaunch?: PiRpcSessionAdapterDeps['resolveLaunch'] openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] openAcpConnection?: AcpStructuredSessionAdapterDeps['connect'] /** Answers an ACP launch's command lookup and version probe for a scripted child; every launch 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 005/127] 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 006/127] 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 007/127] 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 008/127] 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 009/127] 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 010/127] 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 011/127] 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 012/127] 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 013/127] 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 014/127] 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 015/127] 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 016/127] 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 017/127] 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 39eb5270c5c9151a3d22c51301efebad30af8fa4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:05:46 -0700 Subject: [PATCH 018/127] Await Pi open and fail signed-out construction --- .../structured-agent-session-host-delivery.ts | 3 +- ...tured-agent-session-outgrown-child.test.ts | 231 +++---------- ...structured-agent-session-outgrown-child.ts | 69 +--- src/main/pi/pi-scripted-child.test-fixture.ts | 11 +- .../pi/rpc-session-adapter.test-fixture.ts | 9 +- src/main/pi/rpc-session-adapter.test.ts | 37 ++- src/main/pi/rpc-session-adapter.ts | 36 +- src/main/pi/rpc-session-startup.test.ts | 308 ++++++------------ src/main/pi/rpc-session-startup.ts | 102 +++--- src/main/pi/rpc-session.ts | 48 ++- ...-agent-session-pi-signed-out-start.test.ts | 37 ++- 11 files changed, 313 insertions(+), 578 deletions(-) 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 3f5e70fc7871..8ea0b209246e 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 @@ -83,7 +83,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { const session = input.clientDelivery.readStopping(sessionId) ? undefined : sessions.get(sessionId) - await retireOutgrownStructuredAgentSessionChild(sessionId, session, startedFor, { + await retireOutgrownStructuredAgentSessionChild(sessionId, session, { work: { childWork: () => input.clientDelivery.readChildWork(sessionId), hasOpenDispatch: () => { @@ -92,7 +92,6 @@ export function createStructuredAgentSessionConversationDelivery(input: { }, providerHoldsDispatch: () => deps.adapter.holdsDispatch?.(sessionId) === true }, - startUnavailable: () => deps.adapter.startUnavailable?.(sessionId), launchPermission: async () => { const record = deps.store.getRecord(sessionId) return record ? structuredAgentSessionLaunchPermission(deps, record) : null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts index df042438c2eb..8c8d4fdb960b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts @@ -1,23 +1,9 @@ -// A child the chat outgrew is replaced before the next send: one that reported it is not signed in, -// so a sign-in made since reaches a new child, and one launched with a permission mode the chat no -// longer has, so the new mode reaches a new child. +// An idle child launched under a different permission mode is replaced before the next send. -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { - acceptedDispatch, - createRestTestRig, - foundRestTestChat, - REST_TEST_CALLER as CALLER, - REST_TEST_SESSION as SESSION, - restTestSend, - type RestTestRig -} from './structured-agent-session-rest-test-rig' -import { - retireOutgrownStructuredAgentSessionChild, - structuredAgentSessionChildReportedSignedOut -} from './structured-agent-session-outgrown-child' +import { retireOutgrownStructuredAgentSessionChild } from './structured-agent-session-outgrown-child' import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' @@ -28,109 +14,23 @@ import type { AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' -import type { AgentSessionUnavailable } from '../../../shared/agent-session-availability' -import type { JournalItemLinkageVisitor } from '../agent-session-journal/journal-store-contracts' import type { StructuredAgentSessionChildWorkReads } from './structured-agent-session-idle-sweep' -describe('a send after the agent said it is not signed in', () => { - let rig: RestTestRig - - beforeEach(async () => { - rig = await createRestTestRig() - }) - - afterEach(async () => { - await rig.dispose() - }) - - const fence = () => rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 - - it('goes to a new agent, and the new agent is kept for the sends after it', async () => { - await foundRestTestChat(rig) - rig.adapter.dispatch.mockImplementationOnce(async () => ({ - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), { - surface: 'rejection' - }) - })) - expect((await rig.host.send(CALLER, restTestSend('signed out', fence()))).ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) - expect(rig.adapter.acquire).toHaveBeenCalledTimes(1) - rig.adapter.closeSession.mockClear() - - expect((await rig.host.send(CALLER, restTestSend('signed in now', fence()))).ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(3)) - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) - expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) - - // The earlier child's report does not follow the new one. - expect((await rig.host.send(CALLER, restTestSend('and again', fence()))).ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(4)) - expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) - }) - - // A Pi that started with no model keeps a placeholder: a sign-in made since reaches a new Pi only. - it('goes to a new agent when the running one started signed out', async () => { - await foundRestTestChat(rig) - rig.adapter.startUnavailable.mockReturnValueOnce({ reason: 'notSignedIn' }) - rig.adapter.closeSession.mockClear() - expect((await rig.host.send(CALLER, restTestSend('signed in since', fence()))).ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) - expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) - }) - - it('keeps the agent after any other rejection', async () => { - await foundRestTestChat(rig) - rig.adapter.dispatch.mockImplementationOnce(async () => ({ - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { - surface: 'rejection' - }) - })) - expect((await rig.host.send(CALLER, restTestSend('refused', fence()))).ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) - rig.adapter.dispatch.mockImplementation(async () => acceptedDispatch()) - - expect((await rig.host.send(CALLER, restTestSend('next', fence()))).ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(3)) - expect(rig.adapter.acquire).toHaveBeenCalledTimes(1) - }) -}) - type Row = { itemId: string; fence: number; agentId?: string; body: AgentJournalItemBody } -const statusRow = ( - fence: number, - kind: 'notSignedIn' | 'providerExited', - agentId?: string -): Row => ({ - itemId: `status-${fence}-${kind}-${agentId ?? 'root'}`, - fence, - ...(agentId ? { agentId } : {}), - body: { - kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'row' }) - } -}) -const rejected = (fence: number) => ({ - fence, - dispatchState: 'rejected' as const, - rejection: agentSessionFailureFact('notSignedIn') -}) const childAt = ( overrides: Partial = {} ): StructuredAgentSessionProviderChild => ({ generation: 'generation-2', fence: 2, phase: 'ready', + permissionMode: 'ask', ...overrides }) const conversation = ( input: { child?: StructuredAgentSessionProviderChild | null items?: Row[] - submissions?: ReturnType[] activeTurnId?: string newestTurnEndedAt?: number } = {} @@ -139,14 +39,8 @@ const conversation = ( return { child: input.child === undefined ? childAt() : input.child, journal: { - submissions: () => input.submissions ?? [], visitItems: (visit: (itemId: string, sequence: number, body: AgentJournalItemBody) => void) => items.forEach((item, index) => visit(item.itemId, index, item.body)), - visitItemsWithLinkage: (visit: JournalItemLinkageVisitor) => - items.forEach((item, index) => - visit(item.itemId, index, item.body, item.agentId ? { agentId: item.agentId } : {}) - ), - itemFence: (itemId: string) => items.find((item) => item.itemId === itemId)?.fence, activeTurnId: () => input.activeTurnId ?? null, newestTurn: (): AgentJournalTurnLifecycle | null => input.newestTurnEndedAt === undefined @@ -156,41 +50,39 @@ const conversation = ( } } -describe('structuredAgentSessionChildReportedSignedOut', () => { - const reported = (input: Parameters[0]) => - structuredAgentSessionChildReportedSignedOut(conversation(input)) - - it('reads a not-signed-in row the running child wrote', () => { - expect(reported({ items: [statusRow(2, 'notSignedIn')] })).toBe(true) - }) - - it('ignores one an earlier child or a subagent wrote, and any other failure', () => { - expect(reported({ items: [statusRow(1, 'notSignedIn')] })).toBe(false) - expect(reported({ items: [statusRow(2, 'notSignedIn', 'subagent-1')] })).toBe(false) - expect(reported({ items: [statusRow(2, 'providerExited')] })).toBe(false) - }) - - it('reads a send rejected as not signed in at this child, not at an earlier one', () => { - expect(reported({ submissions: [rejected(2)] })).toBe(true) - expect(reported({ submissions: [rejected(1)] })).toBe(false) - }) - - it('leaves a child that is starting, closing, or absent', () => { - const items = [statusRow(2, 'notSignedIn')] - expect(reported({ items, child: childAt({ phase: 'starting' }) })).toBe(false) - expect(reported({ items, child: null })).toBe(false) - }) - - it("reads the running child's own start, and only a signed-out one", () => { - const fromStart = (startUnavailable: AgentSessionUnavailable, child = childAt({})) => - structuredAgentSessionChildReportedSignedOut(conversation({ child }), startUnavailable) - expect(fromStart({ reason: 'notSignedIn' })).toBe(true) - expect(fromStart({ reason: 'cliMissing' })).toBe(false) - expect(fromStart({ reason: 'notSignedIn' }, childAt({ phase: 'starting' }))).toBe(false) - }) -}) - describe('retireOutgrownStructuredAgentSessionChild', () => { + it('keeps a signed-out child until normal failure settlement closes it', async () => { + const stopAgent = vi.fn(async () => undefined) + await retireOutgrownStructuredAgentSessionChild( + 'session', + conversation({ + child: childAt({ permissionMode: undefined }), + items: [ + { + itemId: 'signed-out', + fence: 2, + body: { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), { + surface: 'row' + }) + } + } + ] + }), + { + work: { + childWork: () => [], + hasOpenDispatch: () => false, + providerHoldsDispatch: () => false + }, + launchPermission: async () => 'full-access', + stopAgent, + logger: { warn: vi.fn(), error: vi.fn() } + } + ) + expect(stopAgent).not.toHaveBeenCalled() + }) const liveChildWork: AgentChildWorkView = { id: 'subagent-1', kind: 'agent', @@ -206,54 +98,30 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { hasOpenDispatch: () => false, providerHoldsDispatch: () => false } - const signedOut = { submissions: [rejected(2)] } + const outgrown = { child: childAt({ permissionMode: 'ask' }) } const retire = async ( input: Parameters[0], work: Partial = {}, stopAgent = vi.fn(async () => undefined), - launchPermission?: () => Promise, - startedFor = 'next-send' + launchPermission: () => Promise = async () => 'full-access' ) => { const warn = vi.fn() - await retireOutgrownStructuredAgentSessionChild('session', conversation(input), startedFor, { + await retireOutgrownStructuredAgentSessionChild('session', conversation(input), { work: { ...idle, ...work }, - ...(launchPermission ? { launchPermission } : {}), + launchPermission, stopAgent, logger: { warn, error: vi.fn() } }) return { stopAgent, warn } } - it('stops a signed-out child that owes nothing', async () => { - expect((await retire(signedOut)).stopAgent).toHaveBeenCalledWith('session') - }) - - it('keeps a child started for the send it is about to take', async () => { - const own = await retire( - { ...signedOut, child: childAt({ startedFor: 'next-send' }) }, - {}, - undefined, - undefined, - 'next-send' - ) - expect(own.stopAgent).not.toHaveBeenCalled() - const earlier = await retire( - { ...signedOut, child: childAt({ startedFor: 'earlier-send' }) }, - {}, - undefined, - undefined, - 'next-send' - ) - expect(earlier.stopAgent).toHaveBeenCalledWith('session') - }) - it('keeps it while it owes work the idle sweep also protects', async () => { // A pending prompt is the shared check's too; the idle sweep's tests cover that case. for (const kept of [ - await retire({ ...signedOut, activeTurnId: 'turn-1' }), - await retire(signedOut, { childWork: () => [liveChildWork] }), - await retire(signedOut, { hasOpenDispatch: () => true }), - await retire(signedOut, { providerHoldsDispatch: () => true }) + await retire({ ...outgrown, activeTurnId: 'turn-1' }), + await retire(outgrown, { childWork: () => [liveChildWork] }), + await retire(outgrown, { hasOpenDispatch: () => true }), + await retire(outgrown, { providerHoldsDispatch: () => true }) ]) { expect(kept.stopAgent).not.toHaveBeenCalled() } @@ -268,12 +136,12 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { settledAt: 200 } const after = await retire( - { ...signedOut, newestTurnEndedAt: 100 }, + { ...outgrown, newestTurnEndedAt: 100 }, { childWork: () => [settled] } ) expect(after.stopAgent).not.toHaveBeenCalled() const before = await retire( - { ...signedOut, newestTurnEndedAt: 300 }, + { ...outgrown, newestTurnEndedAt: 300 }, { childWork: () => [settled] } ) expect(before.stopAgent).toHaveBeenCalledWith('session') @@ -281,7 +149,7 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { it('logs a stop that fails and lets the send go on', async () => { const { warn } = await retire( - signedOut, + outgrown, {}, vi.fn(async () => { throw new Error('exit not proven') @@ -400,7 +268,10 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { it('never reads the mode for a child launched with none', async () => { const current = now('full-access') - expect((await retire({}, {}, undefined, current)).stopAgent).not.toHaveBeenCalled() + expect( + (await retire({ child: childAt({ permissionMode: undefined }) }, {}, undefined, current)) + .stopAgent + ).not.toHaveBeenCalled() expect(current).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts index 10e3fad53df3..3c0d7fff55e1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts @@ -1,10 +1,5 @@ -// A child the chat outgrew (signed out, or launched with another permission mode) is replaced before -// the next send: agents read their login and permission mode only when they start. +// An idle child launched with another permission mode is replaced before the next send. -import type { AgentSessionUnavailable } from '../../../shared/agent-session-availability' -import { readWholeAgentSessionFailureFact } from '../../../shared/agent-session-failure' -import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -17,53 +12,7 @@ import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permiss /** What the check reads of a conversation; every read skips building a snapshot. */ type OutgrownChildReading = Pick & { - journal: Pick< - AgentSessionJournal, - 'visitItems' | 'visitItemsWithLinkage' | 'itemFence' | 'activeTurnId' | 'newestTurn' - > & { - submissions(): readonly Pick[] - } -} - -const signedOut = (failure: unknown): boolean => - readWholeAgentSessionFailureFact(failure)?.kind === 'notSignedIn' - -/** The running child itself said it is not signed in: its start, a send it rejected, or a row it - * wrote. Each child holds its own fence, so matching it scopes both to this child; a new one has - * nothing. `startUnavailable` is the adapter's read of that child's start. */ -export function structuredAgentSessionChildReportedSignedOut( - session: OutgrownChildReading, - startUnavailable?: AgentSessionUnavailable -): boolean { - const { child, journal } = session - if (!child || child.phase === 'starting' || child.close) { - return false - } - if (startUnavailable?.reason === 'notSignedIn') { - return true - } - if ( - journal - .submissions() - .some( - (submission) => - submission.dispatchState === 'rejected' && - submission.fence === child.fence && - signedOut(submission.rejection) - ) - ) { - return true - } - // The session's own agent only: a subagent's sign-in failure says nothing about its parent. - let found = false - journal.visitItemsWithLinkage((itemId, _sequence, body, attribution) => { - found ||= - body.kind === 'status' && - isRootAgentJournalItem(attribution) && - signedOut(body.failure) && - journal.itemFence(itemId) === child.fence - }) - return found + journal: Pick } /** Child work that settled after the lead's newest turn ended: the lead may still owe the turn @@ -91,18 +40,14 @@ async function launchedWithOtherPermissionMode( return mode !== null && mode !== child.permissionMode } -/** For a caller inside the session's serialize, before it hands the next send (`startedFor`) to - * the child. A child started for that same send keeps it: the send has not reached any child yet, - * and the agent's own refusal settles it once, where a new child would refuse it again forever. - * Work the child still owes keeps it, as it keeps an idle one from the sweep. A stop that fails +/** For a caller inside the session's serialize, before it hands the next send to + * the child. Work the child still owes keeps it, as it keeps an idle one from the sweep. A stop that fails * leaves the close begun, which the start joins. */ export async function retireOutgrownStructuredAgentSessionChild( sessionId: string, session: OutgrownChildReading | undefined, - startedFor: string, deps: { work: StructuredAgentSessionChildWorkReads - startUnavailable?: () => AgentSessionUnavailable | undefined /** The mode a start of the chat would launch with now. */ launchPermission?: () => Promise stopAgent: (sessionId: string) => Promise @@ -111,11 +56,7 @@ export async function retireOutgrownStructuredAgentSessionChild( ): Promise { if ( !session || - !( - (session.child?.startedFor !== startedFor && - structuredAgentSessionChildReportedSignedOut(session, deps.startUnavailable?.())) || - (await launchedWithOtherPermissionMode(session.child, deps.launchPermission)) - ) || + !(await launchedWithOtherPermissionMode(session.child, deps.launchPermission)) || structuredAgentSessionChildHasOpenWork(session.journal, deps.work) || owesWakeUp(session.journal, deps.work.childWork()) ) { diff --git a/src/main/pi/pi-scripted-child.test-fixture.ts b/src/main/pi/pi-scripted-child.test-fixture.ts index 635c30bc8a75..5c6c4d38dec1 100644 --- a/src/main/pi/pi-scripted-child.test-fixture.ts +++ b/src/main/pi/pi-scripted-child.test-fixture.ts @@ -29,7 +29,7 @@ class ScriptedPiConnection implements PiRpcConnection { private readonly wire: string[], private readonly onClosed: () => void, number: number, - /** A Pi with no login: it lists no model and fails every prompt as not signed in. */ + /** A Pi with no login lists no model. */ private readonly signedOut = false ) { this.pid = 42_000 + number @@ -195,6 +195,7 @@ class ScriptedPiConnection implements PiRpcConnection { export type ScriptedPiChild = ScriptedAgentChild & { /** Every request command and prompt any spawn received, in order. */ wire(): readonly string[] + signIn(): void } export const piScriptedChild = (options: { signedOut?: boolean } = {}): ScriptedPiChild => { @@ -203,6 +204,7 @@ export const piScriptedChild = (options: { signedOut?: boolean } = {}): Scripted const wire: string[] = [] let resumed = 0 let closed = 0 + let signedOut = options.signedOut ?? false const script: ScriptedPiChild = { holdHandshakes: true, completeTurns: true, @@ -242,7 +244,7 @@ export const piScriptedChild = (options: { signedOut?: boolean } = {}): Scripted closed += 1 }, children.length + 1, - options.signedOut + signedOut ) children.push(child) return child @@ -255,7 +257,10 @@ export const piScriptedChild = (options: { signedOut?: boolean } = {}): Scripted spawns: () => children.length, resumes: () => resumed, closes: () => closed, - wire: () => wire + wire: () => wire, + signIn: () => { + signedOut = false + } } return script } diff --git a/src/main/pi/rpc-session-adapter.test-fixture.ts b/src/main/pi/rpc-session-adapter.test-fixture.ts index 573d28d9a678..10810ec4abf0 100644 --- a/src/main/pi/rpc-session-adapter.test-fixture.ts +++ b/src/main/pi/rpc-session-adapter.test-fixture.ts @@ -168,9 +168,12 @@ export async function setup( logger: { warn: vi.fn(), error: vi.fn() } }) const acquired = await adapter.acquire(input) - // Commands are read after the start; waiting for them leaves nothing of it in flight. - await vi.waitFor(() => expect(adapter.readCommands(sessionId)).toBeDefined()) - const started = reportedEvent(lifecycle, 'started') + const started = await adapter.open({ + sessionId, + fence: 7, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) lifecycle.mockClear() const connection = connections[0] if (!connection) { diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 48a0b9bc5bce..e401cbed2096 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -63,7 +63,16 @@ describe('Pi RPC session ownership and delivery', () => { const acquired = h.adapter.acquire({ ...h.input, fence: 8 }) const child = await opened.promise await Promise.resolve() - await expect(acquired).resolves.toMatchObject({ process: { pid: 4123 } }) + const owned = await acquired + expect(owned).toMatchObject({ process: { pid: 4123 } }) + const opening = h.adapter + .open({ + sessionId, + fence: 8, + acquisitionGeneration: owned.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + .catch((error: unknown) => error) h.lifecycle.mockClear() expect(h.adapter.readCommands(sessionId)).toBeUndefined() expect(h.adapter.holdsDispatch(sessionId)).toBe(false) @@ -73,7 +82,6 @@ describe('Pi RPC session ownership and delivery', () => { expect.objectContaining({ type: 'ended', startupUnproven: true, - startupUnanswered: true, cause: 'requested-close' }) ) @@ -81,6 +89,7 @@ describe('Pi RPC session ownership and delivery', () => { expect.objectContaining({ type: 'started', fence: 8 }) ) expect(child.rootVerdict).toBe('exited') + expect(await opening).toBeInstanceOf(Error) }) it('retains final events when exit publication times out under journal backpressure', async () => { @@ -422,9 +431,8 @@ describe('Pi RPC session ownership and delivery', () => { }) describe('Pi RPC start without a model', () => { - it('reports a start that listed no model as signed out, for that child only', async () => { + it('refuses a successfully empty listing and leaves cleanup to the host', async () => { const h = await setup() - expect(h.adapter.startUnavailable(sessionId)).toBeUndefined() await h.adapter.closeSession(sessionId) h.openConnection.mockImplementationOnce((_launch, handlers) => { const connection = new FakeConnection(handlers) @@ -434,19 +442,18 @@ describe('Pi RPC start without a model', () => { h.connections.push(connection) return connection }) - await h.adapter.acquire({ ...h.input, fence: 8 }) - // Known by the time the start is proven: the listing is part of Pi's start. - await vi.waitFor(() => - expect(h.lifecycle).toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) - ) - expect(h.adapter.startUnavailable(sessionId)).toEqual({ reason: 'notSignedIn' }) - // Its root gone while its output still drains: that child says nothing more. + const acquired = await h.adapter.acquire({ ...h.input, fence: 8 }) + await expect( + h.adapter.open({ + sessionId, + fence: 8, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + ).rejects.toMatchObject({ reason: 'notSignedIn' }) const child = h.connections.at(-1)! - child.rootVerdict = 'exited' expect(child.closed).toBe(false) - expect(h.adapter.startUnavailable(sessionId)).toBeUndefined() - child.rootVerdict = 'live' await h.adapter.closeSession(sessionId) - expect(h.adapter.startUnavailable(sessionId)).toBeUndefined() + expect(child.closed).toBe(true) }) }) diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index 181cd802d7ce..6dc725194c29 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -1,6 +1,5 @@ import { randomUUID } from 'node:crypto' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import type { AgentSessionUnavailable } from '../../shared/agent-session-availability' import { agentSessionFailureFact } from '../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import { @@ -81,7 +80,7 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { } const spec = buildPiRpcLaunch(launch) const fresh = !launch.sessionFile && !launch.forkFile - session = new PiRpcSession(input, randomUUID(), spec, this.deps, fresh) + session = new PiRpcSession(input, randomUUID(), spec, this.deps, launch, fresh) this.sessions.set(id, session) this.starts.track(attempt, session.connection) const spawned = providerSpawnedProcessIdentity( @@ -96,8 +95,6 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { if (session.connection.closed || attempt.signal.aborted) { throw new Error('Pi exited while starting') } - const child = session - void child.start(launch, () => this.starts.retainFailed(id, child.connection)) return { process, acquisitionGeneration: session.generation, @@ -125,6 +122,19 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { } } + open: NonNullable = async (input) => { + const session = this.sessions.get(input.sessionId) + if ( + !session || + session.input.fence !== input.fence || + session.generation !== input.acquisitionGeneration || + session.connection.closed + ) { + throw new Error('Pi child changed before it opened') + } + return session.open(input.signal) + } + dispatch: StructuredAgentSessionAdapter['dispatch'] = async (input) => { const session = this.session(input.sessionId, input.fence) let prompt @@ -234,17 +244,6 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { holdsDispatch(id: string): boolean { return this.sessions.get(id)?.turns.holdsDispatch ?? false } - /** Started with no model listed: Pi keeps a placeholder, so every prompt this child takes fails - * as not signed in, even after a sign-in, which only a new Pi reads. */ - startUnavailable(id: string): AgentSessionUnavailable | undefined { - const session = this.sessions.get(id) - return session && - !session.connection.closed && - session.connection.rootVerdict !== 'exited' && - session.startup.options?.models.length === 0 - ? { reason: 'notSignedIn' } - : undefined - } holdsLiveProviderProcess(id: string, generation: string): boolean { const session = this.sessions.get(id) return session?.generation === generation && !session.connection.closed @@ -295,7 +294,12 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { } private session(id: string, fence: number): PiRpcSession { const session = this.sessions.get(id) - if (!session || session.input.fence !== fence || session.connection.closed) { + if ( + !session || + session.input.fence !== fence || + session.connection.closed || + !session.openedLink + ) { throw new Error('Pi session is not live under this fence') } return session diff --git a/src/main/pi/rpc-session-startup.test.ts b/src/main/pi/rpc-session-startup.test.ts index e463ed9cb621..eb426dd06b2e 100644 --- a/src/main/pi/rpc-session-startup.test.ts +++ b/src/main/pi/rpc-session-startup.test.ts @@ -1,12 +1,8 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' -import { AgentSessionAcquisitionExitUnprovenError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { afterEach, describe, expect, it } from 'vitest' import { JsonlRpcResponseError } from '../jsonl-rpc/peer' import { closePiRpcAdapterFixtures, - file, restart, - reportedEvent, sessionId, setup, state @@ -14,235 +10,129 @@ import { afterEach(closePiRpcAdapterFixtures) -describe('Pi background startup', () => { - it('publishes at spawn, restores model before effort, lists models in its start and reads commands after', async () => { +describe('Pi host awaited open', () => { + it('owns the process before RPC and restores model before effort during open', async () => { const h = await setup() - const handshake = Promise.withResolvers() - const listing = Promise.withResolvers() - const commands = Promise.withResolvers() - let revision = 3 + const stateReply = Promise.withResolvers() const { acquired, child } = await restart( h, (connection) => { - connection.requestOverride = (command) => { - if ( - command === 'get_state' && - !h.lifecycle.mock.calls.some(([event]) => event.type === 'started') - ) { - // The listing's own state read sees what the restore set. - return connection.requests.includes('get_available_models') - ? Promise.resolve({ - ...state, - model: { provider: 'anthropic', id: 'model-2' }, - thinkingLevel: 'high' - }) - : handshake.promise - } - if (command === 'get_available_models') { - return listing.promise - } - if (command === 'get_commands') { - return commands.promise - } - return command === 'set_thinking_level' ? Promise.resolve({}) : undefined - } + connection.requestOverride = (command) => + command === 'get_state' && connection.requests.length === 1 + ? stateReply.promise + : command === 'set_thinking_level' + ? Promise.resolve({}) + : undefined }, - { options: { effort: 'high', model: 'anthropic/model-2' }, optionRevision: () => revision } - ) - expect(acquired.link).toBeUndefined() - expect(child.requests).toEqual(['get_state']) - expect(h.lifecycle).not.toHaveBeenCalled() - if (!acquired.acquisitionGeneration) { - throw new Error('acquisition generation missing') - } - expect(h.adapter.holdsLiveProviderProcess(sessionId, acquired.acquisitionGeneration)).toBe(true) - revision = 5 - handshake.resolve(state) - await vi.waitFor(() => expect(child.requests).toContain('get_available_models')) - expect(child.requests.slice(0, 4)).toEqual([ - 'get_state', - 'set_model', - 'set_thinking_level', - 'get_available_models' - ]) - // The listing is part of the start: no `started` before it answers. - expect(h.lifecycle).not.toHaveBeenCalled() - listing.resolve({ models: [{ provider: 'anthropic', id: 'model-2', reasoning: true }] }) - await vi.waitFor(() => - expect(h.lifecycle).toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) + { options: { model: 'anthropic/model-2', effort: 'high' } } ) - expect(h.lifecycle.mock.calls[0]?.[0]).toMatchObject({ - type: 'started', + expect(child.requests).toEqual([]) + expect(acquired.process.pid).toBe(4123) + const opening = h.adapter.open({ + sessionId, fence: 8, - acquisitionGeneration: acquired.acquisitionGeneration, - link: { handle: { nativeId: file }, origin: 'created' }, - reportedOptions: { - model: 'anthropic/model-2', - effort: 'high', - confirmed: ['model', 'effort'] - }, - restoreSkippedOptions: [], - optionRevision: 3 + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal }) - // Commands never hold the start. - expect(h.adapter.readCommands(sessionId)).toBeUndefined() - commands.resolve({ commands: [{ name: 'help' }] }) - await vi.waitFor(() => expect(h.adapter.readCommands(sessionId)).toBeDefined()) - expect(h.lifecycle).toHaveBeenCalledTimes(1) + expect(child.requests).toEqual(['get_state']) + stateReply.resolve(state) + await expect(opening).resolves.toMatchObject({ + reportedOptions: { model: 'anthropic/model-1' } + }) + expect(child.requests.indexOf('set_model')).toBeLessThan( + child.requests.indexOf('set_thinking_level') + ) + expect(h.lifecycle).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) }) - it.each(['closeSession', 'disposeSession', 'forceCloseSession', 'releaseAcquisition'] as const)( - '%s stops a held handshake without a late started event', - async (method) => { - const h = await setup() - const handshake = Promise.withResolvers() - const { child } = await restart(h, (connection) => { - connection.requestOverride = (command) => - command === 'get_state' ? handshake.promise : undefined - }) - await (method === 'releaseAcquisition' - ? h.adapter.releaseAcquisition({ sessionId }) - : h.adapter[method](sessionId)) - handshake.resolve(state) - await h.adapter.drainObservedExits() - await Promise.resolve() - expect(child.rootVerdict).toBe('exited') - expect(h.lifecycle).toHaveBeenCalledWith( - expect.objectContaining({ type: 'ended', startupUnproven: true, startupUnanswered: true }) - ) - expect(h.lifecycle).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) - } - ) - - it.each([ - ['get_state', new JsonlRpcResponseError('get_state', 'Pi is not signed in'), true], - ['set_model', new Error('Agent RPC request timed out: set_model'), false], - ['set_model', new Error('Agent JSON-lines RPC stream closed'), false] - ] as const)( - 'fails startup on %s failure %s and keeps its diagnostic', - async (command, error, unanswered) => { - const h = await setup() - const { child } = await restart( - h, - (connection) => { - connection.requestOverride = (name) => - name === command ? Promise.reject(error) : undefined - }, - { options: { model: 'anthropic/model-2' } } - ) - await vi.waitFor(() => - expect(h.lifecycle).toHaveBeenCalledWith(expect.objectContaining({ type: 'ended' })) - ) - expect(child.rootVerdict).toBe('exited') - const ended = reportedEvent(h.lifecycle, 'ended') - expect(ended).toMatchObject({ - cause: 'unexpected-exit', - startupUnproven: true, - reason: error.message - }) - expect(ended.startupUnanswered).toBe(unanswered ? true : undefined) - if (command === 'get_state') { - expect(ended.failure?.detail).toEqual({ text: 'Pi is not signed in', audience: 'person' }) - } - expect(h.lifecycle).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) - } - ) - - it('does not retry acquisition while a failed start still owns an unproven process', async () => { + it('rejects a known empty listing once, then opens a fresh child after sign-in', async () => { const h = await setup() - const { child } = await restart(h, (connection) => { - connection.closeResult = { root: 'unverifiable', tree: null } - connection.requestOverride = () => Promise.reject(new Error('get_state failed')) - }) - await vi.waitFor(() => expect(child.closed).toBe(true)) - await expect(h.adapter.acquire({ ...h.input, fence: 9 })).rejects.toBeInstanceOf( - AgentSessionAcquisitionExitUnprovenError - ) - expect(h.connections).toHaveLength(2) - child.closeResult = { root: 'exited', tree: 'exited' } - await expect(h.adapter.acquire({ ...h.input, fence: 10 })).resolves.toMatchObject({ - process: { pid: 4123 } + const { acquired, child } = await restart(h, (connection) => { + connection.requestOverride = (command) => + command === 'get_available_models' ? Promise.resolve({ models: [] }) : undefined }) + await expect( + h.adapter.open({ + sessionId, + fence: 8, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + ).rejects.toMatchObject({ reason: 'notSignedIn' }) + expect(child.closed).toBe(false) + expect(child.sent).toEqual([]) + await h.adapter.closeSession(sessionId) + const again = await h.adapter.acquire({ ...h.input, fence: 9 }) + await expect( + h.adapter.open({ + sessionId, + fence: 9, + acquisitionGeneration: again.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + ).resolves.toMatchObject({ reportedOptions: { model: 'anthropic/model-1' } }) + expect(h.connections).toHaveLength(3) }) - it('preserves the provider exit diagnostic when transport closure failed startup first', async () => { + it('treats an unknown provider as signed out even when listing succeeds', async () => { const h = await setup() - await restart(h, (connection) => { - connection.closeError = withProviderDiagnostic( - new Error('provider exited'), - providerDiagnostic('Pi is not signed in', 'log') - ) - connection.requestOverride = () => - Promise.reject(new Error('Agent JSON-lines RPC stream closed')) + const { acquired, child } = await restart(h, (connection) => { + connection.requestOverride = (command) => + command === 'get_state' + ? Promise.resolve({ ...state, model: { provider: 'unknown', id: 'unknown' } }) + : undefined }) - await vi.waitFor(() => - expect(h.lifecycle).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'ended', - startupUnproven: true, - failure: { - kind: 'providerStartFailed', - detail: { text: 'Pi is not signed in', audience: 'log' } - } - }) - ) - ) + await expect( + h.adapter.open({ + sessionId, + fence: 8, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + ).rejects.toMatchObject({ reason: 'notSignedIn' }) + expect(child.requests).toEqual(['get_state']) }) - it('ends a child that exits while its handshake is held', async () => { + it('keeps a failed optional listing distinct from a known empty list', async () => { const h = await setup() - const { child } = await restart(h, (connection) => { - connection.requestOverride = () => new Promise(() => {}) + const { acquired } = await restart(h, (connection) => { + connection.requestOverride = (command) => + command === 'get_available_models' + ? Promise.reject(new JsonlRpcResponseError(command, 'listing unavailable')) + : undefined }) - child.exit(new Error('provider died during get_state')) - await h.adapter.drainObservedExits() - expect(h.lifecycle).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'ended', - cause: 'unexpected-exit', - startupUnproven: true, - startupUnanswered: true, - reason: 'provider died during get_state' + await expect( + h.adapter.open({ + sessionId, + fence: 8, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal }) - ) - expect(h.lifecycle).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) + ).resolves.toMatchObject({ reportedOptions: { model: 'anthropic/model-1' } }) }) - it('rejects an invalid session file and reports a proved resumed file from the launch', async () => { + it('lets the host close a child held in the state request without a late open', async () => { const h = await setup() - const { child } = await restart(h, (connection) => { - connection.requestOverride = (name) => - name === 'get_state' - ? Promise.resolve({ ...state, sessionFile: 'relative.jsonl' }) - : undefined + const stateReply = Promise.withResolvers() + const { acquired, child } = await restart(h, (connection) => { + connection.requestOverride = (command) => + command === 'get_state' ? stateReply.promise : undefined + connection.abortRequest = () => stateReply.reject(new Error('closed while starting')) }) - await vi.waitFor(() => - expect(h.lifecycle).toHaveBeenCalledWith( - expect.objectContaining({ type: 'ended', startupUnproven: true }) - ) - ) + const controller = new AbortController() + const opening = h.adapter + .open({ + sessionId, + fence: 8, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: controller.signal + }) + .catch((error: unknown) => error) + expect(child.requests).toEqual(['get_state']) + controller.abort() + await h.adapter.closeSession(sessionId) + expect(await opening).toBeInstanceOf(Error) expect(child.rootVerdict).toBe('exited') - const launch = await h.resolveLaunch.mock.results[0]?.value - if (!launch) { - throw new Error('launch missing') - } - h.resolveLaunch.mockResolvedValueOnce({ - ...launch, - sessionFile: file, - previous: h.started.link - }) - await restart(h, () => {}) - await vi.waitFor(() => - expect(h.lifecycle).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'started', - link: expect.objectContaining({ origin: 'resumed' }) - }) - ) - ) - expect(h.openConnection.mock.calls.at(-1)?.[0].args).toEqual( - expect.arrayContaining(['--session', file]) - ) - expect(reportedEvent(h.lifecycle, 'started').link?.handle.nativeId).toBe(file) + expect(h.lifecycle).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) }) }) diff --git a/src/main/pi/rpc-session-startup.ts b/src/main/pi/rpc-session-startup.ts index d24f5d5691ae..af2d5f3a10b9 100644 --- a/src/main/pi/rpc-session-startup.ts +++ b/src/main/pi/rpc-session-startup.ts @@ -1,11 +1,16 @@ import { randomUUID } from 'node:crypto' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' import type { AgentSessionOptionsResult, AgentSessionSlashCommand } from '../../shared/agent-session-wire' import { JsonlRpcResponseError } from '../jsonl-rpc/peer' -import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + AgentSessionAcquisitionRefusal, + type StructuredAgentSessionAcquireInput +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import { unpickedSessionConfiguredChoice, withLiveCatalogListing, @@ -27,17 +32,13 @@ import { STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } from '../native-chat/agen const START_REQUEST = { timeoutMs: STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } export class PiRpcSessionStartup { - started = false - answered = false skipped: readonly string[] = [] commands?: AgentSessionSlashCommand[] /** What the start listed; a signed-out Pi lists no model. */ options?: AgentSessionOptionsResult - private readonly controller = new AbortController() constructor( private readonly input: StructuredAgentSessionAcquireInput, - private readonly generation: string, private readonly connection: PiRpcConnection, private readonly selected: Map, private readonly deps: PiRpcSessionDeps, @@ -46,16 +47,17 @@ export class PiRpcSessionStartup { private readonly setState: (state: PiRpcState) => void ) {} - stop(): void { - this.controller.abort(new Error('Pi closed while starting')) - } - - async run(launch: PiRpcResolvedLaunch): Promise { + async run( + launch: PiRpcResolvedLaunch, + signal: AbortSignal + ): Promise { const optionRevision = this.input.optionRevision?.() ?? 0 - const answer = await this.wait(this.connection.request('get_state', {}, START_REQUEST)) - this.answered = true - this.assertLive() + const answer = await this.wait(this.connection.request('get_state', {}, START_REQUEST), signal) + this.assertLive(signal) const state = piRpcStateSchema.parse(answer) + if (state.model?.provider === 'unknown') { + throw this.notSignedIn() + } const link = piRpcProviderLink( launch, state.sessionFile, @@ -79,9 +81,10 @@ export class PiRpcSessionStartup { } picked.add(key) try { - this.assertLive() + this.assertLive(signal) await this.wait( - applyPiRpcSessionOption(this.connection, this.selected, key, value, START_REQUEST) + applyPiRpcSessionOption(this.connection, this.selected, key, value, START_REQUEST), + signal ) current[key] = value } catch (error) { @@ -92,12 +95,15 @@ export class PiRpcSessionStartup { this.warn('Pi rejected a saved option', 'pi-option-restore', error, { key }) } } - this.assertLive() + this.assertLive(signal) current.confirmed = [...(current.model ? ['model'] : []), ...(current.effort ? ['effort'] : [])] // Part of the start, as other agents' handshakes answer with theirs: a Pi that lists no model // is signed out, and what it started on names its config's default. - const options = await this.readListing() - this.assertLive() + const options = await this.readListing(signal) + this.assertLive(signal) + if (options && options.models.length === 0) { + throw this.notSignedIn() + } // What it started on names its config's default once; a later switch teaches nothing. let catalogListing: AgentModelCatalogLiveListing | undefined if (options) { @@ -108,67 +114,65 @@ export class PiRpcSessionStartup { ).catalogListing } this.skipped = skipped - this.started = true - this.deps.onLifecycle({ - type: 'started', - ...this.identity(), + await this.readCommands(signal) + this.assertLive(signal) + return { link, reportedOptions: options?.current ?? current, restoreSkippedOptions: skipped, optionRevision, ...(catalogListing ? { catalogListing } : {}) - }) - await this.readCommands() + } } /** Never fails the start: without it the chat only lacks the listing and the sign-in verdict. */ - private async readListing(): Promise { + private async readListing(signal: AbortSignal): Promise { try { - return await this.wait(readPiRpcSessionOptions(this.connection)) + return await this.wait(readPiRpcSessionOptions(this.connection), signal) } catch (error) { - this.assertLive() + this.assertLive(signal) this.warn('Pi options could not be read', 'pi-options', error) return undefined } } - private async readCommands(): Promise { + private async readCommands(signal: AbortSignal): Promise { try { - this.assertLive() - const result = await this.wait(readPiRpcCommands(this.connection)) - this.assertLive() + this.assertLive(signal) + const result = await this.wait(readPiRpcCommands(this.connection), signal) + this.assertLive(signal) this.commands = result.commands } catch (error) { this.warn('Pi commands could not be read', 'pi-commands', error) } } - private assertLive(): void { - if (this.controller.signal.aborted || this.connection.closed) { + private assertLive(signal: AbortSignal): void { + if (signal.aborted || this.connection.closed) { throw new Error('Pi closed while starting') } } - private wait(promise: Promise): Promise { - return waitForPromiseWithSignal(promise, this.controller.signal) + private wait(promise: Promise, signal: AbortSignal): Promise { + return waitForPromiseWithSignal(promise, signal) } - private identity() { - return { - sessionId: this.input.identity.sessionId, - fence: this.input.fence, - acquisitionGeneration: this.generation - } + private notSignedIn(): AgentSessionAcquisitionRefusal { + return withProviderDiagnostic( + new AgentSessionAcquisitionRefusal( + 'Pi has no authenticated model provider available.', + 'notSignedIn' + ), + providerDiagnostic('Pi has no authenticated model provider available.', 'person') + ) } private warn(message: string, scope: string, error: unknown, fields = {}): void { - if (!this.controller.signal.aborted) { - this.deps.logger.warn(message, { - scope, - sessionId: this.input.identity.sessionId, - error, - ...fields - }) - } + this.deps.logger.warn(message, { + scope, + sessionId: this.input.identity.sessionId, + error, + ...fields + }) } } diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts index 925a1bbf727e..917b12eff7bd 100644 --- a/src/main/pi/rpc-session.ts +++ b/src/main/pi/rpc-session.ts @@ -22,6 +22,8 @@ import { PiRpcDialogCallbacks } from './rpc-dialog-callbacks' import { readPiRpcSessionOptions } from './rpc-options' import { withLiveCatalogListing } from '../native-chat/agent-model-catalog/agent-model-catalog-entry' import type { PiRpcResolvedLaunch } from './rpc-launch-resolution' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { PiRpcSessionStartup } from './rpc-session-startup' export type PiRpcConnection = Pick< @@ -62,6 +64,7 @@ export class PiRpcSession { readonly dialogs: PiRpcDialogCallbacks readonly selected = new Map() readonly startup: PiRpcSessionStartup + openedLink: AgentSessionProviderHandleLink | null = null requestedClose = false private publishedExit = false private failedCause?: Error @@ -73,6 +76,7 @@ export class PiRpcSession { readonly generation: string, launch: ProviderProcessLaunch, private readonly deps: PiRpcSessionDeps, + private readonly resolvedLaunch: PiRpcResolvedLaunch, /** A new Pi session starts on its config's model; a resumed or forked one keeps its own. */ readonly resolvesConfig = false ) { @@ -143,7 +147,6 @@ export class PiRpcSession { }) this.startup = new PiRpcSessionStartup( input, - generation, this.connection, this.selected, deps, @@ -154,18 +157,10 @@ export class PiRpcSession { ) } - async start(launch: PiRpcResolvedLaunch, retainFailed: () => void): Promise { - try { - await this.startup.run(launch) - } catch (error) { - if (!this.requestedClose) { - this.failedCause ??= error instanceof Error ? error : new Error(String(error)) - } - const result = await this.close(false).catch(() => null) - if (result?.root !== 'exited') { - retainFailed() - } - } + async open(signal: AbortSignal): Promise { + const result = await this.startup.run(this.resolvedLaunch, signal) + this.openedLink = result.link ?? null + return result } /** What the child reports now; only the start says what its config resolved. */ @@ -175,7 +170,6 @@ export class PiRpcSession { async close(requested = true): ReturnType { this.requestedClose ||= requested - this.startup.stop() this.dialogs.cancelAll() return this.connection.close() } @@ -187,6 +181,9 @@ export class PiRpcSession { sessionId: this.input.identity.sessionId, error }) + if (!this.openedLink) { + return + } void this.close(false) .then((result) => { if (result.root !== 'exited') { @@ -230,7 +227,6 @@ export class PiRpcSession { } this.publishedExit = true this.failedCause ??= error - this.startup?.stop() this.dialogs.cancelAll() this.turns.end() this.lane.finalize() @@ -265,19 +261,15 @@ export class PiRpcSession { observedAt: Date.now(), cause: this.requestedClose ? 'requested-close' : 'unexpected-exit', reason: cause.message, - failure: agentSessionFailureFact( - this.startup.started ? 'providerExited' : 'providerStartFailed', - { - detail: - providerDiagnosticOf(cause) ?? - providerDiagnosticOf(error) ?? - (cause instanceof JsonlRpcResponseError - ? providerDiagnostic(cause.message, 'person') - : undefined) - } - ), - ...(this.startup.started ? {} : { startupUnproven: true }), - ...(this.startup.answered ? {} : { startupUnanswered: true }) + failure: agentSessionFailureFact(this.openedLink ? 'providerExited' : 'providerStartFailed', { + detail: + providerDiagnosticOf(cause) ?? + providerDiagnosticOf(error) ?? + (cause instanceof JsonlRpcResponseError + ? providerDiagnostic(cause.message, 'person') + : undefined) + }), + ...(this.openedLink ? {} : { startupUnproven: true }) }) } diff --git a/src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts b/src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts index f35cd7b4657c..0d5faf66d5f6 100644 --- a/src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts +++ b/src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts @@ -1,11 +1,11 @@ -// A message that starts a signed-out Pi reaches it once and is settled by Pi's own refusal; the -// start made for it is never replaced as a signed-out child, which would start Pi for it forever. +// A signed-out Pi fails construction once; a later send after sign-in opens a new child. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { readWholeAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import { hostTestMessage, HOST_TEST_SESSION as SESSION @@ -13,6 +13,7 @@ import { import { createTestParams } from '../native-chat/agent-session-wire/structured-agent-session-create-test-fixture' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { piScriptedChild } from '../pi/pi-scripted-child.test-fixture' +import { peekOpenedAgentSessionRecordStore } from './agent-session-record-store-slot' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime @@ -31,7 +32,7 @@ afterEach(async () => { } }) -it('settles the first message of a signed-out Pi as not signed in, and starts Pi once', async () => { +it('settles the held message once, closes Pi, and starts afresh for a later send after sign-in', async () => { directory = await mkdtemp(join(tmpdir(), 'orca-pi-signed-out-')) const root = directory const script = piScriptedChild({ signedOut: true }) @@ -67,11 +68,10 @@ it('settles the first message of a signed-out Pi as not signed in, and starts Pi if (!created.ok) { throw new Error(JSON.stringify(created.refusal)) } - // Pi answers its start after the host has published it, as a real Pi does. + // Pi answers its start after the host has published it. await vi.waitFor(() => expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') ) - await new Promise((resolve) => setTimeout(resolve, 50)) script.releaseHandshake() const submission = async () => { await host.flushStreamedEvents(SESSION) @@ -86,9 +86,28 @@ it('settles the first message of a signed-out Pi as not signed in, and starts Pi expect(readWholeAgentSessionFailureFact((await submission())?.rejection)?.kind).toBe( 'notSignedIn' ) - // Nothing starts Pi again for a message it already refused. - await new Promise((resolve) => setTimeout(resolve, 500)) + await vi.waitFor(() => expect(script.closes()).toBe(1)) expect(script.spawns()).toBe(1) - expect(script.closes()).toBe(0) - expect(new Set(script.prompts())).toEqual(new Set(['hello, signed-out Pi'])) + expect(script.prompts()).toEqual([]) + script.signIn() + const body = hostTestMessage('hello after sign-in') + const fields = { body } + const sent = await host.send(caller, { + envelope: { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: + peekOpenedAgentSessionRecordStore()?.getRecord(SESSION)?.lease.runtimeFence ?? null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields + }) + }, + body + }) + expect(sent.ok).toBe(true) + await vi.waitFor(() => expect(script.spawns()).toBe(2)) + script.releaseHandshake() + await vi.waitFor(() => expect(script.prompts()).toEqual(['hello after sign-in'])) }) From 50d36b04d24d921a8693b8cc57d47798baff9c9b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:44:34 -0700 Subject: [PATCH 019/127] Read Pi catalog facts from awaited opening --- src/main/pi/rpc-configured-default.test.ts | 33 +++++++++------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/src/main/pi/rpc-configured-default.test.ts b/src/main/pi/rpc-configured-default.test.ts index 7ba6289084a4..2e22a3811e70 100644 --- a/src/main/pi/rpc-configured-default.test.ts +++ b/src/main/pi/rpc-configured-default.test.ts @@ -28,10 +28,6 @@ import { agentModelCatalogFingerprint } from '../native-chat/agent-model-catalog import { createPiModelCatalogProbe, piModelCatalogFromListing } from './rpc-model-catalog-probe' import type { PiRpcConnection } from './rpc-session' import { PiRpcSessionAdapter } from './rpc-session-adapter' -import type { - StructuredAgentSessionLifecycleEvent, - StructuredAgentSessionStartedEvent -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' const PI_HOME = { variable: 'PI_CODING_AGENT_DIR', path: '/homes/pi' } // What `pi --list-models` prints: the first row is not the model Pi starts on. @@ -180,7 +176,6 @@ async function piHost() { } } as AgentSessionRecord) const rig = await openProviderTimelineRig({ agent: 'pi', sessionId }) - const onLifecycle = vi.fn<(event: StructuredAgentSessionLifecycleEvent) => void>() const adapter = new PiRpcSessionAdapter({ resolveLaunch: async () => ({ command: '/opt/pi/bin/pi', @@ -196,13 +191,12 @@ async function piHost() { child = piChild(opts.available ?? AVAILABLE, opts.thinking ?? 'high', opts.startsOn ?? '') return child.connection }, - onLifecycle, onSettled: vi.fn(), onIdle: vi.fn(), logger: { warn: vi.fn(), error: vi.fn() } }) adapters.push(adapter) - await adapter.acquire({ + const acquired = await adapter.acquire({ identity: { sessionId, workspaceId: 'ws-1', @@ -215,16 +209,17 @@ async function piHost() { options: opts.saved ?? {}, events: rig.eventSink }) - // The acquire answers at spawn; the start hands the host its listing once it is proven. - const started = await vi.waitFor(() => { - const event = onLifecycle.mock.calls - .map(([call]) => call) - .find((call): call is StructuredAgentSessionStartedEvent => call.type === 'started') - if (!event) { - throw new Error('Pi has not started') - } - return event - }) + const started = await adapter + .open({ + sessionId, + fence: 1, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + .catch(async (error: unknown) => { + await adapter.closeSession(sessionId) + throw error + }) if (!started.catalogListing) { throw new Error('a Pi start hands the host its listing') } @@ -316,12 +311,12 @@ describe('Pi’s default comes from what a chat with no pick runs', () => { const { catalog, chat } = await piHost() const clean = workspace('clean') await catalog.read({ agent: 'pi', waitForListing: true }) - expect((await chat(clean, { available: [] })).listing).not.toHaveProperty('configuredDefault') + await expect(chat(clean, { available: [] })).rejects.toMatchObject({ reason: 'notSignedIn' }) expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual(NOTHING) // Nor does it forget one a signed-in chat taught. await chat(clean) - await chat(clean, { available: [] }) + await expect(chat(clean, { available: [] })).rejects.toMatchObject({ reason: 'notSignedIn' }) expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ model: 'openai/gpt-6', effort: 'high' From 90db80bac7b2d1eecde40515fd29e1c8fcc426c1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:47:14 -0700 Subject: [PATCH 020/127] Keep the Pi fixture exit observer --- src/main/pi/rpc-configured-default.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/pi/rpc-configured-default.test.ts b/src/main/pi/rpc-configured-default.test.ts index 2e22a3811e70..3091f3096f7b 100644 --- a/src/main/pi/rpc-configured-default.test.ts +++ b/src/main/pi/rpc-configured-default.test.ts @@ -191,6 +191,7 @@ async function piHost() { child = piChild(opts.available ?? AVAILABLE, opts.thinking ?? 'high', opts.startsOn ?? '') return child.connection }, + onLifecycle: vi.fn(), onSettled: vi.fn(), onIdle: vi.fn(), logger: { warn: vi.fn(), error: vi.fn() } 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 021/127] 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 022/127] 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 023/127] 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 024/127] 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 c4471b10c0961ffeb67f72754299d5d4eaa4ec31 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 025/127] Capture message choices per accepted send and preserve picker state --- .../scripts/vitest-sqlite-runtime-files.mjs | 3 + ...uctured-agent-session-host-support.test.ts | 5 + ...e-structured-agent-session-host-support.ts | 3 + .../mobile-structured-agent-session-rpc.ts | 6 +- .../mobile-structured-agent-session-send.ts | 5 + .../use-mobile-structured-agent-options.ts | 14 +- .../use-mobile-structured-agent-session.ts | 1 + ...use-mobile-structured-send-with-outcome.ts | 7 + .../codex/codex-structured-session-adapter.ts | 8 +- .../codex-structured-session-options.test.ts | 44 +- .../codex/codex-structured-session-options.ts | 19 + src/main/codex/codex-structured-turn-start.ts | 4 +- .../journal-queued-messages.ts | 2 + .../journal-row-builders.ts | 4 + .../journal-row-schema.ts | 2 + .../journal-store-contracts.ts | 2 + .../journal-submission-fold.ts | 3 + .../queued-message-schema.ts | 4 +- .../queued-message-stored-row.ts | 15 + .../queued-message-table.ts | 11 +- .../structured-agent-session-adapter.ts | 3 + .../structured-agent-session-delivery-loop.ts | 7 + ...uctured-agent-session-execution-options.ts | 40 ++ ...structured-agent-session-host-mutations.ts | 2 + ...ured-agent-session-message-choices.test.ts | 55 +++ ...tructured-agent-session-message-options.ts | 61 +++ ...structured-agent-session-mutation-plans.ts | 10 +- ...agent-session-options-cold-catalog.test.ts | 137 ++++++ .../structured-agent-session-options-read.ts | 6 +- ...ueued-message-rig-provider.test-fixture.ts | 4 +- ...session-queued-message-rig.test-fixture.ts | 11 +- ...tructured-agent-session-queued-messages.ts | 14 +- ...ructured-agent-session-queued-mutations.ts | 1 + ...ctured-agent-session-queued-publication.ts | 1 + .../structured-agent-session-queued-send.ts | 2 + .../structured-agent-session-send.test.ts | 65 ++- ...d-agent-session-status-summary-equality.ts | 2 + ...structured-agent-session-status-summary.ts | 1 + .../structured-agent-session-turns.ts | 55 ++- .../NativeChatStructuredSession.tsx | 2 +- .../StructuredAgentSessionStatusBridge.tsx | 52 ++- .../structured-agent-session-pending-sends.ts | 2 + .../structured-agent-session-send-attempt.ts | 3 +- .../structured-agent-session-send-slot.ts | 3 + .../use-host-model-catalog-upgrade.ts | 9 +- ...e-structured-agent-session-option-state.ts | 139 +++++- ...structured-agent-session-option-surface.ts | 53 +++ ...gent-session-options.cold-catalog.test.tsx | 109 +++++ ...session-options.current-selection.test.tsx | 408 ++++++++++++++++++ ...session-options.no-catalog-effort.test.tsx | 104 +++++ ...t-session-options.pending-restart.test.tsx | 98 +++++ ...nt-session-options.scoped-default.test.tsx | 148 +++++++ .../use-structured-agent-session-options.ts | 151 +++---- .../use-structured-agent-session-sends.ts | 11 +- .../use-structured-agent-session.ts | 8 +- ...tructured-agent-session-host-capability.ts | 8 + ...agent-session-execution-option-snapshot.ts | 23 + src/shared/agent-session-execution-options.ts | 21 + ...agent-session-journal-submission-schema.ts | 2 + src/shared/agent-session-journal-types.ts | 3 + src/shared/agent-session-options-result.ts | 50 +++ .../agent-session-queue-capabilities.ts | 17 + .../agent-session-queued-message-wire.ts | 2 + src/shared/agent-session-wire.ts | 47 +- src/shared/protocol-version.ts | 35 +- .../structured-agent-session-params.ts | 2 + ...-session-model-catalog-application.test.ts | 93 ++++ .../structured-agent-session-option-view.ts | 2 +- .../structured-agent-session-options.ts | 26 +- ...ctured-agent-session-send-mutation.test.ts | 39 ++ .../structured-agent-session-send-mutation.ts | 6 +- 71 files changed, 2076 insertions(+), 239 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-options-cold-catalog.test.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-option-surface.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.cold-catalog.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.no-catalog-effort.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.pending-restart.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.scoped-default.test.tsx create mode 100644 src/shared/agent-session-execution-option-snapshot.ts create mode 100644 src/shared/agent-session-execution-options.ts create mode 100644 src/shared/agent-session-options-result.ts create mode 100644 src/shared/agent-session-queue-capabilities.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 2f31e6c35c00..9e12afc9edd3 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -116,6 +116,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-live-turn-window.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts', @@ -131,6 +132,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts', @@ -161,6 +163,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts', diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts index e5debf14376a..f0592549965c 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts @@ -4,6 +4,7 @@ import { AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, + AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY, AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' @@ -15,6 +16,7 @@ describe('structuredAgentSessionHostSupport', () => { promptCancel: false, questionAnswers: false, queuedMessages: false, + messageChoices: false, queuedCommands: false, quietRepeatedStop: false, statusFeed: false @@ -29,6 +31,9 @@ describe('structuredAgentSessionHostSupport', () => { expect( structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY]) ).toEqual({ ...none, queuedMessages: true }) + expect( + structuredAgentSessionHostSupport([AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY]) + ).toEqual({ ...none, messageChoices: true }) expect( structuredAgentSessionHostSupport([AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY]) ).toEqual({ ...none, queuedCommands: true }) diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts index 84c329e63738..f672c0352aee 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -3,6 +3,7 @@ import { AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, + AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY, AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' @@ -13,6 +14,7 @@ export type StructuredAgentSessionHostSupport = { questionAnswers: boolean /** Mid-turn sends queue as host-held drafts; an older host keeps today's immediate path. */ queuedMessages: boolean + messageChoices: boolean /** A /compact sent while the agent works waits as a card; an older host refuses it. */ queuedCommands: boolean /** The host publishes every session's status on one stream. A host from before phones could @@ -29,6 +31,7 @@ export function structuredAgentSessionHostSupport( promptCancel: capabilities.includes(AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY), questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY), queuedMessages: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY), + messageChoices: capabilities.includes(AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY), queuedCommands: capabilities.includes(AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY), statusFeed: capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY), quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY) diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts index 7b2da967d56c..fc2877221b82 100644 --- a/mobile/src/session/mobile-structured-agent-session-rpc.ts +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -125,6 +125,8 @@ export async function requestStructuredAgentSessionMutation(args: { sessionId: string expectedRuntimeFence: number fields: Record + /** Sent beside the request, excluded from the operation fingerprint. */ + unfingerprintedFields?: Record clientOperationId?: string timeoutMs?: number }): Promise> { @@ -135,6 +137,7 @@ export async function requestStructuredAgentSessionMutation(args: { sessionId, expectedRuntimeFence, fields, + unfingerprintedFields, clientOperationId, timeoutMs } = args @@ -153,7 +156,8 @@ export async function requestStructuredAgentSessionMutation(args: { fields }) }, - ...fields + ...fields, + ...unfingerprintedFields }, timeoutMs ) diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index e2e1ebccbe03..d48fccfb03e2 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -1,4 +1,5 @@ import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire' +import type { AgentSessionExecutionOptions } from '../../../src/shared/agent-session-execution-options' import { structuredAgentSessionSendBody, type StructuredAgentSessionAttachment @@ -20,6 +21,7 @@ export async function sendMobileStructuredAgentSessionMessage(input: { attachments: readonly StructuredAgentSessionAttachment[] /** Sent only when the host advertises `agent-session.queued-messages.v1`. */ delivery?: 'queue-if-active' + executionOptions?: AgentSessionExecutionOptions deadline?: number /** The id the host records the message under. */ clientMessageId: string @@ -41,6 +43,9 @@ export async function sendMobileStructuredAgentSessionMessage(input: { body: structuredAgentSessionSendBody(input.text, input.attachments), ...(input.delivery ? { delivery: input.delivery } : {}) }, + ...(input.executionOptions + ? { unfingerprintedFields: { executionOptions: input.executionOptions } } + : {}), clientOperationId: input.clientMessageId, timeoutMs }) diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 63a434f90b1c..dc08a9d31a45 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -68,7 +68,6 @@ export function useMobileStructuredAgentOptions(args: { ) const optionStateRef = useRef(optionState) const activeOptionRecordRef = useRef(optionState.record) - const pendingOptionRef = useRef(null) const optionMutationGeneration = useRef(0) const updateOptionState = useCallback( (update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState) => { @@ -106,7 +105,6 @@ export function useMobileStructuredAgentOptions(args: { // The chat's own, which a restart for a new mode keeps: the pill never blanks between them. const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept optionMutationGeneration.current += 1 - pendingOptionRef.current = null optionStateRef.current = next activeOptionRecordRef.current = next.record setOptionState(next) @@ -182,7 +180,6 @@ export function useMobileStructuredAgentOptions(args: { const currentState = optionStateRef.current const encoded = encodeStructuredAgentSessionOptionValue(id, value) if ( - pendingOptionRef.current !== null || !client || !sessionId || !optionCatalog || @@ -204,10 +201,8 @@ export function useMobileStructuredAgentOptions(args: { ? commitStructuredAgentSessionOption(current, id, encoded) : current ) - } else { - pendingOptionRef.current = id - updateOptionState((current) => ({ ...current, pendingId: id })) } + updateOptionState((current) => ({ ...current, pendingPick: { id, value: encoded } })) // A same-fence accepted write, or a shown one that failed, reads back what the host now holds. let readsBack = shownAtOnce try { @@ -266,10 +261,9 @@ export function useMobileStructuredAgentOptions(args: { .catch(() => undefined) } if (isCurrent()) { - pendingOptionRef.current = null updateOptionState((current) => - current.record === targetRecord && current.pendingId === id - ? { ...current, pendingId: null } + current.record === targetRecord && current.pendingPick?.id === id + ? { ...current, pendingPick: null } : current ) } @@ -316,7 +310,7 @@ export function useMobileStructuredAgentOptions(args: { (agent !== null && isAgentSessionHandleProvider(agent)), optionSnapshot, optionSurface, - pendingOptionId: optionState.pendingId, + pendingOptionId: optionState.pendingPick?.id ?? null, setStructuredOption, invokeStructuredOption } diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 4b11ee4fd582..b8649d56310f 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -185,6 +185,7 @@ export function useMobileStructuredAgentSession(args: { sessionId, enabled, queueCapable, + messageChoices: hostSupport?.messageChoices === true, commandsWait, stateRef, stopping, diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index a7b3c1c32b8b..3482b1cfd9d4 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -4,6 +4,7 @@ // host advertises the queue and no pending prompt is one this build cannot answer. import { useCallback } from 'react' +import { agentSessionExecutionOptionsFromSnapshot } from '../../../src/shared/agent-session-execution-option-snapshot' import { runningStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' import { pendingPromptsAllUnanswerableHere } from '../../../src/shared/agent-session-approval-subject' import { @@ -44,6 +45,7 @@ export function useMobileStructuredSendWithOutcome(args: { sessionId: string | null enabled: boolean queueCapable: boolean + messageChoices: boolean /** The host holds a /compact sent while the agent works as a card. */ commandsWait: boolean stateRef: { readonly current: StructuredAgentSessionState } @@ -74,6 +76,7 @@ export function useMobileStructuredSendWithOutcome(args: { hold, onSendError, queueCapable, + messageChoices, sessionId, stateRef, stopping @@ -147,6 +150,9 @@ export function useMobileStructuredSendWithOutcome(args: { ...(mobileStructuredSendQueues(queueCapable, stateRef.current.items) ? { delivery: 'queue-if-active' as const } : {}), + ...(messageChoices + ? { executionOptions: agentSessionExecutionOptionsFromSnapshot(controller.snapshot) } + : {}), deadline, clientMessageId: send.clientMessageId, onError: onSendError @@ -169,6 +175,7 @@ export function useMobileStructuredSendWithOutcome(args: { hold, onSendError, queueCapable, + messageChoices, sessionId, stateRef, stopping diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index a8d250bb1c5f..8bbc046ce049 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -1,5 +1,6 @@ import * as codexRewind from './codex-structured-rewind' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { isCodexAppServerRequestError } from './codex-app-server-connection' import type { @@ -19,7 +20,8 @@ import { applyCodexStructuredSessionOption, prepareLiveCodexSessionOptions, readLiveCodexSessionOptions, - readCodexAcquisitionOptions + readCodexAcquisitionOptions, + resolvedCodexMessageOptions } from './codex-structured-session-options' import { CodexAcquisitionRegistry, @@ -227,6 +229,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap sessionId: string clientMessageId: string body: AgentJournalMessageItem + executionOptions?: AgentSessionExecutionOptions fence: number requestedAt?: number beforeDispatch?: () => Promise @@ -235,7 +238,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap session.dispatchPending = true try { await input.beforeDispatch?.() - return await dispatchCodexTurn(session, input, this.deps.requestTimeoutMs) + const turnOptions = await resolvedCodexMessageOptions(session, input.executionOptions) + return await dispatchCodexTurn(session, { ...input, turnOptions }, this.deps.requestTimeoutMs) } finally { session.dispatchPending = false } diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index 693bd1fa9ccc..9d76d870d2f3 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -15,7 +15,8 @@ import { applyCodexStructuredSessionOption, readCodexStructuredSessionOptions, readLiveCodexSessionOptions, - restoredCodexSessionOptions + restoredCodexSessionOptions, + resolvedCodexMessageOptions } from './codex-structured-session-options' import { reportedCodexThreadOptions } from './codex-structured-service-tier' import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' @@ -58,6 +59,47 @@ async function primePicker(session: CodexSession): Promise { } describe('structured Codex session options', () => { + it('uses each message’s choices on turn/start without changing the saved next pick', async () => { + const request = vi.fn(async (_method: string, _params?: Record) => ({ + turn: { id: 'turn-1' } + })) + const session = optionSession(request) + session.options.set('model', 'saved-next') + session.options.set('effort', 'medium') + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'go' }] + } + for (const [id, model, effort] of [ + ['first', 'model-a', 'high'], + ['second', 'model-b', 'low'] + ]) { + const turnOptions = await resolvedCodexMessageOptions(session, { model, effort }) + await startCodexTurn(session, { clientMessageId: id, body, turnOptions }) + } + expect( + request.mock.calls + .filter(([method]) => method === 'turn/start') + .map(([, params]) => [params?.model, params?.effort]) + ).toEqual([ + ['model-a', 'high'], + ['model-b', 'low'] + ]) + expect(Object.fromEntries(session.options)).toEqual({ model: 'saved-next', effort: 'medium' }) + }) + + it('refuses an unavailable message option before a protocol prompt', async () => { + const request = vi.fn(async (_method: string, _params?: Record) => ({ + turn: { id: 'turn-1' } + })) + const session = optionSession(request) + await expect(resolvedCodexMessageOptions(session, { fastMode: 'true' })).rejects.toThrow( + 'does not support Fast mode' + ) + expect(request).not.toHaveBeenCalled() + }) + it.each([ ['model', 'gpt-next'], ['effort', 'high'], diff --git a/src/main/codex/codex-structured-session-options.ts b/src/main/codex/codex-structured-session-options.ts index e9118f111ac1..6a20a51f3470 100644 --- a/src/main/codex/codex-structured-session-options.ts +++ b/src/main/codex/codex-structured-session-options.ts @@ -1,6 +1,7 @@ import type { AgentSessionOptionsResult } from '../../shared/agent-session-wire' import type { CodexAppServerConnection } from './codex-app-server-connection' import type { CodexSession } from './codex-structured-session-state' +import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' import { isCodexTurnOptionKey } from './codex-structured-turn-start' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' @@ -187,6 +188,24 @@ export async function applyCodexStructuredSessionOption( } } +/** Validate one message against a separate option map, leaving the pick for the next send alone. */ +export async function resolvedCodexMessageOptions( + session: CodexSession, + choices: AgentSessionExecutionOptions | undefined +): Promise> | undefined> { + if (!choices) { + return undefined + } + const selected = { ...session, options: new Map(session.options) } + for (const key of ['model', 'effort', 'personality', 'fastMode', 'serviceTier'] as const) { + const value = choices[key] + if (value !== undefined) { + await applyCodexStructuredSessionOption(selected, key, value) + } + } + return Object.fromEntries(selected.options) +} + /** * With no listing known, a pick is kept as the next turn's intent rather than refused: catalog * bookkeeping must not gate it. Codex judges the model and tier on `turn/start`. diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 52a653a68e3c..1728683b2114 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -112,6 +112,7 @@ export async function startCodexTurn( body: AgentJournalMessageItem requestedAt?: number timeoutMs?: number + turnOptions?: Readonly> } ): Promise<{ turnId: string | null; via: AgentJournalTurnJoin } | false> { // Armed before the write: the echo and `turn/started` can both land while the @@ -140,7 +141,7 @@ export async function startCodexTurn( clientUserMessageId: input.clientMessageId, input: turnInputFor(input.body), // Codex omits a tier the model does not list. - ...Object.fromEntries(host.options) + ...(input.turnOptions ?? Object.fromEntries(host.options)) }, { timeoutMs: input.timeoutMs } ) @@ -160,6 +161,7 @@ export async function dispatchCodexTurn( clientMessageId: string body: AgentJournalMessageItem requestedAt?: number + turnOptions?: Readonly> }, timeoutMs: number | undefined ): Promise { diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index e87fb450154c..0c1b9d7691fc 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -6,6 +6,7 @@ import type Database from '../../sqlite/sync-database' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, AGENT_SESSION_OPERATION_FUTURE_SKEW_MS @@ -107,6 +108,7 @@ export class JournalQueuedMessages { input: { messageId: string body: AgentJournalMessageItem + executionOptions?: AgentSessionExecutionOptions fingerprint: string hostInstance: string requireAttachments?: true 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..399402ea88c9 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -9,6 +9,7 @@ import type { AgentSessionJournalProviderHandle } from '../../../shared/agent-session-journal-types' import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { agentSessionJournalProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' @@ -87,6 +88,7 @@ export function journalSubmissionRowBuilder( payloadFingerprint: string body: AgentJournalMessageItem fence: number + executionOptions?: AgentSessionExecutionOptions handoverRecorded?: true queuedMessageId?: string origin?: 'client' | 'host' @@ -276,6 +278,7 @@ export function buildJournalSubmissionRow(input: { payloadFingerprint: string providerHandle: AgentSessionJournalProviderHandle body: AgentJournalMessageItem + executionOptions?: AgentSessionExecutionOptions seq: number fence: number ts: number @@ -290,6 +293,7 @@ export function buildJournalSubmissionRow(input: { payloadFingerprint: input.payloadFingerprint, providerHandle: input.providerHandle, body: input.body, + ...(input.executionOptions ? { executionOptions: input.executionOptions } : {}), ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), ...(input.handoverRecorded ? { handoverRecorded: true } : {}), ...(input.queuedMessageId !== undefined ? { queuedMessageId: input.queuedMessageId } : {}), diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index f018f6307f94..cbb6d615153e 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -22,6 +22,7 @@ // sent-message kind. import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION, type AgentJournalDispatchState, @@ -147,6 +148,7 @@ export function isJournalStopOrResumeRow(row: JournalRow): row is JournalStopOrR * reconcile into instead of appending a second copy. */ export type JournalSubmissionRow = JournalRowBase & { kind: 'submission' + executionOptions?: AgentSessionExecutionOptions clientMessageId: string payloadFingerprint: string providerHandle: AgentSessionJournalProviderHandle diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index f9f7502ea8b8..f92acf0abf94 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -12,6 +12,7 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { JournalHostDatabase } from './journal-host-database' import type { JournalLifecycleMutationInput } from './journal-row-builders' import type { JournalRow } from './journal-row-schema' @@ -94,6 +95,7 @@ export type JournalResolvedLifecycleBatchInput = Omit< } export type JournalSubmissionInput = { + executionOptions?: AgentSessionExecutionOptions clientMessageId: string payloadFingerprint: string body: AgentJournalMessageItem diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index 7b03b4f457f8..44f04800bb6e 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -6,6 +6,7 @@ import { type AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { readAgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { journalRenderItem } from './journal-render-item' import { statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' import type { JournalReducerState } from './journal-reducer' @@ -16,8 +17,10 @@ export function applyJournalSubmission( state: JournalReducerState, row: Extract ): void { + const executionOptions = readAgentSessionExecutionOptions(row.executionOptions) state.submissions.set(row.clientMessageId, { clientMessageId: row.clientMessageId, + ...(executionOptions ? { executionOptions } : {}), fence: row.fence, payloadFingerprint: row.payloadFingerprint, dispatchState: 'pending', diff --git a/src/main/native-chat/agent-session-journal/queued-message-schema.ts b/src/main/native-chat/agent-session-journal/queued-message-schema.ts index 3f9d0122fda2..4274a7f519d6 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-schema.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-schema.ts @@ -12,7 +12,8 @@ const NULLABLE_COLUMNS: readonly (readonly [name: string, type: string])[] = [ ['consumed_as', 'TEXT'], ['carried_from', 'TEXT'], ['queued_epoch', 'TEXT'], - ['queued_sequence', 'INTEGER'] + ['queued_sequence', 'INTEGER'], + ['execution_options_json', 'TEXT'] ] /** @@ -43,6 +44,7 @@ CREATE TABLE IF NOT EXISTS queued_messages ( carried_from TEXT, queued_epoch TEXT, queued_sequence INTEGER, + execution_options_json TEXT, PRIMARY KEY (session_id, message_id) ); `) diff --git a/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts index 51233b66abb0..faf9ca3a8665 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts @@ -5,6 +5,7 @@ import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-sessio import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { readStoredRejectionFact } from './journal-dispatch-reducer' import type { QueuedMessageRow } from './queued-message-table' +import { readAgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' /** A `queued_messages` row as this file's SELECTs return it; null when it cannot be read back. */ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | null { @@ -27,6 +28,7 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul carried_from: string | null queued_epoch: string | null queued_sequence: number | null + execution_options_json: string | null } let body: AgentJournalMessageItem try { @@ -46,8 +48,10 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul ) { return null } + const executionOptions = storedExecutionOptions(record.execution_options_json) return { sessionId: record.session_id, + ...(executionOptions ? { executionOptions } : {}), messageId: record.message_id, position: record.position, body, @@ -69,6 +73,17 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul } } +function storedExecutionOptions(json: string | null): QueuedMessageRow['executionOptions'] | null { + if (json === null) { + return null + } + try { + return readAgentSessionExecutionOptions(JSON.parse(json)) + } catch { + return null + } +} + /** Holds an earlier build stored that this one derives instead: a kept send (`kept`) waits under * the reopen's pause, a Stop's (`stopped`) under the Stop's. */ const QUEUED_MESSAGE_RETIRED_HOLD_REASONS: ReadonlySet = new Set(['kept', 'stopped']) diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index 9f2559804486..fa8361debbb9 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -17,6 +17,7 @@ import type { import type { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../shared/agent-session-queued-message-wire' import { rejectedDraftSettlement } from './journal-dispatch-settlement' import { readStoredQueuedMessageRow } from './queued-message-stored-row' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdrawn' @@ -36,6 +37,7 @@ export function isUnsettledQueuedMessage(row: Pick): export type QueuedMessageRow = { sessionId: string + executionOptions?: AgentSessionExecutionOptions messageId: string position: number body: AgentJournalMessageItem @@ -64,7 +66,7 @@ export type QueuedMessageRow = { } const COLUMNS = - 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from, queued_epoch, queued_sequence' + 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from, queued_epoch, queued_sequence, execution_options_json' export function insertQueuedMessage( db: Database.Database, @@ -72,6 +74,7 @@ export function insertQueuedMessage( sessionId: string messageId: string body: AgentJournalMessageItem + executionOptions?: AgentSessionExecutionOptions fingerprint: string hostInstance: string queuedAt: AgentJournalCursor @@ -88,7 +91,7 @@ export function insertQueuedMessage( const position = input.position ?? Number(highest?.p ?? 0) + 1 db.prepare( `INSERT INTO queued_messages (${COLUMNS}) - VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?)` ).run( input.sessionId, input.messageId, @@ -100,13 +103,15 @@ export function insertQueuedMessage( input.holdReason ?? null, null, input.queuedAt.epoch, - input.queuedAt.sequence + input.queuedAt.sequence, + input.executionOptions ? JSON.stringify(input.executionOptions) : null ) return { sessionId: input.sessionId, messageId: input.messageId, position, body: input.body, + ...(input.executionOptions ? { executionOptions: input.executionOptions } : {}), fingerprint: input.fingerprint, createdAt: input.now, hostInstance: input.hostInstance, 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..8e909cbdc7ea 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 @@ -48,6 +48,7 @@ export type { StructuredAgentSessionStopCause } from './structured-agent-session-stop-cause' import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' @@ -303,6 +304,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & sessionId: string clientMessageId: string body: AgentJournalMessageItem + /** Immutable choices of this submission; adapters apply them only to this dispatch. */ + executionOptions?: AgentSessionExecutionOptions fence: number /** Host clock on the submission row this send came from; the origin the turn * it opens records as `requestedAt`. */ 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..2f53fa7ef76a 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 @@ -41,6 +41,7 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo 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' +import { waitsForCodexTurnOptions } from './structured-agent-session-message-options' export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap @@ -280,6 +281,12 @@ export class StructuredAgentSessionDeliveryLoop { return 'continue' } } + if ( + this.deps.record(sessionId)?.provider === 'codex' && + waitsForCodexTurnOptions(session.journal, next) + ) { + return this.stop(sessionId) + } await handOverSubmission( { sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts new file mode 100644 index 000000000000..8e275ca1646d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts @@ -0,0 +1,40 @@ +import { + readAgentSessionExecutionOptions, + type AgentSessionExecutionOptions +} from '../../../shared/agent-session-execution-options' +import { refuse, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import { structuredAgentSessionOptionOverridesRefusal } from './structured-agent-session-options-read' + +/** Capture once, while acceptance holds the conversation's ordered lane. */ +export function captureStructuredExecutionOptions( + ctx: Pick, + supplied?: AgentSessionExecutionOptions +): { options: AgentSessionExecutionOptions } | { refusal: AgentSessionWireRefusal } { + const saved = ctx.persistedOptions ?? {} + const candidate = supplied ?? { + ...(saved.model ? { model: saved.model } : {}), + ...(saved.effort ? { effort: saved.effort } : {}), + ...(saved.personality ? { personality: saved.personality } : {}), + ...(saved.fastMode ? { fastMode: saved.fastMode } : {}), + ...(saved.serviceTier ? { serviceTier: saved.serviceTier } : {}) + } + const options = readAgentSessionExecutionOptions(candidate) + if (!options) { + return { + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'optionRejected' }, + 'This message has an invalid choice.' + ) + } + } + const refusal = structuredAgentSessionOptionOverridesRefusal( + ctx.agents, + ctx.agent, + Object.fromEntries( + Object.entries(options).filter(([key]) => !(ctx.agent === 'codex' && key === 'fastMode')) + ) + ) + return refusal ? { refusal } : { options } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 8dc39a62d567..cb0cac03d7c8 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 @@ -8,6 +8,7 @@ // an operation only the provider can perform starts it before admission. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { AgentSessionCancelResult, AgentSessionMutationEnvelope, @@ -65,6 +66,7 @@ export function sendStructuredAgentSessionTurn( body: AgentJournalMessageItem retryUnknown?: true delivery?: 'queue-if-active' + executionOptions?: AgentSessionExecutionOptions /** Host-local, set only by the client-facing `agentSession.send` RPC (the * renderer's launch prompt included): recorded as the submission's `client` * origin, so a restart or a close keeps it as a card if it never reached the diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts new file mode 100644 index 000000000000..1c1a1148b364 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, expect, it } from 'vitest' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) +}) + +afterEach(() => rig.dispose()) + +it('uses each send’s captured choices when two messages arrive during startup', async () => { + const releaseStart = rig.holdNextStart() + const first = rig.send('first', undefined, { + executionOptions: { model: 'model-a', effort: 'high' } + }) + const second = rig.send('second', undefined, { + executionOptions: { model: 'model-b', effort: 'low' } + }) + expect(await first.result).toMatchObject({ ok: true }) + expect(await second.result).toMatchObject({ ok: true }) + expect(rig.dispatch).not.toHaveBeenCalled() + releaseStart() + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(1)) + await rig.settleAccepted(first.id, 'a') + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) + expect(rig.dispatch.mock.calls.map(([input]) => input.executionOptions)).toEqual([ + { model: 'model-a', effort: 'high' }, + { model: 'model-b', effort: 'low' } + ]) + expect(rig.setOption).not.toHaveBeenCalled() +}) + +it('keeps a draft’s accepted choices through a host restart and Send now', async () => { + const working = await rig.workingSend() + const choices = { model: 'model-b', effort: 'low' } + const queued = await rig.send('use these choices', 'queue-if-active', { + executionOptions: choices + }).result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const draftId = queued.value.queued.messageId + await rig.restartHostProcess() + const page = await rig.host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.queuedMessages?.[0]?.executionOptions).toEqual(choices) + await rig.settleAccepted(working, 'a') + expect(await rig.sendNow(draftId)).toMatchObject({ ok: true }) + expect((await rig.handoff(draftId))?.executionOptions).toEqual(choices) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts new file mode 100644 index 000000000000..d9b5c98b405f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts @@ -0,0 +1,61 @@ +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' + +const OPTION_ORDER = ['model', 'effort', 'personality', 'fastMode', 'serviceTier'] as const + +function sameOptions( + a: AgentSessionExecutionOptions | undefined, + b: AgentSessionExecutionOptions | undefined +): boolean { + return OPTION_ORDER.every((key) => a?.[key] === b?.[key]) +} + +/** A different Codex choice needs a new turn: steer has no choice parameters. */ +export function waitsForCodexTurnOptions( + journal: Pick, + next: AgentJournalSubmission +): boolean { + const activeTurnId = journal.activeTurnId() + if (!activeTurnId) { + return false + } + const active = journal.submissions().find((submission) => { + if (submission.handedOverAt === undefined) { + return false + } + const item = journal.item(agentJournalSubmissionKey(submission.clientMessageId)) + return item?.turnScope?.kind === 'turn' && item.turnScope.turnItemId === activeTurnId + }) + return active ? !sameOptions(active.executionOptions, next.executionOptions) : true +} + +/** Only the provider's live selection changes here; the saved picker choice stays for future sends. */ +export async function applyMessageExecutionOptions( + adapter: Pick, + sessionId: string, + fence: number, + options: AgentSessionExecutionOptions | undefined +): Promise<{ ok: true } | { ok: false; reason: string }> { + if (!options) { + return { ok: true } + } + for (const key of OPTION_ORDER) { + const value = options[key] + if (value === undefined) { + continue + } + try { + await adapter.setOption({ sessionId, fence, key, value }) + } catch (error) { + if (isAgentSessionOptionRejectedError(error)) { + return { ok: false, reason: error.message } + } + throw error + } + } + return { ok: true } +} 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 305aa6766e1c..097ef7121823 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -6,6 +6,7 @@ // others' current answer. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { AGENT_MESSAGE_SOURCE, USER_MESSAGE_SOURCE, @@ -138,6 +139,7 @@ export function sendPlan(params: { body: AgentJournalMessageItem retryUnknown?: true delivery?: 'queue-if-active' + executionOptions?: AgentSessionExecutionOptions userSend?: true /** A person's message the host sends for them; `userSend` is always one. */ personsMessage?: true @@ -160,7 +162,7 @@ export function sendPlan(params: { recoverUnknownFromDurableState: true, // `retryUnknown` is a compatibility-only client signal. A recorded send // always replays and never reaches the provider twice. - run: (ctx) => { + run: async (ctx) => { // Asked at acceptance: a send accepted after this one is queued behind it. params.beforeRun?.() const source = sendSource(params) @@ -170,7 +172,8 @@ export function sendPlan(params: { clientMessageId, // Body-only, so the reducer's echo aliasing never sees control fields or the sender. payloadFingerprint: agentSessionSendBodyFingerprint(params.envelope.sessionId, params.body), - body: params.body + body: params.body, + executionOptions: params.executionOptions }) }, replay: (ctx, outcome) => { @@ -342,8 +345,7 @@ export function setOptionPlan(params: { method: 'agentSession.setOption', fields: { key: params.key, value: params.value }, run: (ctx) => performSetOption(ctx, params), - // A pending row may have crashed before the adapter call. Reapplying the - // same assignment is safe; only a settled success can be answered directly. + // Reapply unsettled picks; only a settled success can be replayed directly. replay: (ctx, outcome) => outcome.status === 'succeeded' ? { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-cold-catalog.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-cold-catalog.test.ts new file mode 100644 index 000000000000..a295677b8038 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-cold-catalog.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { structuredAgentSessionSeedCatalog } from '../../../shared/structured-agent-session-seed-catalog' +import { + applyStructuredAgentSessionModelCatalog, + applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../../shared/structured-agent-session-options' +import { acpLaunchSpecFor } from '../../acp/acp-launch-specs' +import { acpStructuredAgentDefinition } from '../../acp/acp-structured-agent-definitions' +import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' +import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' +import { PI_RPC_AGENT } from '../../pi/rpc-agent-definition' +import { createAgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' +import { + AgentModelCatalogStore, + type AgentModelCatalogSuccess +} from '../agent-model-catalog/agent-model-catalog-store' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { readStructuredAgentSessionOptions } from './structured-agent-session-options-read' + +const DEFINITIONS = [ + CODEX_STRUCTURED_AGENT, + acpStructuredAgentDefinition(acpLaunchSpecFor('grok')!), + PI_RPC_AGENT, + CLAUDE_STRUCTURED_AGENT +] +const MODELS = [ + { + id: 'configured-model', + label: 'Configured model', + isDefault: true, + defaultEffort: 'high', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } +] + +function coldChat(definition: (typeof DEFINITIONS)[number], starting: boolean) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are all the record fields the options read and catalog fingerprint access. + const record = { + provider: definition.agent, + accountHome: { variable: 'TEST_AGENT_HOME', path: '/test/account' }, + location: { executionHostId: 'local', wslDistro: null }, + options: {} + } as unknown as AgentSessionRecord + const listing = Promise.withResolvers() + const probe = vi.fn(() => listing.promise) + const modelCatalog = createAgentModelCatalogService({ + store: new AgentModelCatalogStore(), + getRecord: () => record, + drivesRecord: () => true, + resolveAccountHome: async () => record.accountHome, + recordWorkspacePath: async () => '/test/folder', + workspaceMayOverrideDefaultModel: async () => false, + listingNamesConfiguredModel: new Set([definition.agent]), + probes: { [definition.agent]: probe } + }) + const resting = { + child: starting ? { phase: 'starting' } : null, + params: { provider: definition.agent }, + journal: { context: { floor: () => null } } + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting path accesses only these context members; no provider process runs. + const context = { + deps: { + adapter: {}, + agents: { definition: () => definition, capabilities: () => null }, + store: { getRecord: () => record }, + modelCatalog + }, + serialize: (_sessionId: string, task: () => Promise) => task(), + openConversation: async () => resting, + conversation: async () => resting + } as unknown as StructuredAgentSessionMutationContext + const read = (sessionId = 'first-chat') => + readStructuredAgentSessionOptions(context, sessionId, { readsWithoutModel: true }) + return { listing, probe, modelCatalog, read } +} + +describe.each(DEFINITIONS)('$agent first options read before its catalog loads', (definition) => { + it.each([false, true])('fills the picker in place with starting=%s', async (starting) => { + const chat = coldChat(definition, starting) + const agent = definition.agent + const seed = structuredAgentSessionSeedCatalog(agent) + let state = createStructuredAgentSessionOptionState(agent, seed) + const pending = await chat.modelCatalog.read({ agent, sessionId: 'first-chat' }) + expect(pending).toMatchObject({ origin: 'unknown', listingInProgress: true }) + const waiting = chat.modelCatalog.read({ agent, sessionId: 'first-chat', waitForListing: true }) + const options = await chat.read() + expect(options.current.model).toBeUndefined() + state = applyStructuredAgentSessionOptions(state, seed, options) + const beforeListing = structuredAgentSessionOptionSnapshot(state) + chat.listing.resolve({ origin: 'probe', models: MODELS }) + const loaded = await waiting + state = applyStructuredAgentSessionModelCatalog(state, seed, loaded, { newLaunch: true }) + expect(structuredAgentSessionOptionSnapshot(state)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + kind: expect.objectContaining({ currentValue: 'configured-model' }) + }), + expect.objectContaining({ + id: 'effort', + kind: expect.objectContaining({ currentValue: 'high' }) + }) + ]) + ) + expect(beforeListing).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'model', settable: false, valueSource: 'unknown' }) + ]) + ) + expect(chat.probe).toHaveBeenCalledOnce() + + const secondOptions = await chat.read('second-chat') + const second = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState(agent, seed), + seed, + secondOptions + ) + expect(secondOptions.current).toMatchObject({ model: 'configured-model', effort: 'high' }) + expect(structuredAgentSessionOptionSnapshot(second)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + kind: expect.objectContaining({ currentValue: 'configured-model' }) + }) + ]) + ) + expect(chat.probe).toHaveBeenCalledOnce() + chat.modelCatalog.stop() + }) +}) 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..b742ab045199 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 @@ -28,7 +28,10 @@ import { structuredAgentSessionLaunchPermission } from './structured-agent-sessi import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -type RestingOptions = Pick +type RestingOptions = Pick< + AgentSessionOptionsResult, + 'models' | 'modelsKnown' | 'fastModeSupport' | 'current' +> /** What the asking client takes: whether it reads an answer that names no model. */ export type StructuredAgentSessionOptionsReader = { readsWithoutModel: boolean } @@ -133,6 +136,7 @@ async function readStructuredAgentSessionOptionsAtRest( : undefined) return { models: listed ? structuredAgentSessionOptionModels(listed, model, (row) => row) : [], + modelsKnown: catalog.origin !== 'unknown', ...(catalog.origin !== 'unknown' && catalog.fastModeSupport ? { fastModeSupport: catalog.fastModeSupport } : {}), 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 10a720c8ce52..bdd6be6144a3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts @@ -51,6 +51,7 @@ export function createQueuedRigProvider( const cancelTurn: Mock = vi.fn(async () => ({ cancelled: true })) + const setOption: Mock = vi.fn(async () => undefined) const closeSession: Mock> = vi.fn( async () => true ) @@ -101,7 +102,7 @@ export function createQueuedRigProvider( ...(options.stopEndsSession ? { stopEndsSession: () => true } : {}), ...(options.startUnanswered ? { startAnswered: () => false } : {}), answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined), + setOption, ...(options.rewind ? { rewind: options.rewind, rewindSupport: () => ({ supported: true as const }) } : {}), @@ -155,6 +156,7 @@ export function createQueuedRigProvider( failNextStart, compact, cancelTurn, + setOption, closeSession, finishCompact, providerEvents diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 41fc7807bcc4..9af72d823e5e 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 @@ -10,6 +10,7 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { agentSessionMessagePayload } from '../../../shared/structured-agent-session-send-mutation' @@ -35,7 +36,11 @@ import { import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } -type RigSendOptions = { internal?: true; from?: AgentMessageSource } +type RigSendOptions = { + internal?: true + from?: AgentMessageSource + executionOptions?: AgentSessionExecutionOptions +} export function eventually(assertion: () => void | Promise): Promise { return vi.waitFor(assertion, { timeout: 10_000 }) @@ -55,7 +60,7 @@ export async function createQueuedMessageTestRig( resetHostTestOperationIds() const store = await openTestAgentSessionRecordStore(root) const provider = createQueuedRigProvider(store, options, () => host) - const { dispatch, compact, cancelTurn, closeSession } = provider + const { dispatch, compact, cancelTurn, closeSession, setOption } = provider const makeHost = () => new StructuredAgentSessionHost({ agents: claudeAndCodexDeclared(), @@ -100,6 +105,7 @@ export async function createQueuedMessageTestRig( envelope: envelope(fields, 'agentSession.send', clientOperationId), ...fields, body, + ...(options?.executionOptions ? { executionOptions: options.executionOptions } : {}), ...(options?.internal ? {} : { userSend: true as const }) }) return { id: clientOperationId, result } @@ -281,6 +287,7 @@ export async function createQueuedMessageTestRig( return host }, dispatch, + setOption, cancelTurn, closeSession, compact, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index ac85a5ed8bfb..ad53238edccf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -8,6 +8,7 @@ import { randomUUID } from 'node:crypto' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionSendResult, @@ -33,6 +34,7 @@ import { nextSendableQueuedCard } from '../agent-session-journal/queued-message- import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import { agentSessionAttachmentExpiredRefusal } from './structured-agent-session-turns' import { isAgentSessionAttachmentExpiredError } from '../agent-session-attachments/agent-session-attachment-claims' +import { captureStructuredExecutionOptions } from './structured-agent-session-execution-options' /** Text-only v1: any image block routes to the immediate path. */ export function queuedMessageBodyIsTextOnly(body: AgentJournalMessageItem): boolean { @@ -176,11 +178,15 @@ export async function maybeQueueStructuredAgentSessionSend( context: { deps: { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } } }, - ctx: Pick, + ctx: Pick< + AgentSessionTurnContext, + 'sessionId' | 'journal' | 'fence' | 'operationReceipt' | 'agents' | 'agent' | 'persistedOptions' + >, params: { envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + executionOptions?: AgentSessionExecutionOptions /** A person's send at a chat surface: every attachment it names must still be stored. */ userSend?: true /** A person's message the host sends for them. */ @@ -222,6 +228,10 @@ export async function maybeQueueStructuredAgentSessionSend( if (refusal) { return { ok: false, refusal } } + const captured = captureStructuredExecutionOptions(ctx, params.executionOptions) + if ('refusal' in captured) { + return { ok: false, refusal: captured.refusal } + } // The insert notifies through the journal's commit listener: publication and // the drain re-derive with no call here to forget. let row: QueuedMessageRow @@ -230,6 +240,7 @@ export async function maybeQueueStructuredAgentSessionSend( { messageId: clientMessageId, body: params.body, + executionOptions: captured.options, // In the session that will send it: the reducer aliases the provider's echo by exactly this. fingerprint: agentSessionSendBodyFingerprint(ctx.sessionId, params.body), hostInstance: structuredAgentSessionHostInstance(), @@ -357,6 +368,7 @@ export class StructuredAgentSessionQueuedMessageDrain { origin: 'host', payloadFingerprint: next.fingerprint, body: next.body, + ...(next.executionOptions ? { executionOptions: next.executionOptions } : {}), fence, handoverRecorded: true }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index a3fee6e2a339..62d62262b12c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -166,6 +166,7 @@ export function sendQueuedStructuredAgentMessage( origin: 'client', payloadFingerprint: row.fingerprint, body: row.body, + ...(row.executionOptions ? { executionOptions: row.executionOptions } : {}), fence: ctx.fence, handoverRecorded: true }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts index d48d9bf27c39..808cd1a83513 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -52,6 +52,7 @@ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSess const held = row.state === 'waiting' && row.holdReason !== null published.push({ messageId: row.messageId, + ...(row.executionOptions ? { executionOptions: row.executionOptions } : {}), position: row.position, body: row.body, state: row.state, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts index d472e159fcbc..cbcbeafae3c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts @@ -3,6 +3,7 @@ import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { maybeQueueStructuredAgentSessionSend } from './structured-agent-session-queued-messages' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -14,6 +15,7 @@ export async function runQueueableStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + executionOptions?: AgentSessionExecutionOptions userSend?: true personsMessage?: true }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index 228bf2b66cd6..01dc17733798 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -25,13 +25,15 @@ import { } from './structured-agent-session-host-test-data' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let dispatch: Mock +let setOption: Mock beforeEach(() => { - ;({ store, host, dispatch } = hostTestState()) + ;({ store, host, dispatch, setOption } = hostTestState()) }) function hostJournal(): AgentSessionJournal { @@ -57,6 +59,67 @@ async function delivered(clientMessageId: string, options: { handedOver?: true } } describe('send', () => { + it('passes each accepted message choice to its provider dispatch and keeps replay immutable', async () => { + await attach() + const first = hostTestMessage('first') + const second = hostTestMessage('second') + const a = { + envelope: envelope('agentSession.send', { body: first }), + body: first, + executionOptions: { model: 'model-a', effort: 'high' } + } + const b = { + envelope: envelope('agentSession.send', { body: second }), + body: second, + executionOptions: { model: 'model-b', effort: 'low' } + } + await host.send(CALLER, a) + await delivered(a.envelope.clientOperationId) + await host.send(CALLER, b) + await delivered(b.envelope.clientOperationId) + + expect(dispatch.mock.calls.map(([input]) => input.executionOptions)).toEqual([ + a.executionOptions, + b.executionOptions + ]) + expect(dispatch).toHaveBeenCalledTimes(2) + expect( + hostJournal() + .submissions() + .map((submission) => submission.executionOptions) + ).toEqual([a.executionOptions, b.executionOptions]) + await expect( + host.send(CALLER, { ...a, executionOptions: b.executionOptions }) + ).resolves.toMatchObject({ + ok: true, + replayed: true + }) + expect(dispatch).toHaveBeenCalledTimes(2) + expect(setOption).not.toHaveBeenCalled() + }) + + it('settles a refused message choice without dispatching its prompt', async () => { + await attach() + dispatch.mockRejectedValueOnce(new AgentSessionOptionRejectedError('model unavailable')) + const body = hostTestMessage('do not send') + const params = { + envelope: envelope('agentSession.send', { body }), + body, + executionOptions: { model: 'model-unavailable' } + } + const result = await host.send(CALLER, params) + expect(result).toMatchObject({ ok: true }) + await vi.waitFor(() => { + expect( + hostJournal() + .submissions() + .find((submission) => submission.clientMessageId === params.envelope.clientOperationId) + ).toMatchObject({ dispatchState: 'rejected' }) + }) + expect(dispatch).toHaveBeenCalledOnce() + expect(setOption).not.toHaveBeenCalled() + }) + it('writes the submission before dispatching and resolves it accepted', async () => { await attach() const body = hostTestMessage('add a retry') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts index 46a62a05dc49..7cad7d0d9363 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts @@ -1,6 +1,7 @@ // Which status-summary changes reach every session list. The summary is broadcast to remote // subscribers too, so only a change a session list shows may re-send it. +import { structuralValuesEqual } from '../../../shared/structural-value-equality' import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' import { agentSessionBackgroundTasksEqual, @@ -32,6 +33,7 @@ export function structuredStatusSummariesEqual( a.latestPrompt === b.latestPrompt && a.model === b.model && a.permissionMode === b.permissionMode && + structuralValuesEqual(a.optionSelection, b.optionSelection) && a.toolName === b.toolName && a.toolInput === b.toolInput && a.lastAssistantMessage === b.lastAssistantMessage && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts index 5edc115cdcca..270583abdec0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts @@ -62,6 +62,7 @@ export function structuredAgentSessionStatusSummary({ : {}), ...(model ? { model } : {}), ...(isAgentChatPermissionMode(permissionMode) ? { permissionMode } : {}), + ...(record ? { optionSelection: record.options ?? {} } : {}), ...childWork, ...(providerSession ? { providerSession } : {}), ...(launchDirectory ? { launchDirectory } : {}), 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 004a16a7cb8b..09ae3e55e7b6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -6,7 +6,14 @@ // row the next attach settles as `unknown`, whereas the reverse would lose a // turn the provider already accepted. -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { + agentSessionFailureFact, + providerDiagnosticOf +} from '../../../shared/agent-session-failure' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' +import { captureStructuredExecutionOptions } from './structured-agent-session-execution-options' +import { applyMessageExecutionOptions } from './structured-agent-session-message-options' +import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' import { agentSessionFailureWords, type AgentSessionFailureWordsContext @@ -103,17 +110,28 @@ async function dispatchSafely( ctx: AgentSessionHandoverContext, clientMessageId: string, body: AgentJournalMessageItem, - requestedAt: number + requestedAt: number, + executionOptions: AgentSessionExecutionOptions | undefined ): Promise { try { return await ctx.adapter.dispatch({ sessionId: ctx.sessionId, clientMessageId, body, + ...(executionOptions ? { executionOptions } : {}), fence: ctx.fence, requestedAt }) } catch (error) { + if (isAgentSessionOptionRejectedError(error)) { + return { + state: 'rejected', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { detail: providerDiagnosticOf(error) }), + { surface: 'rejection' } + ) + } + } return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } } } @@ -133,6 +151,7 @@ export async function performSend( input: { clientMessageId: string payloadFingerprint: string + executionOptions?: AgentSessionExecutionOptions body: AgentJournalMessageItem /** Who asked for the turn; absent on callers that predate it. */ origin?: 'client' | 'host' @@ -155,9 +174,13 @@ export async function performSend( value: { clientMessageId: input.clientMessageId, submission: { ...existing } } } } + const captured = captureStructuredExecutionOptions(ctx, input.executionOptions) + if ('refusal' in captured) { + return { ok: false, refusal: captured.refusal } + } try { await ctx.journal.appendSubmission( - { ...input, fence: ctx.fence, handoverRecorded: true }, + { ...input, executionOptions: captured.options, fence: ctx.fence, handoverRecorded: true }, undefined, ctx.operationReceipt ) @@ -219,6 +242,29 @@ export async function handOverSubmission( await handOverStructuredAgentSessionCommand(ctx, submission, body) return } + const codex = ctx.record()?.provider === 'codex' + const applied = codex + ? { ok: true as const } + : await applyMessageExecutionOptions( + ctx.adapter, + ctx.sessionId, + ctx.fence, + submission.executionOptions + ) + if (!applied.ok) { + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'rejected', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { + detail: providerDiagnosticOf(new Error(applied.reason)) + }), + { surface: 'rejection' } + ), + fence: ctx.fence + }) + return + } // The message joins the turn running at handover, a steer, or opens its own. await ctx.journal.resolveDispatch({ clientMessageId, @@ -232,7 +278,8 @@ export async function handOverSubmission( ctx, clientMessageId, body, - structuredAgentSessionHandoverOrigin(ctx.journal, submission) + structuredAgentSessionHandoverOrigin(ctx.journal, submission), + codex ? submission.executionOptions : undefined ) // An admission needs no dispatch row: the submission is already pending. if (outcome.state === 'admitted') { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 7ed6ba234461..7ce911de145c 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -86,7 +86,7 @@ export function NativeChatStructuredSession( composerScopeKey: structuredAgentSessionDraftScopeKey(props.sessionId), queueFollowUps, hostStopping: hostExecution.stopping, - hostOptionStamp: hostExecution.optionStamp, + optionSelection: hostExecution.optionSelection, providerStarting: hostExecution.phase === 'starting', // A start's own verdict (a signed-out agent) exists only once that start is proven. providerRunning: hostExecution.phase === 'ready', diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index cd32e239fa02..49c6a1e817b8 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useSyncExternalStore } from 'react' import { useShallow } from 'zustand/react/shallow' +import { structuralValuesEqual } from '../../../../shared/structural-value-equality' import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' import { @@ -94,7 +95,6 @@ export function useStructuredAgentSessionHostExecutionPhase( ) } -/** Whether the host says a person's Stop is still ending the work; an older host never does. */ export function useStructuredAgentSessionHostStopping( sessionId: string, target: RuntimeClientTarget @@ -108,20 +108,48 @@ export function useStructuredAgentSessionHostStopping( ) } -/** Startup phase, Stop and option stamp, each re-rendering the chat only on a change. */ +type HostExecutionSelection = { + phase: AgentSessionStatusSummary['hostExecutionPhase'] | null + stopping: boolean + optionSelection: AgentSessionStatusSummary['optionSelection'] +} + +const EMPTY_HOST_EXECUTION: HostExecutionSelection = { + phase: null, + stopping: false, + optionSelection: undefined +} + +/** Select only the host facts the chat renders. */ export function useStructuredAgentSessionHostExecution( sessionId: string, target: RuntimeClientTarget -): { - phase: ReturnType - stopping: boolean - optionStamp: string -} { - return { - phase: useStructuredAgentSessionHostExecutionPhase(sessionId, target), - stopping: useStructuredAgentSessionHostStopping(sessionId, target), - optionStamp: useStructuredAgentSessionOptionStamp(sessionId, target) - } +): HostExecutionSelection { + const feed = useMemo(() => getStructuredAgentSessionStatusFeed(target), [target]) + useEffect(() => feed.activate(), [feed]) + const snapshot = useMemo(() => { + let previous = EMPTY_HOST_EXECUTION + return (): HostExecutionSelection => { + const summary = feed.getSnapshot().get(sessionId) + const next = { + phase: summary?.hostExecutionPhase ?? null, + stopping: summary?.stopping === true, + optionSelection: structuralValuesEqual(previous.optionSelection, summary?.optionSelection) + ? previous.optionSelection + : summary?.optionSelection + } + if ( + previous.phase === next.phase && + previous.stopping === next.stopping && + previous.optionSelection === next.optionSelection + ) { + return previous + } + previous = next + return next + } + }, [feed, sessionId]) + return useSyncExternalStore(feed.subscribe, snapshot, () => EMPTY_HOST_EXECUTION) } /** Only the host's rewind recovery latch, so a chat re-renders when that changes, not on every status. */ diff --git a/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts b/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts index 24ee3073779d..bcc8d864e26a 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts @@ -2,6 +2,7 @@ // chat's view reads. import type { structuredAgentSessionSendBody } from '../../../../shared/structured-agent-session-send-mutation' +import type { AgentSessionExecutionOptions } from '../../../../shared/agent-session-execution-options' export type StructuredAgentSessionPendingSend = { clientMessageId: string @@ -10,6 +11,7 @@ export type StructuredAgentSessionPendingSend = { previewUris: readonly string[] queuedAt: number delivery?: 'queue-if-active' + executionOptions?: AgentSessionExecutionOptions /** * `sending`: on its way, its one request out or about to be; the chat takes no other send meanwhile. * `recorded`: the host holds it; an open chat draws it until the host's row or card arrives. diff --git a/src/renderer/src/components/native-chat/structured-agent-session-send-attempt.ts b/src/renderer/src/components/native-chat/structured-agent-session-send-attempt.ts index 076ce0474442..8ef262785675 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-send-attempt.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-send-attempt.ts @@ -125,7 +125,8 @@ export async function attemptStructuredAgentSessionSend(args: { clientOperationId: entry.clientMessageId, expectedRuntimeFence: fence, body: entry.body, - ...(entry.delivery ? { delivery: entry.delivery } : {}) + ...(entry.delivery ? { delivery: entry.delivery } : {}), + ...(entry.executionOptions ? { executionOptions: entry.executionOptions } : {}) }) type SendAnswer = AgentSessionMutationResult let answer: StructuredAgentSessionSendAnswer diff --git a/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts b/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts index e54850bdecda..a21d195d8a49 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts @@ -2,6 +2,7 @@ // of that chat out until it settles. import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation' +import type { AgentSessionExecutionOptions } from '../../../../shared/agent-session-execution-options' import { structuredAgentSessionSendBody, type StructuredAgentSessionAttachment @@ -20,6 +21,7 @@ export type StructuredAgentSessionSendInput = { attachments?: readonly StructuredAgentSessionAttachment[] /** Asks the host to hold it as a card while the agent works; decided once, at the send. */ delivery?: 'queue-if-active' + executionOptions?: AgentSessionExecutionOptions /** The caller keeps the text if it comes back, instead of the chat's composer. */ callerKeepsText?: true /** Made while the chat read Stopping: drawn after that turn until the host records it. */ @@ -42,6 +44,7 @@ export function takeStructuredAgentSessionSendSlot( previewUris: attachments.map((attachment) => attachment.previewUri), queuedAt: input.now ?? Date.now(), ...(input.delivery ? { delivery: input.delivery } : {}), + ...(input.executionOptions ? { executionOptions: input.executionOptions } : {}), ...(input.callerKeepsText ? { callerKeepsText: true as const } : {}), ...(input.sentWhileStopping ? { sentWhileStopping: true as const } : {}), ...(attachments.some((attachment) => attachment.connectionId) diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts index 1722720a7b3f..f160b47b6fe5 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts @@ -128,13 +128,16 @@ export function useHostModelCatalogUpgrade(args: { if (catalog && newLaunch) { recordHostModelCatalogSnapshot(target, agent, worktree ?? '', catalog) } + // A new scoped default must refresh the view even when its live choices already won. updateOptionState((current) => current.record !== activeOptionRecordRef.current ? current : catalog - ? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, { - newLaunch - }) + ? { + ...applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, { + newLaunch + }) + } : settleStructuredAgentSessionBuiltinCatalog(current) ) } 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 b5fdd7bdf378..3ae528191476 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 @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { AgentSessionConversationCommand } from '../../../../shared/agent-session-conversation-command' import type { AgentSessionOptionsResult } from '../../../../shared/agent-session-wire' import type { AgentSessionRewindSupport } from '../../../../shared/agent-session-rewind' @@ -8,13 +8,19 @@ import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured import { applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, + commitStructuredAgentSessionOptionValues, createStructuredAgentSessionOptionState, + structuredAgentSessionOptionCatalog, type StructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' 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 { + cloneNativeChatSessionOptionRecord, + createNativeChatSessionOptionRecord +} from '../../../../shared/native-chat-session-option-state' +import { structuredAgentSessionOptionView } from '../../../../shared/structured-agent-session-option-view' import { createCoalescedPollRunner, type CoalescedPollRunner @@ -33,7 +39,7 @@ function firstFrameOptionState( return snapshot ? applyStructuredAgentSessionModelCatalog(seeded, seed, snapshot, launch) : seeded } -/** The picker's option state for one session: seeded, kept across its fences, re-read each turn. */ +/** The picker's catalog and selection for one conversation on its execution host. */ export function useStructuredAgentSessionOptionState(args: { agent: AgentType optionCatalog: AgentSessionOptionCatalog | null @@ -51,6 +57,9 @@ export function useStructuredAgentSessionOptionState(args: { /** The option values the host's status carries: a pick made on another device moves it. */ hostOptionStamp: string readsBeforeStart: boolean + hostSelection: Readonly> | undefined + launchSeedOptions: Readonly> | undefined + held: Readonly> turnId: string | null unloadedTurnRevisions: number | undefined }) { @@ -65,11 +74,15 @@ export function useStructuredAgentSessionOptionState(args: { providerStarting, providerVisible, readsBeforeStart, + hostSelection, + launchSeedOptions, + held, sessionId, target, turnId, worktree } = args + const selectionFromStatus = hostSelection !== undefined const [conversationSupport, setConversationSupport] = useState<{ sessionId: string commands: readonly AgentSessionConversationCommand[] @@ -91,14 +104,12 @@ export function useStructuredAgentSessionOptionState(args: { ...(worktree ? { worktree } : {}) }) ) - // Read only when a fence or session resets the state, so a new target alone resets nothing. const firstFrameLaunch = useRef({ target, newLaunch, worktree, seedsModel }) useEffect(() => { firstFrameLaunch.current = { target, newLaunch, worktree, seedsModel } }, [newLaunch, seedsModel, target, worktree]) const optionStateRef = useRef(optionState) const activeOptionRecordRef = useRef(optionState.record) - const pendingOptionRef = useRef(null) const optionMutationGeneration = useRef(0) const updateOptionState = useCallback( (update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState) => { @@ -113,6 +124,11 @@ export function useStructuredAgentSessionOptionState(args: { const previous = optionStateRef.current const sameSession = optionIdentityRef.current === identity optionIdentityRef.current = identity + if (sameSession && selectionFromStatus) { + optionMutationGeneration.current += 1 + updateOptionState((current) => ({ ...current, pendingPick: null })) + return + } const launch = firstFrameLaunch.current const seeded = firstFrameOptionState(agent, launch.target, { newLaunch: launch.newLaunch, @@ -122,25 +138,25 @@ export function useStructuredAgentSessionOptionState(args: { // 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 kept = - sameSession && previous.catalogSource !== 'seed' && previous.catalogSource !== null + sameSession && + (previous.record.model !== undefined || + (previous.catalogSource !== 'seed' && previous.catalogSource !== null)) ? { ...previous, record: cloneNativeChatSessionOptionRecord(previous.record), - pendingId: null + pendingPick: null } : seeded // The chat's own, which a restart for a new mode keeps: the picker never blanks between them. const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept optionMutationGeneration.current += 1 - pendingOptionRef.current = null - optionStateRef.current = next activeOptionRecordRef.current = next.record - setOptionState(next) - }, [agent, fence, identity]) + updateOptionState(() => next) + }, [agent, fence, identity, selectionFromStatus, updateOptionState]) const optionsReadRef = useRef(null) - // Refresh options each turn to confirm which model the provider actually selected, and once - // the provider starts: only then has the host read what it will run. + // The read also carries commands, goal, context, image and rewind support. A capable host's + // selection comes from status even when this metadata read returns an older model. useEffect(() => { if (!providerVisible || (providerStarting && !readsBeforeStart) || !optionCatalog) { return @@ -164,11 +180,19 @@ export function useStructuredAgentSessionOptionState(args: { rewind: result.rewind ?? { supported: false, reason: 'unsupported' }, fence }) - updateOptionState((current) => - current.record === activeOptionRecordRef.current - ? applyStructuredAgentSessionOptions(current, optionCatalog, result) - : current - ) + updateOptionState((current) => { + if (current.record !== activeOptionRecordRef.current) { + return current + } + if (!selectionFromStatus) { + return applyStructuredAgentSessionOptions(current, optionCatalog, result) + } + if (result.modelsKnown === false) { + return current + } + const catalog = structuredAgentSessionOptionCatalog(optionCatalog, result) + return { ...current, catalog, catalogSource: 'live' } + }) } }) optionsReadRef.current = runner @@ -185,6 +209,7 @@ export function useStructuredAgentSessionOptionState(args: { providerStarting, providerVisible, readsBeforeStart, + selectionFromStatus, sessionId, target, turnId, @@ -204,13 +229,89 @@ export function useStructuredAgentSessionOptionState(args: { // An options answer made before the account's listing landed listed none of it. const rereadOptions = useCallback(() => optionsReadRef.current?.run(), []) + const configured = readHostModelCatalogSnapshot(target, agent, { + newLaunch, + worktree, + seedsModel + }) return { conversationSupport, rereadOptions, optionState, + optionView: useMemo(() => { + // Live rows supply choices; the latest scoped host answer supplies the launch default. + const scoped = + configured && optionCatalog + ? applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState(agent), + optionCatalog, + configured, + { newLaunch } + ).catalog + : null + const defaultModel = scoped?.defaultModelIsCliDefault + ? scoped.models.find((model) => model.isDefault) + : undefined + const defaultEffort = defaultModel?.options.find((option) => option.id === 'effort')?.kind + const catalog = + optionState.catalog && selectionFromStatus + ? { + ...optionState.catalog, + defaultModelIsCliDefault: defaultModel ? (true as const) : undefined, + models: optionState.catalog.models.map((model) => ({ + ...model, + isDefault: model.id === defaultModel?.id, + options: model.options.map((option) => + model.id === defaultModel?.id && + option.id === 'effort' && + option.kind.type === 'select' && + defaultEffort?.type === 'select' && + defaultEffort.defaultIsCliDefault && + option.kind.choices.some((choice) => choice.value === defaultEffort.defaultValue) + ? { + ...option, + kind: { + ...option.kind, + defaultValue: defaultEffort.defaultValue, + defaultIsCliDefault: true as const + } + } + : option + ) + })) + } + : optionState.catalog + const selected = selectionFromStatus + ? commitStructuredAgentSessionOptionValues( + { ...optionState, catalog, record: createNativeChatSessionOptionRecord(agent) }, + hostSelection + ) + : optionState + const pending = optionIdentityRef.current === identity ? optionState.pendingPick : null + return structuredAgentSessionOptionView( + { + ...(selectionFromStatus && pending + ? commitStructuredAgentSessionOptionValues(selected, { [pending.id]: pending.value }) + : selected), + pendingPick: pending + }, + selectionFromStatus ? undefined : launchSeedOptions, + held + ) + }, [ + agent, + configured, + identity, + newLaunch, + optionCatalog, + held, + hostSelection, + launchSeedOptions, + optionState, + selectionFromStatus + ]), optionStateRef, activeOptionRecordRef, - pendingOptionRef, optionMutationGeneration, updateOptionState } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-surface.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-surface.ts new file mode 100644 index 000000000000..3067588e624d --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-option-surface.ts @@ -0,0 +1,53 @@ +import { useCallback, useMemo } from 'react' +import type { + SessionOptionDescriptor, + SessionOptionsSurface +} from '../../../../shared/native-chat-session-options' +import { cloneNativeChatSessionOptionRecord } from '../../../../shared/native-chat-session-option-state' +import { + commitStructuredAgentSessionOptionValues, + structuredAgentSessionOptionSnapshot, + type StructuredAgentSessionOptionState +} from '../../../../shared/structured-agent-session-options' +import { structuredAgentSessionOptionView } from '../../../../shared/structured-agent-session-option-view' +import { getStructuredAgentSessionLaunchSelection } from '@/lib/structured-agent-session-launch-options' + +/** A menu pick returns the latest held launch value even before React renders it. */ +export function useStructuredAgentSessionOptionSurface(args: { + sessionId: string + optionView: StructuredAgentSessionOptionState + optionSnapshot: SessionOptionDescriptor[] + changeOption: ( + id: string, + value: string | boolean + ) => Promise> | null> +}): SessionOptionsSurface { + const { sessionId, optionView, optionSnapshot, changeOption } = args + const setOption = useCallback( + async (id: string, value: string | boolean) => { + const committed = await changeOption(id, value) + const held = getStructuredAgentSessionLaunchSelection(sessionId)?.held + const selected = committed + ? commitStructuredAgentSessionOptionValues( + { ...optionView, record: cloneNativeChatSessionOptionRecord(optionView.record) }, + committed + ) + : optionView + return { + snapshot: structuredAgentSessionOptionSnapshot( + structuredAgentSessionOptionView(selected, undefined, held ?? {}) + ) + } + }, + [changeOption, optionView, sessionId] + ) + return useMemo( + () => ({ + getSnapshot: () => optionSnapshot, + setOption, + invokeAction: async () => ({ snapshot: optionSnapshot }), + subscribe: () => () => {} + }), + [setOption, optionSnapshot] + ) +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.cold-catalog.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.cold-catalog.test.tsx new file mode 100644 index 000000000000..168b574fb32f --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.cold-catalog.test.tsx @@ -0,0 +1,109 @@ +// @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 +})) + +import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' +import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' +import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' + +const LOCAL = { kind: 'local' } as const +const EMPTY_SELECTION = {} +const LAUNCH = { kind: 'new' as const, heldOptions: {}, worktree: 'folder:one' } +const MODELS = [ + { + id: 'configured-model', + label: 'Configured model', + isDefault: true, + defaultEffort: 'high', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } +] +const LOADED: AgentSessionModelCatalogResult = { + origin: 'probe', + models: MODELS, + fetchedAt: 1, + listingNamesConfiguredModel: true +} + +function renderChat(agent: string, selectionFromStatus: boolean, sessionId = 'first-chat') { + const frames: string[][] = [] + const view = renderHook(() => { + const options = useStructuredAgentSessionOptions({ + agent, + sessionId, + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + ...(selectionFromStatus ? { optionSelection: EMPTY_SELECTION } : {}), + fence: 1, + turnId: null, + unloadedTurnRevisions: undefined, + mutate: async () => null, + launch: LAUNCH + }) + frames.push(options.optionSnapshot.map((option) => option.id)) + return options + }) + return { ...view, frames } +} + +beforeEach(() => { + mocks.call.mockReset() + resetHostModelCatalogSnapshotsForTests() +}) + +it.each(['codex', 'grok', 'pi', 'claude'])( + '%s keeps its picker when options answer before the first listing, with either selection source', + async (agent) => { + for (const selectionFromStatus of [false, true]) { + resetHostModelCatalogSnapshotsForTests() + const listing = Promise.withResolvers() + mocks.call.mockImplementation( + async (_target: unknown, method: string, params: { waitForListing?: boolean }) => + method === 'agentSession.modelCatalog' + ? params.waitForListing + ? listing.promise + : { origin: 'unknown', listingInProgress: true } + : { models: [], modelsKnown: false, current: {}, conversationCommands: ['clear'] } + ) + const view = renderChat(agent, selectionFromStatus) + await waitFor(() => expect(view.result.current.conversationCommands).toEqual(['clear'])) + expect(view.frames.every((frame) => frame.includes('model'))).toBe(true) + await act(async () => listing.resolve(LOADED)) + await waitFor(() => { + const snapshot = view.result.current.optionSnapshot + expect(snapshot.find((option) => option.id === 'model')?.kind).toMatchObject({ + currentValue: 'configured-model' + }) + expect(snapshot.find((option) => option.id === 'effort')?.kind).toMatchObject({ + currentValue: 'high' + }) + }) + view.unmount() + + mocks.call.mockImplementation(async (_target: unknown, method: string) => + method === 'agentSession.modelCatalog' + ? LOADED + : { models: MODELS, current: { model: 'configured-model', effort: 'high' } } + ) + const second = renderChat(agent, selectionFromStatus, 'second-chat') + expect( + second.result.current.optionSnapshot.find((option) => option.id === 'model')?.kind + ).toMatchObject({ currentValue: 'configured-model' }) + await waitFor(() => expect(second.result.current.rewind).toBeDefined()) + expect(second.frames.every((frame) => frame.includes('model'))).toBe(true) + second.unmount() + } + } +) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx new file mode 100644 index 000000000000..d1f48321e4a3 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx @@ -0,0 +1,408 @@ +// @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(), summary: vi.fn(), settings: 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: mocks.settings +})) +vi.mock('@/lib/structured-agent-session-launch-options', () => ({ + holdStructuredAgentSessionLaunchOption: vi.fn(), + getStructuredAgentSessionLaunchSelection: () => null +})) + +import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +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' + +const EFFORTS = [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } +] +const MODELS = [ + { + id: 'gpt-one', + label: 'One', + isDefault: true, + defaultEffort: 'medium', + efforts: EFFORTS.filter((effort) => effort.value !== 'high') + }, + { id: 'gpt-two', label: 'Two', efforts: EFFORTS } +] +const PICK = { model: 'gpt-two', effort: 'high' } +const LOCAL = { kind: 'local' } as const + +function pill(snapshot: readonly SessionOptionDescriptor[]) { + const current = (id: string): string | null => { + const entry = snapshot.find((option) => option.id === id) + return entry?.kind.type === 'select' ? (entry.kind.currentValue ?? null) : null + } + return { model: current('model'), effort: current('effort') } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => (resolve = settle)) + return { promise, resolve } +} + +beforeEach(() => { + mocks.call.mockReset() + mocks.summary.mockReset() + mocks.settings.mockReset() + resetHostModelCatalogSnapshotsForTests() + mocks.call.mockImplementation(async (_target: unknown, method: string) => + method === 'agentSession.modelCatalog' + ? { + origin: 'live-session', + models: MODELS, + fetchedAt: 1, + listingNamesConfiguredModel: true + } + : { + models: MODELS, + current: { model: 'gpt-one', effort: 'medium' }, + conversationCommands: [{ name: '/help', description: 'Help' }], + rewind: { supported: false, reason: 'unsupported' } + } + ) +}) + +function renderSelection(options: { + capable: boolean + selection?: Readonly> +}) { + const frames: ReturnType[] = [] + const launch = { kind: 'new' as const, heldOptions: {}, seedOptions: PICK } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this proof makes no option mutation. + const mutate = vi.fn() as unknown as StructuredAgentSessionMutate + mocks.summary.mockImplementation(() => + options.capable ? { optionSelection: options.selection ?? PICK } : { status: 'idle' } + ) + const initialProps: { fence: number; turnId: string | null } = { fence: 1, turnId: null } + const view = renderHook( + (props: { fence: number; turnId: string | null }) => { + const current = useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId: 'session-1', + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + optionSelection: mocks.summary()?.optionSelection, + fence: props.fence, + turnId: props.turnId, + unloadedTurnRevisions: undefined, + mutate, + launch + }) + frames.push(pill(current.optionSnapshot)) + return current + }, + { initialProps } + ) + return { frames, ...view } +} + +it('keeps the host selection through fence, turn, and stale metadata reads', async () => { + const view = renderSelection({ capable: true }) + await waitFor(() => expect(pill(view.result.current.optionSnapshot)).toEqual(PICK)) + const nextFrame = view.frames.length + view.rerender({ fence: 2, turnId: 'turn-1' }) + await waitFor(() => + expect(mocks.call).toHaveBeenCalledWith({ kind: 'local' }, 'agentSession.options', { + sessionId: 'session-1' + }) + ) + await waitFor(() => expect(view.result.current.conversationCommands).toHaveLength(1)) + expect(view.frames.slice(nextFrame)).toEqual(expect.arrayContaining([PICK])) + expect(view.frames.slice(nextFrame).every((frame) => frame.model === PICK.model)).toBe(true) + expect(view.frames.slice(nextFrame).every((frame) => frame.effort === PICK.effort)).toBe(true) + view.unmount() +}) + +it('uses the existing options read when an older host omits the selection field', async () => { + const view = renderSelection({ capable: false }) + await waitFor(() => + expect(pill(view.result.current.optionSnapshot)).toEqual({ + model: 'gpt-one', + effort: 'medium' + }) + ) + view.unmount() +}) + +it('retires an accepted pick on reply even when another host change arrived first', async () => { + let selection: Readonly> = { model: 'gpt-one' } + mocks.summary.mockImplementation(() => ({ optionSelection: selection })) + const write = deferred<{ options: Record }>() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deferred result is the setOption reply the hook reads. + const mutate = vi.fn(() => write.promise) as unknown as StructuredAgentSessionMutate + const launch = { kind: 'new' as const, heldOptions: {} } + const view = renderHook( + (sessionId: string) => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId, + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + optionSelection: mocks.summary()?.optionSelection, + fence: 1, + turnId: null, + unloadedTurnRevisions: undefined, + mutate, + launch + }), + { initialProps: 'session-1' } + ) + await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) + let request!: Promise + act(() => { + request = view.result.current.setStructuredOption('model', 'gpt-two') + }) + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') + selection = { model: 'gpt-two' } + view.rerender('session-1') + selection = { model: 'gpt-one' } + view.rerender('session-1') + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') + await act(async () => { + write.resolve({ options: { model: 'gpt-two' } }) + await request + }) + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') + view.unmount() +}) + +it('admits one pending pick, including after the picker re-renders', async () => { + mocks.summary.mockImplementation(() => ({ optionSelection: { model: 'gpt-one' } })) + const write = deferred<{ options: Record }>() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deferred result is the setOption reply the hook reads. + const mutate = vi.fn(() => write.promise) as unknown as StructuredAgentSessionMutate + const view = renderHook(() => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId: 'session-1', + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + optionSelection: mocks.summary()?.optionSelection, + fence: 1, + turnId: null, + unloadedTurnRevisions: undefined, + mutate, + launch: { kind: 'new', heldOptions: {} } + }) + ) + await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) + let first!: Promise + let second!: Promise + act(() => { + first = view.result.current.setStructuredOption('model', 'gpt-two') + second = view.result.current.setStructuredOption('model', 'gpt-two') + }) + await expect(second).resolves.toBe(false) + await act(async () => { + expect(await view.result.current.setStructuredOption('model', 'gpt-two')).toBe(false) + write.resolve({ options: { model: 'gpt-two' } }) + await first + }) + expect(mutate).toHaveBeenCalledOnce() + view.unmount() +}) + +it('drops refused and previous-chat picks without overriding current host selection', async () => { + let selection: Readonly> = { model: 'gpt-one' } + mocks.summary.mockImplementation(() => ({ optionSelection: selection })) + const refused = deferred() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: null is a refused mutation reply. + const mutate = vi.fn(() => refused.promise) as unknown as StructuredAgentSessionMutate + const launch = { kind: 'new' as const, heldOptions: {} } + const view = renderHook( + (sessionId: string) => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId, + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + optionSelection: mocks.summary()?.optionSelection, + fence: 1, + turnId: null, + unloadedTurnRevisions: undefined, + mutate, + launch + }), + { initialProps: 'session-1' } + ) + await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) + let request!: Promise + act(() => { + request = view.result.current.setStructuredOption('model', 'gpt-two') + }) + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') + view.rerender('session-2') + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') + await act(async () => { + refused.resolve(null) + await request + }) + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') + view.rerender('session-1') + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') + selection = { model: 'gpt-two' } + view.rerender('session-2') + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') + view.unmount() +}) + +it('shows the value the host accepted when it normalizes a pick', async () => { + let selection: Readonly> = { model: 'gpt-one' } + mocks.summary.mockImplementation(() => ({ optionSelection: selection })) + const write = deferred<{ options: Record }>() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reply is the setOption result this hook reads. + const mutate = vi.fn(() => write.promise) as unknown as StructuredAgentSessionMutate + const view = renderHook(() => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId: 'session-1', + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + optionSelection: mocks.summary()?.optionSelection, + fence: 1, + turnId: null, + unloadedTurnRevisions: undefined, + mutate, + launch: { kind: 'new', heldOptions: {} } + }) + ) + await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) + let request!: Promise<{ snapshot: readonly SessionOptionDescriptor[] }> + act(() => { + request = view.result.current.optionSurface.setOption('model', 'gpt-two') + }) + selection = { model: 'gpt-one' } + view.rerender() + let returned!: { snapshot: readonly SessionOptionDescriptor[] } + await act(async () => { + write.resolve({ options: { model: 'gpt-one' } }) + returned = await request + }) + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') + expect(pill(returned.snapshot).model).toBe('gpt-one') + expect(mocks.settings).toHaveBeenCalledWith(LOCAL, { + type: 'apply-picks', + agent: 'codex', + picks: [{ modelId: 'gpt-one', optionId: 'model', value: 'gpt-one' }] + }) + view.unmount() +}) + +it('binds an effort-only pick and its returned snapshot to the host-selected model', async () => { + mocks.summary.mockImplementation(() => ({ optionSelection: { model: 'gpt-two', effort: 'low' } })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reply is the setOption result this hook reads. + const mutate = vi.fn(async () => ({ + options: { effort: 'high' } + })) as unknown as StructuredAgentSessionMutate + const launch = { kind: 'new' as const, heldOptions: {} } + const view = renderHook(() => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId: 'session-1', + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + optionSelection: mocks.summary()?.optionSelection, + fence: 1, + turnId: null, + unloadedTurnRevisions: undefined, + mutate, + launch + }) + ) + await waitFor(() => + expect(pill(view.result.current.optionSnapshot)).toEqual({ model: 'gpt-two', effort: 'low' }) + ) + let returned: { snapshot: readonly SessionOptionDescriptor[] } | undefined + await act(async () => { + returned = await view.result.current.optionSurface.setOption('effort', 'high') + }) + expect(pill(returned?.snapshot ?? [])).toEqual({ model: 'gpt-two', effort: 'high' }) + expect(mocks.settings).toHaveBeenCalledWith(LOCAL, { + type: 'apply-picks', + agent: 'codex', + picks: [{ modelId: 'gpt-two', optionId: 'effort', value: 'high' }] + }) + view.unmount() +}) + +it('fences a late pick when the same session moves to another host', async () => { + mocks.summary.mockImplementation(() => ({ optionSelection: { model: 'gpt-one' } })) + const oldWrite = deferred<{ options: Record }>() + const newWrite = deferred() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each promise is a setOption reply. + const mutate = vi + .fn() + .mockReturnValueOnce(oldWrite.promise) + .mockReturnValueOnce(newWrite.promise) as unknown as StructuredAgentSessionMutate + const launch = { kind: 'new' as const, heldOptions: {} } + const remote = { kind: 'environment', environmentId: 'server-1' } as const + const initialTarget: RuntimeClientTarget = LOCAL + const view = renderHook, RuntimeClientTarget>( + (target: RuntimeClientTarget) => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId: 'session-1', + target, + transportEnabled: true, + isVisible: true, + providerVisible: true, + optionSelection: mocks.summary()?.optionSelection, + fence: 1, + turnId: null, + unloadedTurnRevisions: undefined, + mutate, + launch + }), + { initialProps: initialTarget } + ) + await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) + let first!: Promise + act(() => { + first = view.result.current.setStructuredOption('model', 'gpt-two') + }) + view.rerender(remote) + await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) + let second!: Promise + act(() => { + second = view.result.current.setStructuredOption('model', 'gpt-two') + }) + expect(mutate).toHaveBeenCalledTimes(2) + await act(async () => { + oldWrite.resolve({ options: { model: 'gpt-two' } }) + await first + }) + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') + await act(async () => { + newWrite.resolve(null) + await second + }) + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') + view.unmount() +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.no-catalog-effort.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.no-catalog-effort.test.tsx new file mode 100644 index 000000000000..d2cefeab9804 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.no-catalog-effort.test.tsx @@ -0,0 +1,104 @@ +// @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 +})) + +import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' +import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' +import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' +import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' + +const LOCAL = { kind: 'local' } as const +const LAUNCH = { kind: 'new' as const, heldOptions: {}, worktree: 'folder:one' } + +function selection(snapshot: readonly SessionOptionDescriptor[]) { + const value = (id: string) => { + const option = snapshot.find((entry) => entry.id === id) + return option?.kind.type === 'select' ? option.kind.currentValue : undefined + } + return { model: value('model'), effort: value('effort') } +} + +beforeEach(() => { + mocks.call.mockReset() + resetHostModelCatalogSnapshotsForTests() +}) + +it.each([ + ['claude', 'opus'], + ['codex', 'gpt-5.5'] +])('%s keeps a saved effort through start before its catalog loads', async (agent, model) => { + for (const selectionFromStatus of [false, true]) { + resetHostModelCatalogSnapshotsForTests() + const saved = { model, effort: 'high' } + const listing = Promise.withResolvers() + mocks.call.mockImplementation(async (_target: unknown, method: string) => + method === 'agentSession.modelCatalog' + ? listing.promise + : { models: [], modelsKnown: false, current: saved, conversationCommands: ['clear'] } + ) + const frames: ReturnType[] = [] + const view = renderHook( + ({ fence, starting }: { fence: number; starting: boolean }) => { + const options = useStructuredAgentSessionOptions({ + agent, + sessionId: 'saved-chat', + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + providerStarting: starting, + ...(selectionFromStatus ? { optionSelection: saved } : {}), + fence, + turnId: null, + unloadedTurnRevisions: undefined, + mutate: async () => null, + launch: LAUNCH + }) + frames.push(selection(options.optionSnapshot)) + return options + }, + { initialProps: { fence: 1, starting: false } } + ) + await waitFor(() => expect(view.result.current.conversationCommands).toEqual(['clear'])) + expect(selection(view.result.current.optionSnapshot)).toEqual(saved) + const beforeStart = frames.length + view.rerender({ fence: 2, starting: true }) + expect(selection(view.result.current.optionSnapshot)).toEqual(saved) + view.rerender({ fence: 2, starting: false }) + await waitFor(() => expect(view.result.current.rewind).toBeDefined()) + expect( + frames.slice(beforeStart).every((frame) => frame.model === model && frame.effort === 'high') + ).toBe(true) + await act(async () => + listing.resolve({ + origin: 'probe', + models: [ + { + id: model, + label: 'Host model', + isDefault: false, + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + } + ], + fetchedAt: 1 + }) + ) + await waitFor(() => + expect( + view.result.current.optionSnapshot.find((option) => option.id === 'model')?.kind + ).toMatchObject({ choices: [{ value: model, label: 'Host model' }] }) + ) + expect(selection(view.result.current.optionSnapshot)).toEqual(saved) + view.unmount() + } +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.pending-restart.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.pending-restart.test.tsx new file mode 100644 index 000000000000..31f5c7cc90e9 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.pending-restart.test.tsx @@ -0,0 +1,98 @@ +// @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(), settings: 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: mocks.settings +})) + +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' + +const LOCAL = { kind: 'local' } as const +const LAUNCH = { kind: 'new' as const, heldOptions: {} } +const SELECTION = { model: 'gpt-one' } +const MODELS = ['gpt-one', 'gpt-two'].map((id) => ({ id, label: id, efforts: [] })) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => (resolve = settle)) + return { promise, resolve } +} + +beforeEach(() => { + resetHostModelCatalogSnapshotsForTests() + mocks.settings.mockReset() + mocks.call.mockImplementation(async (_target: unknown, method: string) => + method === 'agentSession.modelCatalog' + ? { origin: 'live-session', models: MODELS, fetchedAt: 1 } + : { models: MODELS, current: {} } + ) +}) + +it.each([false, true])( + 'admits a later pick after restart when the earlier reply %s arrives', + async (settlesLate) => { + const abandoned = deferred<{ options: Record }>() + const latest = deferred() + const send = vi.fn().mockReturnValueOnce(abandoned.promise).mockReturnValueOnce(latest.promise) + const mutate: StructuredAgentSessionMutate = send + const initialFence: number | null = 1 + const view = renderHook, number | null>( + (fence: number | null) => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId: 'session-1', + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + optionSelection: SELECTION, + fence, + turnId: null, + unloadedTurnRevisions: undefined, + mutate, + launch: LAUNCH + }), + { initialProps: initialFence } + ) + await waitFor(() => expect(view.result.current.rewind).toBeDefined()) + let first!: Promise + act(() => { + first = view.result.current.setStructuredOption('model', 'gpt-two') + }) + expect(send).toHaveBeenCalledOnce() + view.rerender(null) + view.rerender(2) + let second!: Promise + act(() => { + second = view.result.current.setStructuredOption('model', 'gpt-two') + }) + expect(send).toHaveBeenCalledTimes(2) + if (settlesLate) { + await act(async () => { + abandoned.resolve({ options: { model: 'gpt-two' } }) + expect(await first).toBe(false) + }) + expect(mocks.settings).not.toHaveBeenCalled() + await act(async () => { + expect(await view.result.current.setStructuredOption('model', 'gpt-one')).toBe(false) + }) + expect(send).toHaveBeenCalledTimes(2) + } + await act(async () => { + latest.resolve(null) + expect(await second).toBe(false) + }) + const model = view.result.current.optionSnapshot.find((option) => option.id === 'model') + expect(model?.kind.type === 'select' ? model.kind.currentValue : null).toBe('gpt-one') + view.unmount() + } +) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.scoped-default.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.scoped-default.test.tsx new file mode 100644 index 000000000000..bf6aa9e11597 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.scoped-default.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 +})) + +import type { + AgentSessionModelCatalogResult, + AgentSessionOptionsResult +} from '../../../../shared/agent-session-wire' +import { + forgetHostModelCatalogSnapshots, + recordHostModelCatalogSnapshot, + resetHostModelCatalogSnapshotsForTests +} from '@/runtime/host-model-catalog-snapshots' +import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' + +const LOCAL = { kind: 'local' } as const +const MODELS = ['gpt-one', 'gpt-two'].map((id) => ({ + id, + label: id, + isDefault: false, + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] +})) +const LAUNCH = { kind: 'new' as const, heldOptions: {}, worktree: 'folder:one' } +const EMPTY_SELECTION = {} +const NO_MUTATE: StructuredAgentSessionMutate = async () => null + +function catalog(defaultModel = 'gpt-one'): AgentSessionModelCatalogResult { + return { + origin: 'live-session', + models: MODELS.map((model) => ({ + ...model, + isDefault: model.id === defaultModel, + ...(model.id === defaultModel ? { defaultEffort: 'medium' } : {}) + })), + fetchedAt: 1, + listingNamesConfiguredModel: true + } +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((settle) => (resolve = settle)) + return { promise, resolve } +} + +function renderChat() { + return renderHook( + (fence: number) => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId: 'session-1', + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + optionSelection: EMPTY_SELECTION, + fence, + turnId: null, + unloadedTurnRevisions: undefined, + mutate: NO_MUTATE, + launch: LAUNCH + }), + { initialProps: 1 } + ) +} + +function selected(view: ReturnType) { + return Object.fromEntries( + view.result.current.optionSnapshot.map((option) => [ + option.id, + option.kind.type === 'select' ? (option.kind.currentValue ?? null) : null + ]) + ) +} + +beforeEach(() => { + mocks.call.mockReset() + resetHostModelCatalogSnapshotsForTests() +}) + +it.each(['catalog-first', 'options-first'] as const)( + 'uses the scoped default with an empty host selection after %s replies', + async (order) => { + const listing = deferred() + const options = deferred() + const metadata: AgentSessionOptionsResult = { + models: MODELS, + current: { model: 'gpt-two', effort: 'high' }, + conversationCommands: ['clear'] + } + mocks.call.mockImplementation((_target: unknown, method: string) => + method === 'agentSession.modelCatalog' ? listing.promise : options.promise + ) + const view = renderChat() + await act(async () => { + if (order === 'catalog-first') { + listing.resolve(catalog()) + } else { + options.resolve(metadata) + } + }) + await act(async () => { + listing.resolve(catalog()) + options.resolve(metadata) + }) + await waitFor(() => expect(view.result.current.conversationCommands).toHaveLength(1)) + expect(selected(view)).toEqual({ model: 'gpt-one', effort: 'medium' }) + view.unmount() + } +) + +it.each(['folder config', 'account'] as const)( + 'refreshes an empty selection default after the %s changes', + async (change) => { + recordHostModelCatalogSnapshot(LOCAL, 'codex', LAUNCH.worktree, catalog()) + let defaultModel = 'gpt-one' + mocks.call.mockImplementation(async (_target: unknown, method: string) => + method === 'agentSession.modelCatalog' + ? catalog(defaultModel) + : { models: MODELS, current: {} } + ) + const view = renderChat() + await waitFor(() => expect(view.result.current.rewind).toBeDefined()) + expect(selected(view)).toEqual({ model: 'gpt-one', effort: 'medium' }) + defaultModel = 'gpt-two' + if (change === 'account') { + forgetHostModelCatalogSnapshots(LOCAL, ['codex']) + } + recordHostModelCatalogSnapshot(LOCAL, 'codex', LAUNCH.worktree, catalog(defaultModel)) + view.rerender(1) + expect(selected(view)).toEqual({ model: 'gpt-two', effort: 'medium' }) + view.rerender(2) + await waitFor(() => expect(view.result.current.rewind).toBeDefined()) + expect(selected(view)).toEqual({ model: 'gpt-two', effort: 'medium' }) + view.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..1f089317b151 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 @@ -6,7 +6,6 @@ import type { } from '../../../../shared/agent-session-wire' import type { AgentType } from '../../../../shared/agent-status-types' import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' -import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode' import { applyStructuredAgentSessionOptions, @@ -17,18 +16,18 @@ import { structuredAgentSessionOptionSnapshot, type StructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' -import { structuredAgentSessionOptionView } from '../../../../shared/structured-agent-session-option-view' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-settings-write' import { encodeStructuredAgentSessionOptionValue } from '../../../../shared/structured-agent-session-option-codec' import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' import { useHostModelCatalogUpgrade } from './use-host-model-catalog-upgrade' import { useStructuredAgentSessionOptionState } from './use-structured-agent-session-option-state' +import { useStructuredAgentSessionOptionSurface } from './use-structured-agent-session-option-surface' import { agentSessionWriteFailureText } from './agent-session-write-notice-text' import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch' import { - getStructuredAgentSessionLaunchSelection, holdStructuredAgentSessionLaunchOption, type StructuredLaunchOptionOutcome } from '@/lib/structured-agent-session-launch-options' @@ -45,6 +44,7 @@ export function useStructuredAgentSessionOptions(args: { providerStarting?: boolean providerRunning?: boolean hostOptionStamp?: string + optionSelection?: Readonly> fence: number | null turnId: string | null unloadedTurnRevisions: number | undefined @@ -67,12 +67,13 @@ export function useStructuredAgentSessionOptions(args: { // Published but not attached: the launch no longer holds picks and there is no fence to send one. const acceptsPicks = !transportEnabled || fence !== null const optionCatalog = useMemo(() => structuredAgentSessionSeedCatalog(agent), [agent]) - const identity = `${agent}:${sessionId}` + const identity = `${agent}:${sessionId}:${structuredAgentSessionHostKey(target)}` + const selectionFromStatus = args.optionSelection !== undefined const { optionState, + optionView, optionStateRef, activeOptionRecordRef, - pendingOptionRef, optionMutationGeneration, updateOptionState, rereadOptions, @@ -93,6 +94,9 @@ export function useStructuredAgentSessionOptions(args: { // 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. readsBeforeStart: launch?.kind !== 'new', + hostSelection: args.optionSelection, + launchSeedOptions, + held, turnId, unloadedTurnRevisions: args.unloadedTurnRevisions }) @@ -129,26 +133,23 @@ export function useStructuredAgentSessionOptions(args: { [agent, target] ) const sendStructuredOption = useCallback( - async (id: string, encoded: string): Promise => { - const currentState = optionStateRef.current - const targetRecord = currentState.record + async (id: string, encoded: string): Promise> | null> => { + const targetRecord = optionStateRef.current.record const mutationGeneration = ++optionMutationGeneration.current const isCurrent = (): boolean => 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 - if (shownAtOnce) { + if (shownAtOnce && !selectionFromStatus) { updateOptionState((current) => current.record === targetRecord ? commitStructuredAgentSessionOptionValues(current, { [id]: encoded }) : current ) - } else { - pendingOptionRef.current = id - updateOptionState((current) => ({ ...current, pendingId: id })) } + updateOptionState((current) => ({ ...current, pendingPick: { id, value: encoded } })) let result: AgentSessionOptionResult | null = null + let committed: Readonly> | null = null try { result = await mutate( 'agentSession.setOption', @@ -156,43 +157,53 @@ export function useStructuredAgentSessionOptions(args: { { key: id, value: encoded } ) if (result && isCurrent()) { - const committed = result.options ?? { [id]: encoded } + const acceptedValues = result.options ?? { [id]: encoded } + committed = acceptedValues updateOptionState((current) => - current.record === targetRecord - ? commitStructuredAgentSessionOptionValues(current, committed) + current.record === targetRecord && !selectionFromStatus + ? commitStructuredAgentSessionOptionValues(current, acceptedValues) : current ) - // The launch seed names the model an effort-only pick was made under. - rememberOptionPicks( - structuredAgentSessionOptionView(currentState, launchSeedOptions, NO_HELD_OPTIONS), - committed - ) + rememberOptionPicks(optionView, acceptedValues) + if (!selectionFromStatus) { + void callStructuredAgentSession( + target, + 'agentSession.options', + { sessionId } + ) + .then((refreshed) => { + if (isCurrent()) { + updateOptionState((latest) => + latest.record === targetRecord && optionCatalog + ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) + : latest + ) + } + }) + .catch(() => {}) + } } - return Boolean(result) + return committed } finally { - // A settled write, or a shown one that failed, reads back what the host now holds. - if (isCurrent() && (result || shownAtOnce)) { + if (shownAtOnce && !result && !selectionFromStatus && isCurrent()) { void callStructuredAgentSession( target, 'agentSession.options', { sessionId } ) .then((refreshed) => { - if (isCurrent()) { + if (isCurrent() && optionCatalog) { updateOptionState((latest) => - latest.record === targetRecord && optionCatalog - ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) - : latest + applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) ) } }) .catch(() => {}) } if (isCurrent()) { - pendingOptionRef.current = null updateOptionState((current) => - current.record === targetRecord && current.pendingId === id - ? { ...current, pendingId: null } + current.record === targetRecord && current.pendingPick?.id === id + ? { ...current, pendingPick: null } : current ) } @@ -200,13 +211,13 @@ export function useStructuredAgentSessionOptions(args: { }, [ activeOptionRecordRef, - launchSeedOptions, mutate, optionCatalog, optionMutationGeneration, optionStateRef, - pendingOptionRef, rememberOptionPicks, + optionView, + selectionFromStatus, sessionId, target, updateOptionState @@ -217,85 +228,61 @@ export function useStructuredAgentSessionOptions(args: { if (outcome.kind === 'refused') { toast.error(agentSessionWriteFailureText(outcome.failure, 'option')) } else if (outcome.kind === 'accepted') { - rememberOptionPicks( - structuredAgentSessionOptionView( - optionStateRef.current, - launchSeedOptions, - NO_HELD_OPTIONS - ), - outcome.options - ) + rememberOptionPicks(optionView, outcome.options) } }, - [launchSeedOptions, optionStateRef, rememberOptionPicks] + [optionView, rememberOptionPicks] ) const optionSnapshot = useMemo(() => { - const snapshot = structuredAgentSessionOptionSnapshot( - structuredAgentSessionOptionView(optionState, launchSeedOptions, held) - ) + const snapshot = structuredAgentSessionOptionSnapshot(optionView) return acceptsPicks ? snapshot : lockedStructuredAgentSessionOptionSnapshot(snapshot) - }, [acceptsPicks, held, launchSeedOptions, optionState]) - const setStructuredOption = useCallback( - async (id: string, value: string | boolean): Promise => { - const view = structuredAgentSessionOptionView(optionStateRef.current, launchSeedOptions, held) + }, [acceptsPicks, optionView]) + const changeStructuredOption = useCallback( + async ( + id: string, + value: string | boolean + ): Promise> | null> => { const encoded = encodeStructuredAgentSessionOptionValue(id, value) if ( !optionCatalog || encoded === null || - !canSetStructuredAgentSessionOption(view, id, value) + !canSetStructuredAgentSessionOption(optionView, id, value) ) { - return false + return null } if (!transportEnabled || fence === null) { // No fence yet: the launch holds it and applies it before anything else is sent. const applied = holdStructuredAgentSessionLaunchOption(sessionId, id, encoded) void applied?.then(settleLaunchOptionPick) - return applied !== null + return applied === null ? null : { [id]: encoded } } - if (pendingOptionRef.current !== null) { - return false + if (optionStateRef.current.pendingPick !== null) { + return null } return sendStructuredOption(id, encoded) }, [ fence, - held, - launchSeedOptions, optionCatalog, + optionView, optionStateRef, - pendingOptionRef, sendStructuredOption, sessionId, settleLaunchOptionPick, transportEnabled ] ) - const setOption = useCallback( - async (id: string, value: string | boolean) => { - await setStructuredOption(id, value) - // Read, not rendered: a pick the launch just took is not in this render's props yet. - const currentHeld = getStructuredAgentSessionLaunchSelection(sessionId)?.held - return { - snapshot: structuredAgentSessionOptionSnapshot( - structuredAgentSessionOptionView( - optionStateRef.current, - launchSeedOptions, - currentHeld ?? NO_HELD_OPTIONS - ) - ) - } - }, - [launchSeedOptions, optionStateRef, sessionId, setStructuredOption] - ) - const optionSurface = useMemo( - () => ({ - getSnapshot: () => optionSnapshot, - setOption, - invokeAction: async () => ({ snapshot: optionSnapshot }), - subscribe: () => () => {} - }), - [setOption, optionSnapshot] + const setStructuredOption = useCallback( + async (id: string, value: string | boolean): Promise => + (await changeStructuredOption(id, value)) !== null, + [changeStructuredOption] ) + const optionSurface = useStructuredAgentSessionOptionSurface({ + sessionId, + optionView, + optionSnapshot, + changeOption: changeStructuredOption + }) const support = transportEnabled && conversationSupport?.sessionId === sessionId ? conversationSupport : null diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts index 911b4bff4083..04cc1eba9490 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts @@ -1,6 +1,8 @@ import { useCallback, useEffect, useSyncExternalStore } from 'react' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { StructuredAgentSessionAttachment } from '../../../../shared/structured-agent-session-send-mutation' +import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' +import { agentSessionExecutionOptionsFromSnapshot } from '../../../../shared/agent-session-execution-option-snapshot' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { structuredAgentSessionQueueRequest, @@ -35,6 +37,7 @@ export function useStructuredAgentSessionSends(args: { isWorking?: boolean /** The chat reads Stopping: a send made now is drawn after the turn being stopped. */ stopping?: boolean + executionOptionSnapshot?: readonly SessionOptionDescriptor[] }) { const { fence, @@ -45,7 +48,8 @@ export function useStructuredAgentSessionSends(args: { sessionId, stopping, submissions, - target + target, + executionOptionSnapshot } = args const subscribe = useCallback( (listener: () => void) => subscribeToStructuredAgentSessionPendingSends(sessionId, listener), @@ -97,6 +101,9 @@ export function useStructuredAgentSessionSends(args: { text, attachments, ...(delivery ? { delivery } : {}), + ...(executionOptionSnapshot + ? { executionOptions: agentSessionExecutionOptionsFromSnapshot(executionOptionSnapshot) } + : {}), ...(stopping ? { sentWhileStopping: true as const } : {}) }) // Refused while the chat's send is out: the text stays in the box. @@ -112,7 +119,7 @@ export function useStructuredAgentSessionSends(args: { ? 'queued' : true }, - [isWorking, queue, queuedMessageIds, sessionId, stopping, target] + [executionOptionSnapshot, isWorking, queue, queuedMessageIds, sessionId, stopping, target] ) return { 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 d5cf7ff42200..f3812af5e8ba 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 @@ -11,6 +11,7 @@ import { supportsStructuredAgentSessionPromptCancel } from '@/runtime/structured import { useStructuredAgentSessionHostQueuesCommands, useStructuredAgentSessionHostEditsQueuedMessages, + useStructuredAgentSessionHostMessageChoices, useStructuredAgentSessionHostQueuesMessagesState } from '@/runtime/structured-agent-session-host-capability' import { structuredAgentSessionStopControl } from './structured-agent-session-stop-control' @@ -55,6 +56,8 @@ export function useStructuredAgentSession(args: { providerStarting?: boolean /** The host runs the session's provider and its start is proven. */ providerRunning?: boolean + /** The current selection in a capable host's status, including an empty selection. */ + optionSelection?: Readonly> /** This view started the session; only then does the stored selection name what it runs. */ launch?: StructuredAgentSessionLaunchView /** The composer Edit copies a card's text into. */ @@ -116,6 +119,7 @@ export function useStructuredAgentSession(args: { isVisible, providerVisible, providerStarting, + optionSelection: args.optionSelection, ...(args.providerRunning ? { providerRunning: true } : {}), ...(args.hostOptionStamp ? { hostOptionStamp: args.hostOptionStamp } : {}), fence: state.fence, @@ -126,6 +130,7 @@ export function useStructuredAgentSession(args: { }) // Only a capable host may see `delivery`; a card any host publishes shows, with its actions. const queueCapability = useStructuredAgentSessionHostQueuesMessagesState(target) + const messageChoices = useStructuredAgentSessionHostMessageChoices(target) const queuedMessageIds = useMemo( () => (transportState.queuedMessages ?? []).map((message) => message.messageId), [transportState.queuedMessages] @@ -158,7 +163,8 @@ export function useStructuredAgentSession(args: { queue, historyLoaded: transportEnabled && state.status === 'ready', isWorking: transportState.isWorking, - stopping: stopControl.stopping + stopping: stopControl.stopping, + executionOptionSnapshot: messageChoices ? optionSnapshot : undefined }) const threadGoal = useStructuredAgentSessionThreadGoal({ diff --git a/src/renderer/src/runtime/structured-agent-session-host-capability.ts b/src/renderer/src/runtime/structured-agent-session-host-capability.ts index e10d12804cfd..95dc0ff73c25 100644 --- a/src/renderer/src/runtime/structured-agent-session-host-capability.ts +++ b/src/renderer/src/runtime/structured-agent-session-host-capability.ts @@ -4,6 +4,7 @@ import { AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, + AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RECOVERY_CAPABILITY, type RuntimeCapability } from '../../../shared/protocol-version' @@ -110,6 +111,13 @@ export function useStructuredAgentSessionHostQueuesMessages(target: RuntimeClien return useStructuredAgentSessionHostQueuesMessagesState(target) === 'supported' } +export function useStructuredAgentSessionHostMessageChoices(target: RuntimeClientTarget): boolean { + return useStructuredAgentSessionHostCapability( + target, + AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY + ) +} + /** Three-state: only `supported` lets a send ask to be queued; `unknown` holds nothing back. */ export function useStructuredAgentSessionHostQueuesMessagesState( target: RuntimeClientTarget diff --git a/src/shared/agent-session-execution-option-snapshot.ts b/src/shared/agent-session-execution-option-snapshot.ts new file mode 100644 index 000000000000..1e80ee67151e --- /dev/null +++ b/src/shared/agent-session-execution-option-snapshot.ts @@ -0,0 +1,23 @@ +import type { SessionOptionDescriptor } from './native-chat-session-options' +import type { AgentSessionExecutionOptions } from './agent-session-execution-options' + +/** What the picker displayed at Send, excluding launch-only permission. */ +export function agentSessionExecutionOptionsFromSnapshot( + snapshot: readonly SessionOptionDescriptor[] +): AgentSessionExecutionOptions { + const selected: Record = {} + for (const descriptor of snapshot) { + if ( + descriptor.transport !== 'agent-session' || + descriptor.valueSource === 'unknown' || + !['model', 'effort', 'personality', 'fastMode', 'serviceTier'].includes(descriptor.id) + ) { + continue + } + const value = descriptor.kind.currentValue + if (value !== undefined) { + selected[descriptor.id] = String(value) + } + } + return selected +} diff --git a/src/shared/agent-session-execution-options.ts b/src/shared/agent-session-execution-options.ts new file mode 100644 index 000000000000..38f27b02a058 --- /dev/null +++ b/src/shared/agent-session-execution-options.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' + +/** Choices fixed when a message is accepted. Permission remains a launch setting. */ +export const AgentSessionExecutionOptionsSchema = z + .object({ + model: z.string().min(1).max(256).optional(), + effort: z.string().min(1).max(64).optional(), + personality: z.string().min(1).max(64).optional(), + fastMode: z.enum(['true', 'false']).optional(), + serviceTier: z.string().min(1).max(64).optional() + }) + .strict() + +export type AgentSessionExecutionOptions = z.infer + +export function readAgentSessionExecutionOptions( + value: unknown +): AgentSessionExecutionOptions | null { + const parsed = AgentSessionExecutionOptionsSchema.safeParse(value) + return parsed.success ? parsed.data : null +} diff --git a/src/shared/agent-session-journal-submission-schema.ts b/src/shared/agent-session-journal-submission-schema.ts index ef65649badb7..c65ad4d6a1ac 100644 --- a/src/shared/agent-session-journal-submission-schema.ts +++ b/src/shared/agent-session-journal-submission-schema.ts @@ -6,10 +6,12 @@ import { z } from 'zod' import { AgentJournalAnsweredTurnSchema } from './agent-session-answered-turn-schema' import { AgentSessionFailureFactSchema } from './agent-session-failure-fact-schema' import type { AgentJournalSubmission } from './agent-session-journal-types' +import { AgentSessionExecutionOptionsSchema } from './agent-session-execution-options' // Every persisted field is listed, or a parse strips it: this schema drops unknown keys. export const AgentJournalSubmissionSchema = z.object({ clientMessageId: z.string().min(1), + executionOptions: AgentSessionExecutionOptionsSchema.optional(), fence: z.number().int(), payloadFingerprint: z.string(), dispatchState: z.string().min(1), diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 2ff04f88ad4f..c4d021879970 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -19,6 +19,7 @@ import type { AgentSessionContextUsage } from './agent-session-context-usage' import type { AgentSessionProviderHandle } from './agent-session-provider-handle' import type { NativeChatBlock, NativeChatRole } from './native-chat-types' import type { AgentMessageSource } from './agent-session-message-source' +import type { AgentSessionExecutionOptions } from './agent-session-execution-options' export { type AgentType } @@ -467,6 +468,8 @@ export type AgentJournalDispatchState = (typeof AGENT_JOURNAL_DISPATCH_STATES)[n * the turn reads as delivery unconfirmed, never as sent and never as failed. */ export type AgentJournalSubmission = { clientMessageId: string + /** Immutable choices captured on acceptance; absent on older submissions. */ + executionOptions?: AgentSessionExecutionOptions /** Execution fence of the latest dispatch attempt or recovery. */ fence: number payloadFingerprint: string diff --git a/src/shared/agent-session-options-result.ts b/src/shared/agent-session-options-result.ts new file mode 100644 index 000000000000..30ab778a4c59 --- /dev/null +++ b/src/shared/agent-session-options-result.ts @@ -0,0 +1,50 @@ +import type { AgentJournalCursor, AgentJournalThreadGoal } from './agent-session-journal-types' +import type { AgentSessionConversationCommand } from './agent-session-conversation-command' +import type { AgentSessionContextUsage } from './agent-session-context-usage' +import type { AgentSessionRewindSupport } from './agent-session-rewind' +import type { + AgentSessionFastModeState, + AgentSessionFastModeSupport, + AgentSessionModelOption +} from './agent-session-wire' + +/** Provider-reported choices and effective next-turn values. Additive read-only + * surface so older hosts can reject it without changing structured v1 writes. */ +export type AgentSessionOptionsResult = { + rewind?: AgentSessionRewindSupport + conversationCommands?: readonly AgentSessionConversationCommand[] + /** Present only where this session can change its goal, so a host without + * `agentSession.threadGoal` never offers the controls. `current` is the + * latest goal the whole journal records, for a client whose loaded page + * starts after it. */ + threadGoal?: { current: AgentJournalThreadGoal | null; contextFloor?: AgentJournalCursor } + /** Present only where this session writes context facts to its turn rows. + * `current` is the newest of each part the whole journal records, for a + * client whose loaded page starts after the row that carries it. */ + contextUsage?: { current: AgentSessionContextUsage; contextFloor?: AgentJournalCursor } + /** This session takes image attachments. Absent = unknown (an older host). */ + acceptsImages?: boolean + models: AgentSessionModelOption[] + /** False when no host catalog has loaded; models may contain built-in fallbacks. + * Absent keeps older hosts' authoritative options-list behavior. */ + modelsKnown?: boolean + /** Session/account/transport support. Absent means unknown, never unsupported. */ + fastModeSupport?: AgentSessionFastModeSupport + current: { + model?: string + effort?: string + /** Canonical preference for the next turn. Explicit false is meaningful. */ + fastMode?: boolean + /** Next-turn service tier id where the model lists `serviceTiers`; `default` is standard. */ + serviceTier?: string + permissionMode?: string + /** Provider-reported effective routing, distinct from the next-turn preference. */ + fastModeState?: AgentSessionFastModeState + /** + * Option ids whose value the provider reported back, not merely accepted. + * Optional: a host that predates it sends nothing and the client keeps + * treating the value as unconfirmed, which is what it was before. + */ + confirmed?: readonly string[] + } +} diff --git a/src/shared/agent-session-queue-capabilities.ts b/src/shared/agent-session-queue-capabilities.ts new file mode 100644 index 000000000000..d521a43a2ea5 --- /dev/null +++ b/src/shared/agent-session-queue-capabilities.ts @@ -0,0 +1,17 @@ +// Strict Send params and result arms make the queue opt-in on paired clients. The host can still +// publish cards kept across restart or close without this gate. Mid-turn queueing stays dark until +// its steer rules ship; cards expose their draft-to-submission link and pause in the publication. +export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = + 'agent-session.queued-messages.v1' as const + +/** Strict Send params require a capability before clients include message choices. */ +export const AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY = + 'agent-session.message-choices.v1' as const + +// Kept cards may be edited without mid-turn queueing, so editing has its own gate. +export const AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY = + 'agent-session.queued-message-edit.v1' as const + +// A waiting command needs the queue's card surface to show where it went. +export const AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY = + 'agent-session.queued-commands.v1' as const diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index c2e9ddbbf633..48c9cd775016 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -3,6 +3,7 @@ import type { UnreadAgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalMessageItem } from './agent-session-journal-types' +import type { AgentSessionExecutionOptions } from './agent-session-execution-options' /** The draft could not be converted into a send; an explicit Send retries it. */ export const QUEUED_MESSAGE_PAUSED_SEND_FAILED = 'send_failed' as const @@ -35,6 +36,7 @@ export type AgentSessionQueuedMessagesResumeResult = { * subscribe stream and on history pages. Text-only v1. */ export type AgentSessionQueuedMessage = { messageId: string + executionOptions?: AgentSessionExecutionOptions position: number body: AgentJournalMessageItem state: 'waiting' | 'returned' diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 9a8fed9c76c2..e4eda0a4a0fa 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -3,7 +3,7 @@ import type { AgentSessionBackgroundTask, AgentSessionBackgroundTaskState } from './agent-session-background-task-wire' -import type { AgentSessionRewindReason, AgentSessionRewindSupport } from './agent-session-rewind' +import type { AgentSessionRewindReason } from './agent-session-rewind' import type { AgentSessionWireRefusal } from './agent-session-wire-refusals' import type { AgentChildWorkView } from './agent-status-child-work-view' import type { @@ -12,11 +12,10 @@ import type { AgentSessionQueuePublicationFields } from './agent-session-queued-message-wire' +export type { AgentSessionOptionsResult } from './agent-session-options-result' export * from './agent-session-wire-refusals' export * from './agent-session-queued-message-wire' export * from './agent-session-turn-completion-wire' -import type { AgentSessionConversationCommand } from './agent-session-conversation-command' -import type { AgentSessionContextUsage } from './agent-session-context-usage' // ─── Structured agent-session wire contract ───────────────────────────────── // The shapes `agentSession.*` accepts and publishes. Phase 2 builds provider // adapters and clients against exactly these types, so everything here must be @@ -30,7 +29,6 @@ import type { AgentJournalResetReason, AgentJournalResolution, AgentJournalSubmission, - AgentJournalThreadGoal, AgentJournalTurnLifecycle } from './agent-session-journal-types' import type { AgentTurnOutcome } from './agent-turn-outcome' @@ -250,6 +248,8 @@ export type AgentSessionStatusSummary = { model?: string /** The chat's saved permission mode; a client re-reads options when it or `model` moves. */ permissionMode?: string + /** The host's saved option selection, including an empty selection. Absent on older hosts. */ + optionSelection?: Readonly> /** The tool the running turn is inside, else the last one it used. Absent unless `status` * is 'working'. */ toolName?: string @@ -478,42 +478,3 @@ export type AgentSessionThreadGoalChange = | { kind: 'clear' } export type AgentSessionThreadGoalResult = { change: AgentSessionThreadGoalChange['kind'] } - -/** Provider-reported choices and effective next-turn values. Additive read-only - * surface so older hosts can reject it without changing structured v1 writes. */ -export type AgentSessionOptionsResult = { - rewind?: AgentSessionRewindSupport - conversationCommands?: readonly AgentSessionConversationCommand[] - /** Present only where this session can change its goal, so a host without - * `agentSession.threadGoal` never offers the controls. `current` is the - * latest goal the whole journal records, for a client whose loaded page - * starts after it. */ - threadGoal?: { current: AgentJournalThreadGoal | null; contextFloor?: AgentJournalCursor } - /** Present only where this session writes context facts to its turn rows. - * `current` is the newest of each part the whole journal records, for a - * client whose loaded page starts after the row that carries it. */ - contextUsage?: { current: AgentSessionContextUsage; contextFloor?: AgentJournalCursor } - /** This session takes image attachments. Absent = unknown (an older host). */ - acceptsImages?: boolean - models: AgentSessionModelOption[] - /** Session/account/transport support. Absent means unknown, never unsupported. */ - fastModeSupport?: AgentSessionFastModeSupport - current: { - model?: string - effort?: string - /** Canonical preference for the next turn. Explicit false is meaningful. */ - fastMode?: boolean - /** Next-turn service tier id where the model lists `serviceTiers`; `default` is standard. */ - serviceTier?: string - /** The permission mode a start runs now; absent from a host or agent with no picker. */ - permissionMode?: string - /** Provider-reported effective routing, distinct from the next-turn preference. */ - fastModeState?: AgentSessionFastModeState - /** - * Option ids whose value the provider reported back, not merely accepted. - * Optional: a host that predates it sends nothing and the client keeps - * treating the value as unconfirmed, which is what it was before. - */ - confirmed?: readonly string[] - } -} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index b71bdce37268..3d73559cd4c9 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -50,6 +50,8 @@ import { DEVICE_KEY_RUNTIME_CAPABILITIES } from './device-key-capability' export * from './agent-session-resume-runtime-capabilities' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability' import { AGENT_SESSION_STOP_RUNTIME_CAPABILITIES } from './agent-session-stop-capabilities' +import * as AGENT_SESSION_QUEUE from './agent-session-queue-capabilities' +export * from './agent-session-queue-capabilities' import { AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES } from './agent-session-create-capabilities' import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' import { SKILL_RUNTIME_CAPABILITIES } from './skill-install-capability' @@ -235,34 +237,6 @@ export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = // holds there. export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = 'agent-session.send-answers-proof.v1' as const -// Why: `agentSession.send`'s params are strict, so an older host rejects `delivery`; and only a -// capable client can render the `queued` result arm. It gates `delivery: 'queue-if-active'` and -// the client's queueing setting and chord, never the published draft list or its card actions: a -// host without it still publishes a message it kept unsent across a restart or a close, and both -// clients show that card and call its actions. DARK ON PURPOSE — not in RUNTIME_CAPABILITIES: -// advertising still requires the integrated Codex steer matrix (#21062) in the shipped host. -// v1 includes `submission.queuedMessageId` on every draft hand-off: a client reads that link and -// never compares a draft id with a submission id. It also publishes the queue's pause once, as -// `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or any turn sent -// after it starting; while it is set every waiting card waits. `nextQueuedMessageId` beside the list -// names the card the queue sends next once nothing runs, null while anything holds it; absent (an -// older host) reads as null. A card carries a hold of its own when its conversion failed -// (`send_failed`) or the host kept it unsent (`kept`). The host mechanism lands first; the constant -// gates the rollout. -export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = - 'agent-session.queued-messages.v1' as const -// Why: a host advertising this edits a queued card in place (`agentSession.queuedMessageUpdate`) -// and holds it from automatic delivery while edited (`agentSession.queuedMessageEditHold`). Apart -// from queued-messages.v1: kept and agent-mail cards are editable with mid-turn queueing still off. -// A client offers Edit only when the host advertises it; there is no copy-and-delete fallback. -export const AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY = - 'agent-session.queued-message-edit.v1' as const -// Why: `agentSession.conversationCommand`'s params are strict, so an older host rejects -// `delivery`. A host advertising this holds a /compact sent while the agent works as a queued -// card instead of refusing it. Clients ask only when queued-messages.v1 is advertised too: -// the card is the only place the waiting command shows. -export const AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY = - 'agent-session.queued-commands.v1' as const // Why: paired clients advertise Claude-structured support so the host can gate its agent-specific // journal and lifecycle surfaces independently from Codex support. export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = @@ -417,8 +391,9 @@ export const RUNTIME_CAPABILITIES = [ // turn starts, so a client may gate on either. AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY, - AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, - AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUE.AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUE.AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUE.AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY, AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY, ...STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES, ...AGENT_SESSION_ATTENTION_RUNTIME_CAPABILITIES, diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index d9dd55c7b441..020bcfab85ee 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -22,6 +22,7 @@ export { SessionId } from './structured-agent-session-identifiers' import { z } from 'zod' +import { AgentSessionExecutionOptionsSchema } from '../agent-session-execution-options' import { isAgentSessionSurfaceTabId } from '../agent-session-surface-tab-id' import { isAgentSessionOptions } from '../agent-session-record' import { isStructuredAgentId } from '../agent-session-provider-handle-encoding' @@ -135,6 +136,7 @@ export const SendParams = z * older host refuses it: clients send it only when `agent-session.queued-messages.v1` is * advertised. Participates in the operation fingerprint, never the body fingerprint. */ delivery: z.literal('queue-if-active').optional(), + executionOptions: AgentSessionExecutionOptionsSchema.optional(), body: SendBody }) .strict() diff --git a/src/shared/structured-agent-session-model-catalog-application.test.ts b/src/shared/structured-agent-session-model-catalog-application.test.ts index 7586fb22bb99..ac395c67752b 100644 --- a/src/shared/structured-agent-session-model-catalog-application.test.ts +++ b/src/shared/structured-agent-session-model-catalog-application.test.ts @@ -152,4 +152,97 @@ describe('structured option state from the host model catalog', () => { const model = snapshot.find((descriptor) => descriptor.id === 'model')! expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBe('gpt-live') }) + + it('keeps a known catalog and default when an options read has no loaded catalog', () => { + const hosted = applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState('codex', SEED), + SEED, + HOST_CATALOG, + LAUNCH + ) + const unknown = applyStructuredAgentSessionOptions(hosted, SEED, { + models: [], + modelsKnown: false, + current: {} + }) + expect(unknown.catalog).toBe(hosted.catalog) + expect(unknown.catalogSource).toBe('host') + expect(structuredAgentSessionOptionSnapshot(unknown)).toEqual( + structuredAgentSessionOptionSnapshot(hosted) + ) + const picked = applyStructuredAgentSessionOptions(unknown, SEED, { + models: [], + modelsKnown: false, + current: { model: 'gpt-hosted', effort: 'medium' } + }) + expect(picked.catalog).toBe(hosted.catalog) + const effort = structuredAgentSessionOptionSnapshot(picked).find( + (option) => option.id === 'effort' + ) + expect(effort?.kind).toMatchObject({ currentValue: 'medium' }) + }) + + it.each([ + ['claude', 'opus'], + ['codex', 'gpt-5.5'] + ])( + '%s shows a saved effort before its catalog loads and accepts the later list', + (agent, model) => { + const seed = getAgentSessionOptionCatalog(agent)! + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState(agent, seed), + seed, + { models: [], modelsKnown: false, current: { model, effort: 'high' } } + ) + expect(state.catalog).toBe(seed) + expect(state.catalogSource).toBe('seed') + expect(structuredAgentSessionOptionSnapshot(state)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + kind: expect.objectContaining({ currentValue: model }) + }), + expect.objectContaining({ + id: 'effort', + kind: expect.objectContaining({ currentValue: 'high' }) + }) + ]) + ) + const loaded = applyStructuredAgentSessionModelCatalog( + state, + seed, + { ...HOST_CATALOG, models: [{ ...HOST_CATALOG.models[0]!, id: model }] }, + LAUNCH + ) + expect(loaded.catalogSource).toBe('host') + expect( + structuredAgentSessionOptionSnapshot(loaded).find((option) => option.id === 'effort')?.kind + ).toMatchObject({ currentValue: 'high' }) + expect(loaded.catalog?.models.map((row) => row.label)).toEqual(['GPT Hosted']) + } + ) + + it('keeps an unrecognized saved model quiet until the host lists it', () => { + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('codex', SEED), + SEED, + { models: [], modelsKnown: false, current: { model: 'gpt-hosted', effort: 'high' } } + ) + expect(structuredAgentSessionOptionSnapshot(state)).toEqual([ + expect.objectContaining({ id: 'model', settable: false, valueSource: 'unknown' }) + ]) + const loaded = applyStructuredAgentSessionModelCatalog(state, SEED, HOST_CATALOG, LAUNCH) + expect(structuredAgentSessionOptionSnapshot(loaded)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + kind: expect.objectContaining({ currentValue: 'gpt-hosted' }) + }), + expect.objectContaining({ + id: 'effort', + kind: expect.objectContaining({ currentValue: 'high' }) + }) + ]) + ) + }) }) diff --git a/src/shared/structured-agent-session-option-view.ts b/src/shared/structured-agent-session-option-view.ts index 15d6ec7800d0..ae826c011324 100644 --- a/src/shared/structured-agent-session-option-view.ts +++ b/src/shared/structured-agent-session-option-view.ts @@ -64,5 +64,5 @@ function modelOptionView( if (seeded) { view = commitStructuredAgentSessionOptionValues(view, seed) } - return { ...commitStructuredAgentSessionOptionValues(view, held), pendingId: state.pendingId } + return { ...commitStructuredAgentSessionOptionValues(view, held), pendingPick: state.pendingPick } } diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 5920e68ae173..e1c739c7bb79 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -54,7 +54,7 @@ export type StructuredAgentSessionOptionState = { record: NativeChatSessionOptionRecord /** The permission mode the host or launch last stated; null shows no picker. */ permissionMode: string | null - pendingId: string | null + pendingPick: { id: string; value: string } | null } /** With `seedCatalog`, the state holds the static seed from the first frame, but the picker shows @@ -68,7 +68,7 @@ export function createStructuredAgentSessionOptionState( catalogSource: seedCatalog ? 'seed' : null, record: createNativeChatSessionOptionRecord(agent), permissionMode: null, - pendingId: null + pendingPick: null } } @@ -145,8 +145,11 @@ export function applyStructuredAgentSessionOptions( ) return { ...state, - catalog: structuredAgentSessionOptionCatalog(seed, result), - catalogSource: 'live', + catalog: + result.modelsKnown === false + ? state.catalog + : structuredAgentSessionOptionCatalog(seed, result), + catalogSource: result.modelsKnown === false ? state.catalogSource : 'live', permissionMode: result.current.permissionMode ?? null } } @@ -176,8 +179,11 @@ function modelOptionSnapshot(state: StructuredAgentSessionOptionState): SessionO if (!state.catalog) { return [] } - // Until the host answers, the built-in list would paint a label its list may replace. - if (state.catalogSource === 'seed') { + // A built-in list can name a saved model it recognizes, never a guessed default. + if ( + state.catalogSource === 'seed' && + !state.catalog.models.some((model) => model.id === state.record.model?.value) + ) { return [PROVIDER_DEFAULT_MODEL_PLACEHOLDER] } const snapshot = buildNativeChatSessionOptionSnapshot({ @@ -213,7 +219,7 @@ export function canSetStructuredAgentSessionOption( const descriptor = structuredAgentSessionOptionSnapshot(state).find((entry) => entry.id === id) return Boolean( state.catalog && - state.pendingId === null && + state.pendingPick === null && ((typeof value === 'string' && descriptor?.kind.type === 'select' && descriptor.kind.choices.some((choice) => choice.value === value)) || @@ -227,7 +233,7 @@ export function commitStructuredAgentSessionOption( value: string ): StructuredAgentSessionOptionState { if (id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID) { - return { ...state, permissionMode: value, pendingId: null } + return { ...state, permissionMode: value, pendingPick: null } } if (!state.catalog) { return state @@ -239,10 +245,10 @@ export function commitStructuredAgentSessionOption( ) const decoded = decodeStructuredAgentSessionOptionValue(id, value) if (decoded === null) { - return { ...state, pendingId: null } + return { ...state, pendingPick: null } } setTrackedSessionOption(state.record, id, decoded, 'dispatched', effectiveModel) - return { ...state, pendingId: null } + return { ...state, pendingPick: null } } export function commitStructuredAgentSessionOptionValues( diff --git a/src/shared/structured-agent-session-send-mutation.test.ts b/src/shared/structured-agent-session-send-mutation.test.ts index 353186e4da11..9a09a41e37e2 100644 --- a/src/shared/structured-agent-session-send-mutation.test.ts +++ b/src/shared/structured-agent-session-send-mutation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalMessageItem } from './agent-session-journal-types' import type { AgentMessageSource } from './agent-session-message-source' +import { AgentJournalSubmissionSchema } from './agent-session-journal-submission-schema' import { SendParams } from './rpc-contract/structured-agent-session-params' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' import { @@ -26,6 +27,44 @@ const FROM: AgentMessageSource = { } describe('send fingerprints never cover the sender', () => { + it('keeps message choices outside the operation and body fingerprints', () => { + const send = (executionOptions?: { model: string; effort: string }) => + structuredAgentSessionMessageSendMutation({ + sessionId: 'session-1', + clientOperationId: 'op-1', + expectedRuntimeFence: 1, + body: BODY, + ...(executionOptions ? { executionOptions } : {}) + }) + const legacy = send() + const chosen = send({ model: 'model-a', effort: 'high' }) + expect(chosen.envelope.payloadFingerprint).toBe(legacy.envelope.payloadFingerprint) + expect(chosen.executionOptions).toEqual({ model: 'model-a', effort: 'high' }) + expect(SendParams.safeParse(legacy).success).toBe(true) + expect(SendParams.safeParse(chosen).success).toBe(true) + expect(SendParams.omit({ executionOptions: true }).safeParse(chosen).success).toBe(false) + }) + + it('lets an older submission reader drop the new optional choices', () => { + const submission = { + clientMessageId: 'message-1', + executionOptions: { model: 'model-a' }, + fence: 1, + payloadFingerprint: 'a'.repeat(64), + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2 + } + expect(AgentJournalSubmissionSchema.parse(submission).executionOptions).toEqual({ + model: 'model-a' + }) + expect( + AgentJournalSubmissionSchema.omit({ executionOptions: true }).parse(submission) + ).not.toHaveProperty('executionOptions') + }) + it('keeps the digest every stored submission and draft already holds', () => { // Pinned bytes: a stored fingerprint that changed would stop matching its provider echo. expect(agentSessionSendBodyFingerprint('session-1', BODY)).toBe( diff --git a/src/shared/structured-agent-session-send-mutation.ts b/src/shared/structured-agent-session-send-mutation.ts index aec8ad3988e1..d205eee1d1ed 100644 --- a/src/shared/structured-agent-session-send-mutation.ts +++ b/src/shared/structured-agent-session-send-mutation.ts @@ -1,5 +1,6 @@ import type { AgentJournalItemBody, AgentJournalMessageItem } from './agent-session-journal-types' import type { AgentSessionMutationEnvelope } from './agent-session-wire' +import type { AgentSessionExecutionOptions } from './agent-session-execution-options' import type { z } from 'zod' import type { SendBody } from './rpc-contract/structured-agent-session-message-params' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' @@ -8,6 +9,7 @@ export type StructuredAgentSessionSendMutation = { envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem delivery?: 'queue-if-active' + executionOptions?: AgentSessionExecutionOptions } /** What every send fingerprint covers: the message without its sender, which never reaches the @@ -37,6 +39,7 @@ export function structuredAgentSessionMessageSendMutation(message: { expectedRuntimeFence: number body: AgentJournalMessageItem delivery?: 'queue-if-active' + executionOptions?: AgentSessionExecutionOptions }): StructuredAgentSessionSendMutation { // `delivery` joins the OPERATION fingerprint exactly as the host digests it; never the body's. const delivery = message.delivery ? { delivery: message.delivery } : {} @@ -52,7 +55,8 @@ export function structuredAgentSessionMessageSendMutation(message: { }) }, body: message.body, - ...delivery + ...delivery, + ...(message.executionOptions ? { executionOptions: message.executionOptions } : {}) } } From e24895d9194de19baa26bba6e3b4b24669a180d4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:02:52 -0700 Subject: [PATCH 026/127] Apply accepted Claude message choices at prompt handover --- src/main/claude/claude-structured-dispatch.ts | 16 ++ .../claude-structured-message-options.test.ts | 175 ++++++++++++++++++ .../claude-structured-message-options.ts | 68 +++++++ .../claude-structured-session-adapter.ts | 24 ++- .../claude-structured-session-startup.ts | 8 + .../claude/claude-structured-session-state.ts | 2 + 6 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 src/main/claude/claude-structured-message-options.test.ts create mode 100644 src/main/claude/claude-structured-message-options.ts diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 3467a474e0bf..3604e1e86cf1 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -20,6 +20,7 @@ import { dispatchWriteOutcomeUnknownReason } from '../native-chat/agent-session- import { DISPATCH_REJECTED_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection' import { agentSessionFailureFact, + providerDiagnostic, type SubmissionRejectionFact } from '../../shared/agent-session-failure' import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' @@ -28,6 +29,8 @@ import { claudeUserMessageWasProvablyUnwritten } from './claude-agent-sdk-user-message-queue' import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement' +import { isAgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { ClaudeControlRequestError } from './claude-stream-json-connection' const MAX_ACTIVE_DISPATCH_WAITERS = 64 @@ -171,6 +174,19 @@ export async function dispatchClaudeTurn( if (error instanceof AgentSessionPreDispatchError) { throw error } + if (isAgentSessionOptionRejectedError(error)) { + return { + state: 'rejected', + ...claudeDispatchRejection( + agentSessionFailureFact( + 'providerRejected', + error.cause instanceof ClaudeControlRequestError + ? { detail: providerDiagnostic(error.message, 'person') } + : {} + ) + ) + } + } return { state: 'rejected', ...claudeWriteFailureRejection(error) } } const waiter = replay.waiter diff --git a/src/main/claude/claude-structured-message-options.test.ts b/src/main/claude/claude-structured-message-options.test.ts new file mode 100644 index 000000000000..7366be3bb1cd --- /dev/null +++ b/src/main/claude/claude-structured-message-options.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from 'vitest' +import { ClaudeControlRequestError } from './claude-stream-json-connection' +import { + acquired, + claudeFrame, + fakeClaude, + USER_MESSAGE +} from './claude-structured-session-test-support' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' + +const MODELS = [ + { value: 'default', resolvedModel: 'claude-sonnet-5' }, + { + value: 'sonnet', + resolvedModel: 'claude-sonnet-5', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + }, + { + value: 'opus', + resolvedModel: 'claude-opus-5', + supportsEffort: true, + supportedEffortLevels: ['low', 'high'] + } +] + +describe('Claude accepted message choices', () => { + it('applies a held message A before its prompt, then message B on the existing child', async () => { + const order: string[] = [] + let currentEffort = 'high' + const claude = fakeClaude({ + routes: { + list_models: () => MODELS, + set_model: ({ model } = {}) => order.push(`model:${String(model)}`), + apply_flag_settings: ({ settings } = {}) => { + order.push(`effort:${JSON.stringify(settings)}`) + if ( + typeof settings === 'object' && + settings !== null && + 'effortLevel' in settings && + typeof settings.effortLevel === 'string' + ) { + currentEffort = settings.effortLevel + } + } + } + }) + const adapter = await acquired(claude) + const connection = claude.connections[0]! + connection.getSettings = async () => ({ effective: { effortLevel: currentEffort } }) + const send = connection.send + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + let entered!: () => void + const enteredSend = new Promise((resolve) => { + entered = resolve + }) + let first = true + connection.send = async (message, beforeDispatch) => { + if (first) { + first = false + entered() + await held + } + const result = await send(message, beforeDispatch) + order.push('prompt') + return result + } + + const a = adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'A', + body: USER_MESSAGE, + executionOptions: { model: 'sonnet', effort: 'low' }, + fence: 7 + }) + await enteredSend + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 }) + await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) + order.length = 0 + release() + await expect(a).resolves.toEqual({ state: 'admitted' }) + expect(order).toEqual(['model:sonnet', 'effort:{"effortLevel":"low"}', 'prompt']) + + order.length = 0 + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'B', + body: USER_MESSAGE, + executionOptions: { model: 'opus', effort: 'high' }, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + expect(order).toEqual(['model:opus', 'effort:{"effortLevel":"high"}', 'prompt']) + }) + + it('restores the configured model and effort for an empty snapshot', async () => { + let currentEffort = 'high' + const claude = fakeClaude({ + routes: { + list_models: () => MODELS, + apply_flag_settings: ({ settings } = {}) => { + if ( + typeof settings === 'object' && + settings !== null && + 'effortLevel' in settings && + typeof settings.effortLevel === 'string' + ) { + currentEffort = settings.effortLevel + } + } + } + }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + await vi.waitFor(() => + expect(events).toContainEqual(expect.objectContaining({ type: 'options-reported' })) + ) + claude.connections[0]!.getSettings = async () => ({ effective: { effortLevel: currentEffort } }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'A', + body: USER_MESSAGE, + executionOptions: { model: 'opus', effort: 'low' }, + fence: 7 + }) + const connection = claude.connections[0]! + claudeFrame(connection, { type: 'system', subtype: 'init', model: 'claude-opus-5' }) + connection.calls.length = 0 + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'default', + body: USER_MESSAGE, + executionOptions: {}, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + expect( + connection.calls.filter((call) => ['set_model', 'apply_flag_settings'].includes(call.subtype)) + ).toEqual([ + { subtype: 'set_model', params: { model: 'claude-sonnet-5' } }, + { subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'high' } } } + ]) + expect(connection.sent).toHaveLength(2) + }) + + it('rejects a refused model before any prompt is handed to Claude', async () => { + const claude = fakeClaude({ + routes: { + list_models: () => MODELS, + set_model: () => { + throw new ClaudeControlRequestError('set_model', 'model unavailable') + } + } + }) + const adapter = await acquired(claude) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'refused', + body: USER_MESSAGE, + executionOptions: { model: 'opus', effort: 'low' }, + fence: 7 + }) + ).resolves.toMatchObject({ + state: 'rejected', + rejection: { kind: 'providerRejected' } + }) + expect(claude.connections[0]!.sent).toHaveLength(0) + }) +}) diff --git a/src/main/claude/claude-structured-message-options.ts b/src/main/claude/claude-structured-message-options.ts new file mode 100644 index 000000000000..849026a3f328 --- /dev/null +++ b/src/main/claude/claude-structured-message-options.ts @@ -0,0 +1,68 @@ +import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { listedModels } from './claude-structured-model-catalog' +import { setClaudeStructuredOption } from './claude-structured-options' +import { readClaudeCurrentModel } from './claude-structured-session-options' +import type { ClaudeSession } from './claude-structured-session-state' + +/** Applies the accepted message's choices while its prompt is still in the SDK input queue. */ +export async function applyClaudeMessageOptions( + session: ClaudeSession, + options: AgentSessionExecutionOptions, + timeoutMs: number | undefined +): Promise { + const model = options.model ?? (await defaultModelForMessage(session, timeoutMs)) + if (model && readClaudeCurrentModel(session).id !== model) { + await setClaudeStructuredOption(session, { key: 'model', value: model }, timeoutMs) + } + + const effort = options.effort ?? session.configuredDefaults?.effort + if ( + effort && + (session.options.get('effort') !== effort || !session.confirmedOptions.has('effort')) + ) { + await setClaudeStructuredOption(session, { key: 'effort', value: effort }, timeoutMs) + if ( + effort !== 'max' && + !session.confirmedOptions.has('effort') && + session.reportedOptions.effort !== undefined && + session.reportedOptions.effort !== effort + ) { + throw new AgentSessionOptionRejectedError( + `Claude reported effort ${session.reportedOptions.effort} instead of ${effort}` + ) + } + } else if (!effort && session.options.has('effort')) { + throw new AgentSessionOptionRejectedError('Claude did not report its configured effort') + } + + const fastMode = options.fastMode ?? 'false' + if ( + session.options.get('fastMode') !== fastMode && + (options.fastMode || session.options.has('fastMode')) + ) { + await setClaudeStructuredOption(session, { key: 'fastMode', value: fastMode }, timeoutMs) + if (session.options.get('fastMode') !== fastMode) { + throw new AgentSessionOptionRejectedError(`Claude did not apply Fast mode ${fastMode}`) + } + } +} + +async function defaultModelForMessage( + session: ClaudeSession, + timeoutMs: number | undefined +): Promise { + if (session.configuredDefaults?.model) { + return session.configuredDefaults.model + } + if (!session.options.has('model')) { + return undefined + } + const response = await session.connection.supportedModels({ timeoutMs }).catch(() => null) + const listed = listedModels(response ? { models: response } : null) + const fallback = listed.find((model) => model.isDefault)?.id + if (!fallback) { + throw new AgentSessionOptionRejectedError('Claude did not report its default model') + } + return fallback +} diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index db7f06c5fbb5..384673af0171 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -12,6 +12,8 @@ import { releaseClaudeAcquisition } from './claude-structured-acquisition-releas import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' import { setClaudeStructuredSessionOption } from './claude-structured-options' +import { applyClaudeMessageOptions } from './claude-structured-message-options' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' import { ClaudeAcquisitionRegistry, @@ -162,8 +164,26 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda this.sessions.get(sessionId)?.prompts.bindJournalItemId(journalItemId, promptKey) } - dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => - dispatchClaudeTurn(this.session(input.sessionId), input, input.beforeDispatch) + dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => { + const session = this.session(input.sessionId) + const messageOptions = input.executionOptions + return dispatchClaudeTurn( + session, + input, + messageOptions === undefined + ? input.beforeDispatch + : async () => { + await input.beforeDispatch?.() + try { + await applyClaudeMessageOptions(session, messageOptions, this.deps.requestTimeoutMs) + } catch (error) { + throw error instanceof AgentSessionOptionRejectedError + ? error + : new AgentSessionOptionRejectedError(error) + } + } + ) + } compact: NonNullable = (input) => dispatchClaudeCommand(this.session(input.sessionId), input.command) diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index b8002eff615f..19e12c9804f8 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -23,6 +23,7 @@ import { claudeResolvedCatalogListing, claudeStructuredSessionOptionsFrom, observeClaudeSettingsApplied, + readClaudeSettingsApplied, readClaudeFastModeFacts, readClaudeSettingsEffort } from './claude-structured-session-options' @@ -140,6 +141,13 @@ function applyClaudeSettingsFacts( const { settings, prepared } = facts const effort = readClaudeSettingsEffort(settings) const published = claudeStructuredSessionPublicationOptions(prepared) + if (!writtenSinceRead) { + const applied = readClaudeSettingsApplied(settings) + session.configuredDefaults = { + ...(!session.options.has('model') && applied?.model ? { model: applied.model } : {}), + ...(!session.options.has('effort') && applied?.effort ? { effort: applied.effort } : {}) + } + } observeClaudeSettingsApplied(session, settings) if (published.fastModePerSessionOptIn !== null) { session.fastModePerSessionOptIn = published.fastModePerSessionOptIn diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index f34c5db28628..228453adb69e 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -162,6 +162,8 @@ export type ClaudeSession = { /** What `get_settings` says the next request will send, after Claude's own env and settings * precedence: the lowest-ranked answer, unconfirmed until a turn reports it. */ appliedOptions?: { model?: string; effort?: string } + /** CLI settings resolved before a turn changed them, when no launch override hid that value. */ + configuredDefaults?: { model?: string; effort?: string } fastModeState?: AgentSessionFastModeState fastModeDisabledReason?: string fastModePerSessionOptIn?: boolean From e0b92fc175ed7fdb9a30c89c31ad75d7f484a25f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:13:34 -0700 Subject: [PATCH 027/127] Apply captured message choices at provider dispatch --- src/main/acp/acp-message-execution-options.ts | 35 ++++++++ .../acp-structured-session-adapter.test.ts | 42 ++++++++++ .../acp/acp-structured-session-adapter.ts | 24 ++++-- ...e-structured-session-adapter-turns.test.ts | 22 +++++ ...tructured-agent-session-message-options.ts | 29 ------- .../structured-agent-session-turns.ts | 26 +----- src/main/pi/rpc-message-dispatch.ts | 84 +++++++++++++++++++ src/main/pi/rpc-session-adapter.test.ts | 34 ++++++++ src/main/pi/rpc-session-adapter.ts | 34 ++------ 9 files changed, 239 insertions(+), 91 deletions(-) create mode 100644 src/main/acp/acp-message-execution-options.ts create mode 100644 src/main/pi/rpc-message-dispatch.ts diff --git a/src/main/acp/acp-message-execution-options.ts b/src/main/acp/acp-message-execution-options.ts new file mode 100644 index 000000000000..b2998cea5fd1 --- /dev/null +++ b/src/main/acp/acp-message-execution-options.ts @@ -0,0 +1,35 @@ +import { providerDiagnostic, withProviderDiagnostic } from '../../shared/agent-session-failure' +import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { AcpAgentError } from './acp-errors' +import type { AcpStructuredSession } from './acp-structured-session' +import { acpPromptErrorDetail } from './acp-turn-failures' + +export async function applyAcpMessageOptions( + session: AcpStructuredSession, + options: AgentSessionExecutionOptions | undefined, + setOption: (key: 'model' | 'effort', value: string) => Promise, + isCurrent: () => boolean +): Promise { + for (const key of ['model', 'effort'] as const) { + const value = options?.[key] + if (value === undefined || session.options.reported()[key] === value) { + continue + } + try { + await setOption(key, value) + } catch (error) { + if (error instanceof AcpAgentError) { + throw withProviderDiagnostic( + new AgentSessionOptionRejectedError(error), + providerDiagnostic(acpPromptErrorDetail(session.spec.dialect, error), 'person') + ) + } + throw error + } + if (!isCurrent()) { + return false + } + } + return true +} diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 472eee34444c..47718f02e0d7 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -158,6 +158,48 @@ it('reads those last words when the agent ends its stdout before its exit is see }) describe('ACP structured session adapter: turns', () => { + it('applies a sent message’s model and effort before its prompt', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'chosen-send', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + executionOptions: { model: 'grok-4.6', effort: 'low' } + }) + await rig.frame('session/prompt') + const methods = rig.child().agent.frames.map((frame) => frame.method) + expect(methods.slice(-3)).toEqual([ + 'session/set_config_option', + 'session/set_config_option', + 'session/prompt' + ]) + await expect(rig.adapter.readOptions({ sessionId: SESSION, fence: 1 })).resolves.toMatchObject({ + current: { model: 'grok-4.6', effort: 'low' } + }) + }) + + it('does not send a prompt when the agent refuses a message’s model', async () => { + const rig = await openAcpAdapterRig({ + script: (agent) => + agent.on('session/set_config_option', (frame) => + agent.fail(frame, -32602, 'Model unavailable') + ) + }) + await rig.acquire() + await expect( + rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'refused-model', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + executionOptions: { model: 'grok-4.6' } + }) + ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) + expect(rig.sent('session/prompt')).toEqual([]) + }) + it("sends Grok's prompt identity only to an agent whose dialect echoes it", async () => { const rig = await openAcpAdapterRig({ deps: { spec: { ...GROK, dialect: GENERIC_ACP_DIALECT } } diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 0b61db21d14a..1f4caa8a9b2d 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -5,7 +5,6 @@ import { randomUUID } from 'node:crypto' 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, @@ -27,6 +26,7 @@ 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 { applyAcpMessageOptions } from './acp-message-execution-options' import { awaitAcpTurnEnd, interruptAcpTurn } from './acp-structured-stop' import { acpDispatchPrompt } from './acp-prompt-content' import { @@ -162,14 +162,9 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } } - async dispatch(input: { - sessionId: string - clientMessageId: string - body: AgentJournalMessageItem - fence: number - requestedAt?: number - beforeDispatch?: () => Promise - }): Promise { + async dispatch( + input: Parameters[0] + ): Promise { const lost = this.children.sessions.get(input.sessionId) if (lost && lost.journalClosed !== null) { // The connection broke and the exit is not yet proven: the message never left Orca. @@ -184,6 +179,17 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte // The child ended while its attachments were read: nothing left Orca. return this.rejected(session, 'providerExited') } + const optionsApplied = await applyAcpMessageOptions( + session, + input.executionOptions, + (key, value) => + this.setOption({ sessionId: input.sessionId, fence: input.fence, key, value }), + () => + this.children.sessions.get(input.sessionId) === session && session.journalClosed === null + ) + if (!optionsApplied) { + return this.rejected(session, 'providerExited') + } await input.beforeDispatch?.() session.turns.dispatch({ clientMessageId: input.clientMessageId, diff --git a/src/main/claude/claude-structured-session-adapter-turns.test.ts b/src/main/claude/claude-structured-session-adapter-turns.test.ts index 413d2db4fb67..a48497b84166 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -12,6 +12,28 @@ import { } from './claude-structured-session-test-support' describe('ClaudeStructuredSessionAdapter turns and controls', () => { + it('applies a sent model and effort before handing Claude the message', async () => { + const claude = fakeClaude({ replayUuid: 'chosen-uuid' }) + const adapter = await acquired(claude) + const connection = claude.connections[0]! + const send = connection.send + connection.send = async (...args) => { + const controls = connection.calls.map((call) => call.subtype) + expect(controls.indexOf('set_model')).toBeGreaterThan(-1) + expect(controls.indexOf('set_model')).toBeLessThan(controls.indexOf('apply_flag_settings')) + return send(...args) + } + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'chosen-send', + body: USER_MESSAGE, + fence: 7, + executionOptions: { model: 'sonnet', effort: 'high' } + }) + ).resolves.toEqual({ state: 'admitted' }) + }) + it("admits a dispatch on the write and names it from Claude's replay", async () => { const claude = fakeClaude({ replayUuid: 'user-provider-uuid' }) const settled = vi.fn() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts index d9b5c98b405f..1e797a840732 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts @@ -2,8 +2,6 @@ import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' const OPTION_ORDER = ['model', 'effort', 'personality', 'fastMode', 'serviceTier'] as const @@ -32,30 +30,3 @@ export function waitsForCodexTurnOptions( }) return active ? !sameOptions(active.executionOptions, next.executionOptions) : true } - -/** Only the provider's live selection changes here; the saved picker choice stays for future sends. */ -export async function applyMessageExecutionOptions( - adapter: Pick, - sessionId: string, - fence: number, - options: AgentSessionExecutionOptions | undefined -): Promise<{ ok: true } | { ok: false; reason: string }> { - if (!options) { - return { ok: true } - } - for (const key of OPTION_ORDER) { - const value = options[key] - if (value === undefined) { - continue - } - try { - await adapter.setOption({ sessionId, fence, key, value }) - } catch (error) { - if (isAgentSessionOptionRejectedError(error)) { - return { ok: false, reason: error.message } - } - throw error - } - } - return { ok: true } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 09ae3e55e7b6..d5ca5b187ca9 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 @@ -12,7 +12,6 @@ import { } from '../../../shared/agent-session-failure' import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { captureStructuredExecutionOptions } from './structured-agent-session-execution-options' -import { applyMessageExecutionOptions } from './structured-agent-session-message-options' import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' import { agentSessionFailureWords, @@ -242,29 +241,6 @@ export async function handOverSubmission( await handOverStructuredAgentSessionCommand(ctx, submission, body) return } - const codex = ctx.record()?.provider === 'codex' - const applied = codex - ? { ok: true as const } - : await applyMessageExecutionOptions( - ctx.adapter, - ctx.sessionId, - ctx.fence, - submission.executionOptions - ) - if (!applied.ok) { - await ctx.journal.resolveDispatch({ - clientMessageId, - state: 'rejected', - ...agentSessionFailureWords( - agentSessionFailureFact('providerRejected', { - detail: providerDiagnosticOf(new Error(applied.reason)) - }), - { surface: 'rejection' } - ), - fence: ctx.fence - }) - return - } // The message joins the turn running at handover, a steer, or opens its own. await ctx.journal.resolveDispatch({ clientMessageId, @@ -279,7 +255,7 @@ export async function handOverSubmission( clientMessageId, body, structuredAgentSessionHandoverOrigin(ctx.journal, submission), - codex ? submission.executionOptions : undefined + submission.executionOptions ) // An admission needs no dispatch row: the submission is already pending. if (outcome.state === 'admitted') { diff --git a/src/main/pi/rpc-message-dispatch.ts b/src/main/pi/rpc-message-dispatch.ts new file mode 100644 index 000000000000..1c35cc0b72ce --- /dev/null +++ b/src/main/pi/rpc-message-dispatch.ts @@ -0,0 +1,84 @@ +import { + agentSessionFailureFact, + providerDiagnostic, + withProviderDiagnostic +} from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { ClaudeDispatchContentError } from '../claude/claude-structured-dispatch-content' +import { JsonlRpcResponseError } from '../jsonl-rpc/peer' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { applyPiRpcSessionOption } from './rpc-options' +import { PiRpcPromptError, preparePiRpcPrompt } from './rpc-prompt' +import type { PiRpcSession, PiRpcSessionDeps } from './rpc-session' + +type DispatchInput = Parameters[0] + +function exited(): AgentSessionDispatchOutcome { + return { + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { + agentName: 'Pi', + surface: 'rejection' + }) + } +} + +export async function dispatchPiRpcMessage( + session: PiRpcSession, + input: DispatchInput, + logger: PiRpcSessionDeps['logger'], + isCurrent: () => boolean +): Promise { + let prompt + try { + prompt = await preparePiRpcPrompt(input.body) + } catch (error) { + logger.warn('Pi prompt could not be prepared', { + scope: 'pi-prompt', + sessionId: input.sessionId, + error + }) + return { + state: 'rejected', + ...agentSessionFailureWords( + error instanceof PiRpcPromptError || error instanceof ClaudeDispatchContentError + ? error.failure + : agentSessionFailureFact('attachmentUnreadable'), + { agentName: 'Pi', surface: 'rejection' } + ) + } + } + for (const key of ['model', 'effort'] as const) { + const value = input.executionOptions?.[key] + if (value === undefined) { + continue + } + try { + await applyPiRpcSessionOption(session.connection, session.selected, key, value) + } catch (error) { + if (session.connection.closed) { + return exited() + } + if (error instanceof JsonlRpcResponseError) { + throw withProviderDiagnostic( + new AgentSessionOptionRejectedError(error), + providerDiagnostic(error.message, 'person') + ) + } + throw error + } + } + if (!isCurrent() || session.connection.closed) { + return exited() + } + return session.turns.submit( + input.clientMessageId, + input.requestedAt ?? Date.now(), + { ...prompt }, + input.beforeDispatch + ) +} diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index e401cbed2096..8cd553e870a8 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -199,6 +199,40 @@ describe('Pi RPC session ownership and delivery', () => { expect(h.connection.sent[0]).toMatchObject({ type: 'prompt', message: 'hello' }) }) + it('applies each sent model before effort and before prompting Pi', async () => { + const h = await setup() + h.connection.requestOverride = (command) => + command === 'set_thinking_level' ? Promise.resolve({}) : undefined + const result = await h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'choice-send', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'chosen' }] }, + executionOptions: { model: 'anthropic/model-2', effort: 'low' } + }) + expect(result).toEqual({ state: 'admitted' }) + expect(h.connection.requests.slice(-2)).toEqual(['set_model', 'set_thinking_level']) + expect(h.connection.sent).toMatchObject([{ type: 'prompt', message: 'chosen' }]) + }) + + it('leaves the prompt unsent when Pi refuses a sent model', async () => { + const h = await setup() + h.connection.requestOverride = (command) => + command === 'set_model' + ? Promise.reject(new JsonlRpcResponseError(command, 'Model unavailable')) + : undefined + await expect( + h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'refused-choice', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'chosen' }] }, + executionOptions: { model: 'anthropic/model-2' } + }) + ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) + expect(h.connection.sent).toEqual([]) + }) + it('rejects stale fences and a Stop for a different turn before sending abort', async () => { const h = await setup() await expect( diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index 6dc725194c29..7d8985007e6f 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -1,7 +1,5 @@ import { randomUUID } from 'node:crypto' import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import { agentSessionFailureFact } from '../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import { AgentSessionPreSpawnError, AgentSessionAcquisitionRootExitObservedError, @@ -17,10 +15,9 @@ import { compactPiRpcSession } from './rpc-compaction' import { buildPiRpcLaunch } from './rpc-launch' import type { PiRpcResolvedLaunch } from './rpc-launch-resolution' import { PiRpcSession, type PiRpcSessionDeps, type PiRpcConnection } from './rpc-session' -import { PiRpcPromptError, preparePiRpcPrompt } from './rpc-prompt' +import { dispatchPiRpcMessage } from './rpc-message-dispatch' import { applyPiRpcSessionOption } from './rpc-options' import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' -import { ClaudeDispatchContentError } from '../claude/claude-structured-dispatch-content' export type PiRpcSessionAdapterDeps = PiRpcSessionDeps & { resolveLaunch: (input: { @@ -137,30 +134,11 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { dispatch: StructuredAgentSessionAdapter['dispatch'] = async (input) => { const session = this.session(input.sessionId, input.fence) - let prompt - try { - prompt = await preparePiRpcPrompt(input.body) - } catch (error) { - this.deps.logger.warn('Pi prompt could not be prepared', { - scope: 'pi-prompt', - sessionId: input.sessionId, - error - }) - return { - state: 'rejected', - ...agentSessionFailureWords( - error instanceof PiRpcPromptError || error instanceof ClaudeDispatchContentError - ? error.failure - : agentSessionFailureFact('attachmentUnreadable'), - { agentName: 'Pi', surface: 'rejection' } - ) - } - } - return session.turns.submit( - input.clientMessageId, - input.requestedAt ?? Date.now(), - { ...prompt }, - input.beforeDispatch + return dispatchPiRpcMessage( + session, + input, + this.deps.logger, + () => this.sessions.get(input.sessionId) === session ) } From 323f070dba5dd094634573fd31cdf2fa3e59c9f2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:31:55 -0700 Subject: [PATCH 028/127] Bind native chat choices to accepted message dispatch --- src/main/acp/acp-message-execution-options.ts | 8 +- src/main/acp/acp-structured-handshake.ts | 6 +- src/main/acp/acp-structured-options.test.ts | 15 +- src/main/acp/acp-structured-options.ts | 49 +---- .../acp-structured-publish-at-spawn.test.ts | 22 ++- .../acp-structured-session-adapter.test.ts | 25 +++ .../claude-structured-message-options.ts | 3 +- .../claude-structured-session-startup.test.ts | 44 ++++- .../claude-structured-session-startup.ts | 12 -- .../claude/claude-structured-spawn-options.ts | 5 +- .../claude-structured-start-fast-mode.ts | 72 ------- ...-agent-session-acquisition-options.test.ts | 63 +----- .../structured-agent-session-acquisition.ts | 30 +-- .../structured-agent-session-agent-start.ts | 7 + .../structured-agent-session-attach-flow.ts | 1 + ...ured-agent-session-attach-orchestration.ts | 18 +- ...ed-agent-session-conversation-stop.test.ts | 10 +- ...structured-agent-session-host-mutations.ts | 33 +--- .../structured-agent-session-host-types.ts | 3 - .../structured-agent-session-host.test.ts | 20 +- ...ured-agent-session-message-choices.test.ts | 70 ++++++- ...structured-agent-session-mutation-plans.ts | 8 +- ...ed-agent-session-option-settlement.test.ts | 47 +---- ...uctured-agent-session-options-read.test.ts | 12 +- .../structured-agent-session-options-read.ts | 25 ++- ...red-agent-session-provider-started.test.ts | 6 +- ...ructured-agent-session-provider-started.ts | 184 ++---------------- ...ctured-agent-session-refusal-retry.test.ts | 58 +----- ...uctured-agent-session-repeat-press.test.ts | 6 +- ...agent-session-startup-attempt-host.test.ts | 156 ++------------- ...tructured-agent-session-startup-attempt.ts | 21 +- ...structured-agent-session-startup-intent.ts | 143 -------------- ...nt-session-startup-permission-mode.test.ts | 19 +- ...ctured-agent-session-startup-picks.test.ts | 55 ++---- .../structured-agent-session-turns-options.ts | 34 ---- .../structured-agent-session-turns.ts | 1 - src/main/pi/rpc-message-dispatch.ts | 13 +- src/main/pi/rpc-session-adapter.test.ts | 54 ++++- src/main/pi/rpc-session-startup.ts | 41 +--- src/main/pi/rpc-session.ts | 6 +- 40 files changed, 407 insertions(+), 998 deletions(-) delete mode 100644 src/main/claude/claude-structured-start-fast-mode.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-turns-options.ts diff --git a/src/main/acp/acp-message-execution-options.ts b/src/main/acp/acp-message-execution-options.ts index b2998cea5fd1..9cdf026c687d 100644 --- a/src/main/acp/acp-message-execution-options.ts +++ b/src/main/acp/acp-message-execution-options.ts @@ -11,8 +11,14 @@ export async function applyAcpMessageOptions( setOption: (key: 'model' | 'effort', value: string) => Promise, isCurrent: () => boolean ): Promise { + const initial = session.options.messageDefault() + const model = options?.model ?? initial.model + const defaultEffort = + model === initial.model + ? initial.effort + : session.options.read().models.find((entry) => entry.id === model)?.defaultEffort for (const key of ['model', 'effort'] as const) { - const value = options?.[key] + const value = options?.[key] ?? (key === 'model' ? initial.model : defaultEffort) if (value === undefined || session.options.reported()[key] === value) { continue } diff --git a/src/main/acp/acp-structured-handshake.ts b/src/main/acp/acp-structured-handshake.ts index 2679ba2f68fc..22555f06d1ef 100644 --- a/src/main/acp/acp-structured-handshake.ts +++ b/src/main/acp/acp-structured-handshake.ts @@ -17,7 +17,7 @@ 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 { AcpStructuredOptions } from './acp-structured-options' import type { AcpStructuredPrompts } from './acp-structured-prompts' import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' import { AcpStructuredTurns, type AcpStructuredTurnsDeps } from './acp-structured-turns' @@ -103,8 +103,7 @@ export async function initializeAcpStructuredSession( 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 restoreSkipped: string[] = [] const link: AgentSessionProviderHandleLink = { linkId: deps.mintLinkId?.() ?? `${spec.agent}-${acquire.fence}-${started.sessionId}`.slice(0, 128), @@ -158,7 +157,6 @@ export async function initializeAcpStructuredSession( 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-options.test.ts b/src/main/acp/acp-structured-options.test.ts index b348d0fdba92..917c9bf690c4 100644 --- a/src/main/acp/acp-structured-options.test.ts +++ b/src/main/acp/acp-structured-options.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' -import { AcpStructuredOptions, restoreAcpSessionOptions } from './acp-structured-options' +import { AcpStructuredOptions } from './acp-structured-options' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' const configOptions = [ @@ -121,11 +121,6 @@ describe('ACP session options', () => { }) describe('what a new session with no pick says about the configured default', () => { - const connection = { - setConfigOption: async () => ({ configOptions }), - setModel: async () => ({}) - } - it('is the model and effort the agent chose for itself', () => { const options = new AcpStructuredOptions() options.adoptSession({ configOptions }, 'new') @@ -138,17 +133,17 @@ describe('what a new session with no pick says about the configured default', () expect(options.configuredDefault()).toBeUndefined() }) - it('says nothing once the chat holds a model pick, even one the session already runs', async () => { + it('says nothing once the chat holds a model pick, even one the session already runs', () => { const options = new AcpStructuredOptions() options.adoptSession({ configOptions }, 'new') - await restoreAcpSessionOptions(connection, options, { model: 'model-b' }) + options.notePick('model') expect(options.configuredDefault()).toBeUndefined() }) - it('keeps the model but not an effort the chat picked', async () => { + it('keeps the model but not an effort the chat picked', () => { const options = new AcpStructuredOptions() options.adoptSession({ configOptions }, 'new') - await restoreAcpSessionOptions(connection, options, { effort: 'low' }) + options.notePick('effort') expect(options.configuredDefault()).toEqual({ modelId: 'model-b' }) }) diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index ad17dd78cf4d..662feed95f66 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -8,8 +8,6 @@ import type { AgentSessionOptionsResult, AgentSessionSlashCommand } from '../../shared/agent-session-wire' -import { isAcpStructuredOptionKey } from './acp-structured-agent-definitions' -import { AcpRpcError } from './acp-errors' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpLaunchSpec } from './acp-launch-specs' import type { AcpDialect } from './acp-dialects/acp-dialect' @@ -64,6 +62,7 @@ export class AcpStructuredOptions { // A loaded session keeps the model it ran; only a new one resolves the agent's own config. private resolvesConfig = false private readonly picked = new Set() + private initialChoice: Readonly> = {} /** The session state a new, load or resume response reported. */ adoptSession( @@ -76,6 +75,12 @@ export class AcpStructuredOptions { this.configOptions = response.configOptions ?? [] this.models = response.models ?? null this.resolvesConfig = origin === 'new' + this.initialChoice = this.reported() + } + + /** The native selection before any message changed this child. */ + messageDefault(): Readonly> { + return this.initialChoice } /** Orca sent `key` to the session, or the chat's saved options hold a value for it. */ @@ -267,43 +272,3 @@ export function writeAcpSessionOption( }, fail) }) } - -/** Re-applies the chat's saved picks to the agent's new session; a pick it refuses is skipped and - * reported, never retried. */ -export async function restoreAcpSessionOptions( - connection: Pick, - options: AcpStructuredOptions, - saved: Readonly> | undefined -): Promise { - const skipped: string[] = [] - // 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) - if (options.reported()[key] === value) { - continue - } - const write = options.write(key, value) - try { - if (write?.method === 'config') { - options.adoptConfigOptions( - (await connection.setConfigOption(write.configId, value)).configOptions - ) - } else if (write?.method === 'model') { - await connection.setModel(write.modelId) - options.adoptModel(write.modelId) - } else { - skipped.push(key) - } - } catch (error) { - if (!(error instanceof AcpRpcError)) { - throw error - } - skipped.push(key) - } - } - return skipped -} diff --git a/src/main/acp/acp-structured-publish-at-spawn.test.ts b/src/main/acp/acp-structured-publish-at-spawn.test.ts index 2205b4dfe3fd..a97d1cb0fb7e 100644 --- a/src/main/acp/acp-structured-publish-at-spawn.test.ts +++ b/src/main/acp/acp-structured-publish-at-spawn.test.ts @@ -16,7 +16,7 @@ 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 () => { + it('opens without writing saved message choices to the provider', async () => { let answer: (() => void) | undefined const writes: string[] = [] let configOptions = GROK_CONFIG_OPTIONS @@ -59,11 +59,11 @@ 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' }, + reportedOptions: { model: 'grok-4.7', effort: 'high' }, restoreSkippedOptions: [], optionRevision: 7 }) - expect(writes).toEqual(['model', 'reasoning_effort']) + expect(writes).toEqual([]) rig.child().agent.notify('session/update', { sessionId: PROVIDER_SESSION, update: { sessionUpdate: 'config_option_update', configOptions: GROK_CONFIG_OPTIONS } @@ -158,7 +158,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('waits until message input to try a saved choice the provider will refuse', async () => { const rig = await openAcpAdapterRig({ script: (agent) => agent.on('session/set_config_option', (frame) => @@ -167,9 +167,21 @@ describe('ACP child published before its handshake', () => { }) await rig.acquire({ options: { model: 'retired-model' } }) expect(rig.started()).toMatchObject({ - restoreSkippedOptions: ['model'], + restoreSkippedOptions: [], reportedOptions: { model: 'grok-4.7', effort: 'high' } }) + expect(rig.sent('session/set_config_option')).toEqual([]) + await expect( + rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'refused-choice', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + executionOptions: { model: 'retired-model' } + }) + ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) + expect(rig.sent('session/set_config_option')).toHaveLength(1) + expect(rig.sent('session/prompt')).toEqual([]) expect(rig.child().exited).toBe(false) }) diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 47718f02e0d7..2707cbc93447 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -180,6 +180,31 @@ describe('ACP structured session adapter: turns', () => { }) }) + it('restores the opened model and effort when the next message omits both choices', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'override', + body: hello, + executionOptions: { model: 'grok-4.6', effort: 'low' } + }) + await rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'default', + body: hello, + executionOptions: {} + }) + expect(rig.sent('session/set_config_option')).toMatchObject([ + { params: { configId: 'model', value: 'grok-4.6' } }, + { params: { configId: 'reasoning_effort', value: 'low' } }, + { params: { configId: 'model', value: 'grok-4.7' } }, + { params: { configId: 'reasoning_effort', value: 'high' } } + ]) + }) + it('does not send a prompt when the agent refuses a message’s model', async () => { const rig = await openAcpAdapterRig({ script: (agent) => diff --git a/src/main/claude/claude-structured-message-options.ts b/src/main/claude/claude-structured-message-options.ts index 849026a3f328..c33bd2e5e92b 100644 --- a/src/main/claude/claude-structured-message-options.ts +++ b/src/main/claude/claude-structured-message-options.ts @@ -38,7 +38,8 @@ export async function applyClaudeMessageOptions( const fastMode = options.fastMode ?? 'false' if ( - session.options.get('fastMode') !== fastMode && + (session.options.get('fastMode') !== fastMode || + (session.fastModeAtStart && !session.confirmedOptions.has('fastMode'))) && (options.fastMode || session.options.has('fastMode')) ) { await setClaudeStructuredOption(session, { key: 'fastMode', value: fastMode }, timeoutMs) diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index c3f2ffbb0700..ffbd756b7930 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -3,7 +3,8 @@ import { adapterAtPublishFor, fakeClaude, identityFor, - claudeOpened + claudeOpened, + USER_MESSAGE } from './claude-structured-session-test-support' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' @@ -61,6 +62,47 @@ describe('Claude host-awaited open', () => { await adapter.closeAll() }) + it('applies a Fast choice at message input when launch settings cannot carry it', async () => { + let fastMode = false + const claude = fakeClaude({ + initModels: [{ value: 'claude-sonnet-5', displayName: 'Sonnet', supportsFastMode: true }], + routes: { + apply_flag_settings: () => { + fastMode = true + } + } + }) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterAtPublishFor( + claude, + { options: { extraArgs: { settings: '/repo/claude.json' } } }, + events + ) + await adapter.acquire({ ...ACQUIRE, options: { fastMode: 'true' } }) + await claudeOpened(adapter, 'session-1') + await vi.waitFor(() => + expect(events.some((event) => event.type === 'options-reported')).toBe(true) + ) + const connection = claude.connections[0]! + expect(connection.calls.filter((call) => call.subtype === 'apply_flag_settings')).toEqual([]) + connection.getSettings = async () => ({ effective: { fastMode } }) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'fast-choice', + body: USER_MESSAGE, + executionOptions: { fastMode: 'true' }, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + expect(connection.calls.filter((call) => call.subtype === 'apply_flag_settings')).toEqual([ + { subtype: 'apply_flag_settings', params: { settings: { effortLevel: 'high' } } }, + { subtype: 'apply_flag_settings', params: { settings: { fastMode: true } } } + ]) + expect(connection.sent).toHaveLength(1) + await adapter.closeAll() + }) + it('rejects an advertised signed-out initialize answer for the host to close once', async () => { const claude = fakeClaude({ initAccount: { tokenSource: 'none', apiKeySource: 'none' } }) const { adapter } = adapterFor(claude) diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index 19e12c9804f8..9131138ad40c 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -32,10 +32,6 @@ import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import { - admitClaudeStartFastMode, - applyClaudeStartFastMode -} from './claude-structured-start-fast-mode' export type ClaudeOpenFacts = Pick< StructuredAgentSessionProviderOpenResult, @@ -267,7 +263,6 @@ async function settleClaudeStartupSettings( input.diagnose(claudeAuthDiagnostic(facts.initialization, facts.init, settings)) const writtenSinceRead = sequence !== session.optionMutationSequence applyClaudeSettingsFacts(session, facts, writtenSinceRead) - const startFastMode = writtenSinceRead ? null : admitClaudeStartFastMode(session, facts) input.report({ type: 'options-reported', // Open already returned the initialize listing to the host. @@ -279,11 +274,4 @@ async function settleClaudeStartupSettings( ...claudeRetiredOptions(session), optionRevision }) - if (startFastMode !== null) { - void applyClaudeStartFastMode(session, facts, startFastMode, (event) => { - if (input.isCurrent()) { - input.report(event) - } - }) - } } diff --git a/src/main/claude/claude-structured-spawn-options.ts b/src/main/claude/claude-structured-spawn-options.ts index b86b4ebfba50..fe28be8f83c7 100644 --- a/src/main/claude/claude-structured-spawn-options.ts +++ b/src/main/claude/claude-structured-spawn-options.ts @@ -80,9 +80,8 @@ export function claudeStructuredSpawnOptions(input: { skipped.push('fastMode') } else { options.set('fastMode', fastMode) - // A new CLI session may opt in to Fast per session, which only its settings say, and the - // agent Arguments' own `--settings` would be replaced by one carrying Fast: either way the - // start applies the saved Fast once it has read them (`applyClaudeStartFastMode`). + // A new CLI session may opt in to Fast per session; the first message applies a saved Fast + // when launch settings cannot carry it. if ( (!decoded || input.launch.resumesTranscript) && !Object.hasOwn(sdkOptions.extraArgs ?? {}, 'settings') diff --git a/src/main/claude/claude-structured-start-fast-mode.ts b/src/main/claude/claude-structured-start-fast-mode.ts deleted file mode 100644 index 66cb2df04cb9..000000000000 --- a/src/main/claude/claude-structured-start-fast-mode.ts +++ /dev/null @@ -1,72 +0,0 @@ -// A saved Fast the Claude child was launched without, applied once its start has read the settings -// that decide whether it may run. - -import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' -import { readClaudeModels } from './claude-structured-init-proof' -import { listedModels } from './claude-structured-model-catalog' -import { claudeModelFastModeSupport } from './claude-structured-session-options' -import type { ClaudeStartupFacts, ClaudeStartupReport } from './claude-structured-session-startup' -import type { ClaudeSession } from './claude-structured-session-state' - -/** A saved Fast the launch left out (`fastModeAtStart`): on for a new conversation, whose settings - * may opt in to it per session, or either value beside the agent Arguments' own `--settings`. - * Those settings now read: a new conversation's opt-in drops it, as before, and so do the guards a - * live Fast write takes. The value still to be applied, or null. */ -export function admitClaudeStartFastMode( - session: ClaudeSession, - facts: ClaudeStartupFacts -): string | null { - const fastMode = session.options.get('fastMode') - if (!session.fastModeAtStart || fastMode === undefined) { - return null - } - if (fastMode !== 'true') { - return fastMode - } - if (!facts.resumesTranscript && facts.prepared.fastModePerSessionOptIn === true) { - session.options.delete('fastMode') - return null - } - // Over the listing this start already holds. - const listed = listedModels({ models: readClaudeModels(facts.initialization) }) - const blocked = - session.fastModeDisabledReason !== undefined && - !['preference', 'sdk_opt_in_required'].includes(session.fastModeDisabledReason) - if ( - blocked || - (listed.length > 0 && claudeModelFastModeSupport(session, listed).supported !== true) - ) { - session.options.delete('fastMode') - session.restoreSkippedOptions.add('fastMode') - return null - } - return fastMode -} - -/** Applies a saved Fast the launch left out after open, so nothing waits on it. A refusal - * drops 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( - session: ClaudeSession, - facts: ClaudeStartupFacts, - fastMode: string, - report: (event: ClaudeStartupReport) => void -): Promise { - const sequence = session.optionMutationSequence - try { - await session.connection.applyFlagSettings( - { fastMode: fastMode === 'true' }, - { timeoutMs: facts.requestTimeoutMs } - ) - } catch (error) { - if (sequence !== session.optionMutationSequence) { - return - } - session.confirmedOptions.delete('fastMode') - if (error instanceof ClaudeControlRequestError) { - session.options.delete('fastMode') - session.restoreSkippedOptions.add('fastMode') - report({ type: 'options-skipped', options: { fastMode } }) - } - } -} 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..774f87be548d 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 @@ -288,7 +288,7 @@ describe('structured session acquisition options', () => { expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5.6-sol', effort: 'medium' }) }) - it('persists provider options before proving a resumed legacy record', async () => { + it('preserves saved next-message choices when a provider reports its live options', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-')) const store = await openTestAgentSessionRecordStore(root) @@ -355,13 +355,11 @@ describe('structured session acquisition options', () => { expect(reopened.getRecord(SESSION)?.options).toEqual({ approvalPolicy: 'on-request', personality: 'concise', - model: 'gpt-5.6-terra', - effort: 'medium', - fastMode: 'false' + fastMode: 'true' }) }) - it('clears a rejected Fast restore instead of retaining the prior encoded value', async () => { + it('keeps a saved Fast choice when the provider skips restoring it', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-fast-restore-')) const store = await openTestAgentSessionRecordStore(root) const sessionAdapter = adapter({ @@ -393,10 +391,10 @@ describe('structured session acquisition options', () => { }) expect(created).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-standard' }) + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-standard', fastMode: 'true' }) }) - it('proves an opened Codex owner from effective options without reading its catalog', async () => { + it('proves an opened Codex owner without replacing saved choices from readback', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-known-options-')) const store = await openTestAgentSessionRecordStore(root) const sessionAdapter: StructuredAgentSessionAdapter = { @@ -428,7 +426,8 @@ describe('structured session acquisition options', () => { expect(created).toMatchObject({ ok: true }) expect(sessionAdapter.readOptions).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-reported', effort: 'high' }) + expect(sessionAdapter.readAcquisitionOptions).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-saved', effort: 'medium' }) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') }) @@ -468,51 +467,8 @@ describe('structured session acquisition options', () => { expect(sessionAdapter.readOptions).not.toHaveBeenCalled() }) - it('releases an acquisition when provider options cannot be read', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-failure-')) - const store = await openTestAgentSessionRecordStore(root) - const releaseAcquisition = vi.fn(async () => true) - const failingAdapter: StructuredAgentSessionAdapter = { - ...adapter({ origin: 'created' }), - readOptions: vi.fn(async () => { - throw new Error('model list unavailable') - }), - releaseAcquisition - } - - await expect( - performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: failingAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - optionRevision: () => 0, - params: attachParams(CREATE_OPERATION, null), - now: () => NOW, - onAttached: () => {} - }) - ).resolves.toEqual({ - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: "Codex couldn't restart. Send your message to try again." - } - }) - expect(releaseAcquisition).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.ownerProcess).toBeNull() - }) - describe.each([ ['adapter acquire', 'acquire'], - ['options read', 'options'], ['identity commit', 'identity'], ['owner proof', 'proof'], ['journal attach', 'journal'] @@ -531,11 +487,8 @@ describe('structured session acquisition options', () => { }) const injected = new Error(`${failurePoint} failed`) const acquire = vi.mocked(base.acquire) - const readOptions = vi.mocked(base.readOptions!) if (failurePoint === 'acquire') { acquire.mockRejectedValueOnce(injected) - } else if (failurePoint === 'options') { - readOptions.mockRejectedValueOnce(injected) } else if (failurePoint === 'identity') { vi.spyOn(store, 'commitProcessIdentity').mockRejectedValueOnce(injected) } else if (failurePoint === 'proof') { @@ -563,7 +516,7 @@ describe('structured session acquisition options', () => { } return cleanup }) - const failingAdapter = { ...base, acquire, readOptions, releaseAcquisition } + const failingAdapter = { ...base, acquire, releaseAcquisition } let journalFailure = failurePoint === 'journal' ? injected : null const perform = ( target: AgentSessionRecordStore, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index 71e85ff801f9..168d41831162 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -1,5 +1,4 @@ import { isDeepStrictEqual } from 'node:util' -import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import type { AgentSessionProcessIdentity, AgentSessionRecord @@ -12,8 +11,6 @@ import { import { rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-provider-exit-proof' import { journalIdentityFor } from './structured-agent-session-attach' import type { AttachFlowInput } from './structured-agent-session-attach-flow' -import { readNativeSessionOptions } from './structured-agent-session-option-restoration' -import { withAgentSessionCreatePhase } from '../../observability/agent-session-instrumentation' import { mintStructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt' import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' @@ -77,7 +74,8 @@ export async function acquireOwner( ...(permissionMode ? { permissionMode } : {}), ...(input.eventSink ? { events: input.eventSink } : {}), ...(input.acquireSignal ? { signal: input.acquireSignal } : {}), - optionRevision: input.optionRevision + optionRevision: input.optionRevision, + ...(input.startupOptions ? { startupOptions: input.startupOptions } : {}) }) const progress = input.onStartupAttempt?.(attempt) const acquired = await input.adapter.acquire({ @@ -95,27 +93,6 @@ export async function acquireOwner( } }) const providerChildPhase = acquired.providerChildPhase ?? 'ready' - // A starting child has proven nothing: the record keeps the reservation's saved options as - // intent, never a catalog guess, and the `started` event persists what the child reports. - const options = - providerChildPhase === 'starting' - ? undefined - : await withAgentSessionCreatePhase('restore_options', input.recordPhase, async () => { - const read = { - sessionId: record.sessionId, - fence, - ...(record.options ? { priorOptions: record.options } : {}) - } - // Still inside the start, so the limit or a Stop ends a read the provider never answers. - return waitForPromiseWithSignal( - Promise.resolve( - input.adapter.readAcquisitionOptions - ? input.adapter.readAcquisitionOptions(read) - : readNativeSessionOptions({ adapter: input.adapter, ...read }) - ), - input.acquireSignal - ) - }) if (record.lease.ownerProcess === null) { await input.store.commitProcessIdentity({ sessionId: record.sessionId, @@ -130,8 +107,7 @@ export async function acquireOwner( sessionId: record.sessionId, fence, ...(acquired.link ? { link: acquired.link } : {}), - now: input.now(), - ...(options ? { options } : {}) + now: input.now() }) if (providerChildPhase === 'ready' && acquired.catalogListing) { handOverStartCatalogListing(input, record.sessionId, acquired.catalogListing) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index 2c180c121b33..71745937eb19 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -171,9 +171,16 @@ async function startStructuredAgentSessionAgent( let acquisitionError: unknown let aborted = false let expired = false + const startupOptions = startedFor + ? context.sessions + .get(sessionId) + ?.journal.submissions() + .find((submission) => submission.clientMessageId === startedFor)?.executionOptions + : undefined try { attached = await attachStructuredAgentSessionUnderSerialize(context, callerKey, params, { ...(startedFor === undefined ? {} : { startedFor }), + ...(startupOptions ? { startupOptions } : {}), onAcquisitionFailed: (error) => { acquisitionError = error }, 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 75f238b7dd5b..63f6aba1953e 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 @@ -78,6 +78,7 @@ export type AttachFlowInput = { onStartCatalogListing?: (listing: AgentModelCatalogLiveListing) => void /** The conversation's option revision, which the child's reports are stamped with. */ optionRevision: () => number + startupOptions?: Readonly> /** 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 348d7f49321b..71f50ed697b0 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 { @@ -53,6 +49,7 @@ export type StructuredAgentSessionAttachOptions = { onAcquisitionFailed?: AttachFlowInput['onAcquisitionFailed'] /** The queued message a start is for; see `StructuredAgentSessionProviderChild.startedFor`. */ startedFor?: string + startupOptions?: Readonly> /** A close, an admitted Stop or quit aborted this attach: its refusal is that abort's. */ onAborted?: () => void /** The startup limit aborted this attach's acquire: it failed, nobody stopped it. */ @@ -210,6 +207,7 @@ async function runAttachUnderAbort( return startupAttempts.track(sessionId, startup) }, optionRevision: () => context.runtimeState.optionRevisions.current(sessionId), + ...(options.startupOptions ? { startupOptions: options.startupOptions } : {}), launchPermissionMode: async (record) => (await structuredAgentSessionLaunchPermission(context.deps, record)) ?? undefined, ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), @@ -260,7 +258,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 }) } } @@ -311,15 +308,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-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index 9989ef6386f0..294faaa86d0d 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 @@ -230,9 +230,7 @@ describe('a Stop that names no turn', () => { const followUp = send('follow-up') await followUp.result - await eventually(() => - expect(codex.connections[0].calls.some((call) => call.method === 'turn/steer')).toBe(true) - ) + expect(codex.connections[0].calls.some((call) => call.method === 'turn/steer')).toBe(false) expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) codex.connections[0].handlers.onNotification?.('turn/completed', { threadId: THREAD_ID, 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 cb0cac03d7c8..e695ca8af89b 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 @@ -28,6 +28,7 @@ import { } from './structured-agent-session-mutation-context' import { openForProviderWrite, + openForWrite, sendPreparation, structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' @@ -48,7 +49,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 { @@ -187,44 +187,21 @@ 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 - }, + conversationWrite: true, 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. + if (params.key !== AGENT_CHAT_PERMISSION_MODE_OPTION_ID) { context.optionRevisions.advance(params.envelope.sessionId) } + return 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-message-choices.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts index 1c1a1148b364..d5f0e18bfa80 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts @@ -1,10 +1,16 @@ import { afterEach, beforeEach, expect, it } from 'vitest' +import { providerDiagnostic, withProviderDiagnostic } from '../../../shared/agent-session-failure' import { createQueuedMessageTestRig, eventually, + QUEUED_RIG_CALLER, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' -import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { + HOST_TEST_SESSION as SESSION, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' let rig: QueuedMessageTestRig @@ -14,6 +20,68 @@ beforeEach(async () => { afterEach(() => rig.dispose()) +async function pickModel(model: string): Promise { + const fields = { key: 'model', value: model } + const result = await rig.host.setOption(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.setOption', hostTestOperationId()), + ...fields + }) + expect(result).toMatchObject({ ok: true }) +} + +it('keeps the first accepted choice through opening and saves a later pick for the next send', async () => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) + const first = rig.send('first', undefined, { executionOptions: { model: 'model-a' } }) + expect(await first.result).toMatchObject({ ok: true }) + await pickModel('model-b') + const second = rig.send('second') + expect(await second.result).toMatchObject({ ok: true }) + expect(rig.setOption).not.toHaveBeenCalled() + await rig.proveStart() + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(1)) + expect(rig.dispatch.mock.calls[0]?.[0].executionOptions).toEqual({ model: 'model-a' }) + await rig.settleAccepted(first.id, 'a') + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) + expect(rig.dispatch.mock.calls[1]?.[0].executionOptions).toEqual({ model: 'model-b' }) + expect(rig.store.getRecord(SESSION)?.options?.model).toBe('model-b') +}) + +it('queues a different choice during a running Codex turn', async () => { + await pickModel('model-a') + const first = await rig.workingSend() + await pickModel('model-b') + const second = rig.send('second') + expect(await second.result).toMatchObject({ ok: true }) + expect(rig.dispatch).toHaveBeenCalledTimes(1) + await rig.settleAccepted(first, 'a') + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) + expect(rig.dispatch.mock.calls.map(([input]) => input.executionOptions?.model)).toEqual([ + 'model-a', + 'model-b' + ]) +}) + +it('rejects a refused choice before prompt input and leaves the next pick saved', async () => { + await pickModel('model-b') + rig.dispatch.mockRejectedValueOnce( + withProviderDiagnostic( + new AgentSessionOptionRejectedError('model-a is unavailable'), + providerDiagnostic('model-a is unavailable', 'person') + ) + ) + const first = rig.send('first', undefined, { executionOptions: { model: 'model-a' } }) + expect(await first.result).toMatchObject({ ok: true }) + await eventually(async () => + expect(await rig.submission(first.id)).toMatchObject({ + dispatchState: 'rejected', + reason: expect.stringContaining('model-a is unavailable') + }) + ) + expect(rig.dispatch).toHaveBeenCalledOnce() + expect(rig.store.getRecord(SESSION)?.options?.model).toBe('model-b') +}) + it('uses each send’s captured choices when two messages arrive during startup', async () => { const releaseStart = rig.holdNextStart() const first = rig.send('first', undefined, { 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 097ef7121823..f09d37fd5bc8 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 @@ -36,7 +36,6 @@ import { performCancel, performPrompt, performSend, - performSetOption, type AgentSessionTurnContext, type TurnOutcome } from './structured-agent-session-turns' @@ -344,7 +343,12 @@ export function setOptionPlan(params: { return { method: 'agentSession.setOption', fields: { key: params.key, value: params.value }, - run: (ctx) => performSetOption(ctx, params), + run: async (ctx) => { + const options = { ...ctx.persistedOptions, [params.key]: params.value } + await ctx.persistOptions(options) + ctx.publish() + return { ok: true, value: { ...params, options } } + }, // Reapply unsettled picks; only a settled success can be replayed directly. replay: (ctx, outcome) => outcome.status === 'succeeded' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 8eba7f5bb605..a25de059ee78 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,10 @@ describe('structured session options and close', () => { expect(events).toEqual([ { type: 'snapshot', - sessions: [expect.objectContaining({ status: 'idle', model: DEFAULT_MODEL })] + sessions: [expect.objectContaining({ status: 'idle', optionSelection: {} })] } ]) - 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(store.getRecord(SESSION)?.options).toBeUndefined() }) // Closing a chat used to leave its provider child resident for the whole app session: the host's @@ -234,7 +195,7 @@ describe('structured session options and close', () => { await host.close(SESSION, 'evict') - expect(closeNativeSession).toHaveBeenCalledWith(SESSION) + expect(closeNativeSession).toHaveBeenCalledWith(SESSION, 'evict') expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null, 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..517142195388 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 @@ -164,6 +164,7 @@ describe('live Codex option reads', () => { it('leaves the session lane free while a cold listing waits, then applies it under the child fence', async () => { const listing = Promise.withResolvers() const queue = new StructuredAgentSessionTaskQueue() + const record = { ...restingRecord(), options: { model: 'gpt-picked', effort: 'high' } } const child = { fence: 7 } const apply = vi.fn(() => ({ models: [{ id: 'gpt-live', label: 'GPT Live', efforts: [] }], @@ -183,7 +184,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), @@ -191,7 +192,9 @@ describe('live Codex option reads', () => { conversation: async () => live } as unknown as StructuredAgentSessionMutationContext - const reading = readStructuredAgentSessionOptions(context, SESSION, { readsWithoutModel: true }) + const reading = readStructuredAgentSessionOptions(context, SESSION, { + readsWithoutModel: false + }) await vi.waitFor(() => expect(prepareReadOptions).toHaveBeenCalledWith({ sessionId: SESSION, fence: 7 }) ) @@ -200,7 +203,10 @@ describe('live Codex option reads', () => { ) expect(apply).not.toHaveBeenCalled() listing.resolve() - await expect(reading).resolves.toMatchObject({ current: { model: 'gpt-live' } }) + await expect(reading).resolves.toMatchObject({ + models: [{ id: 'gpt-live' }], + current: { model: 'gpt-picked', effort: 'high' } + }) 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 b742ab045199..b2293f125ba1 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 @@ -240,9 +240,32 @@ export async function readStructuredAgentSessionOptions( const permissionMode = record ? await structuredAgentSessionLaunchPermission(context.deps, record) : null + const selected = live + ? await readStructuredAgentSessionOptionsAtRest(context.deps, sessionId, reader) + : options + const confirmed = options.current.confirmed?.filter((key) => { + if (key === 'model') { + return selected.current.model === options.current.model + } + if (key === 'effort') { + return selected.current.effort === options.current.effort + } + if (key === 'fastMode') { + return selected.current.fastMode === options.current.fastMode + } + if (key === 'serviceTier') { + return selected.current.serviceTier === options.current.serviceTier + } + return false + }) return { ...options, - ...(permissionMode ? { current: { ...options.current, permissionMode } } : {}), + current: { + ...selected.current, + ...(options.current.fastModeState ? { fastModeState: options.current.fastModeState } : {}), + ...(confirmed?.length ? { confirmed } : {}), + ...(permissionMode ? { permissionMode } : {}) + }, rewind: phase === 'prepared' || phase === 'provider-succeeded' ? { supported: false, reason: 'outcome-unknown' } 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..ab6a5ae44f74 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 @@ -3,20 +3,15 @@ // 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 +// needs it), 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`). +// Provider reports enrich the catalog and live facts; they never replace saved picks. // // 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. +// only provider call here is rewind recovery, bounded by the startup limit. -import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' import type { StructuredAgentSessionOptionsReportedEvent, StructuredAgentSessionOptionsSkippedEvent, @@ -25,16 +20,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 +34,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 +45,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 +53,12 @@ export type StructuredAgentSessionProviderStartedContext = { stopStartingChild: (sessionId: string, child: StructuredAgentSessionProviderChildIdentity) => void } -type ReportedOptions = Omit - export async function settleStructuredAgentSessionProviderStarted( context: StructuredAgentSessionProviderStartedContext, event: StructuredAgentSessionStartedEvent ): 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 () => { 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 +75,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))) { 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 +87,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: @@ -240,116 +180,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-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-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.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts index 8e19074321bf..b2c170e5a074 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts @@ -1,14 +1,4 @@ -// One start of a conversation's provider child, minted by the host before any adapter acquires. -// -// Whatever a start means as policy belongs to the host and is the same for every agent: which -// conversation and lease it is for, what cancels it, when it gives up, and when its child may take -// input. An adapter only runs its protocol inside the attempt it is handed. -// -// The clock starts when the process exists, never before (resolving a launch is not starting it). -// A start that says nothing for a minute is wedged; one still talking is given up only at the -// ceiling, so a slow migration or history replay is not killed mid-way while every start still -// ends. Expiry re-derives what to end from the host's own state, so a timer that outlives its -// attempt ends nothing. +// The host owns start policy; expiry rechecks its current child before ending a stale attempt. import { randomUUID } from 'node:crypto' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' @@ -31,8 +21,10 @@ export function mintStructuredAgentSessionStartupAttempt(input: { events?: StructuredAgentSessionEventSink signal?: AbortSignal optionRevision: () => number + startupOptions?: Readonly> }): StructuredAgentSessionStartupAttempt { const { record } = input + const options = input.startupOptions ?? record.options return { attemptId: randomUUID(), identity: input.identity, @@ -43,7 +35,7 @@ export function mintStructuredAgentSessionStartupAttempt(input: { accountHome: record.accountHome, ...(record.launchDirectory === undefined ? {} : { launchDirectory: record.launchDirectory }) }, - ...(record.options ? { options: record.options } : {}), + ...(options ? { options } : {}), ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}), ...(input.events ? { events: input.events } : {}), ...(input.signal ? { signal: input.signal } : {}), @@ -317,14 +309,13 @@ export class StructuredAgentSessionStartupAttempts { tracked.expiry = this.measure(tracked, reason) } tracked.expired = true - const { child } = tracked // A published child's stop is under way and ends nothing a newer attempt owns; one still // acquiring stays tracked until its aborted acquire is abandoned or published. - if (child) { + if (tracked.child) { this.report(tracked.expiry) this.end(sessionId) } - this.deps.expire({ sessionId, attemptId: tracked.attempt.attemptId, child }) + this.deps.expire({ sessionId, attemptId: tracked.attempt.attemptId, child: tracked.child }) } private settle(sessionId: string, tracked: Tracked, outcome: 'ready' | 'ended'): void { 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 index bd8f9aeb6412..81d90583e9ee 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 @@ -51,11 +51,7 @@ it('keeps a permission pick while accepting the startup model report', async () optionRevision: 0 }) await vi.waitFor(() => - expect(rig.store.getRecord(SESSION)?.options).toEqual({ - model: 'provider-model', - effort: 'medium', - permissionMode: 'full-access' - }) + expect(rig.store.getRecord(SESSION)?.options).toEqual({ permissionMode: 'full-access' }) ) } finally { await rig.dispose() @@ -122,15 +118,17 @@ it.each([false, true])( }) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) expect(writes.mock.calls.map(([input]) => [input.key, input.value])).toEqual([ - ['model', 'picked-model'], - ['effort', 'high'] + ['model', 'launched-model'], + ['effort', 'low'] ]) 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 } + hasOpenDispatch + ? { permissionMode: 'ask' } + : { permissionMode: 'full-access', options: { model: 'launched-model', effort: 'low' } } ) ) expect(rig.adapter.acquire.mock.invocationCallOrder[hasOpenDispatch ? 0 : 1]).toBeLessThan( @@ -144,7 +142,10 @@ it.each([false, true])( 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 }) + expect.objectContaining({ + permissionMode: 'full-access', + options: { model: 'picked-model', effort: 'high' } + }) ) } 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 index 1b3b2e1a5beb..e922234f1fdc 100644 --- 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 @@ -1,6 +1,4 @@ -// 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. +// A pick made during startup belongs to the next message; the held message keeps its choice. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -156,48 +154,35 @@ function started(reportedModel = 'launched-model'): Promise { }) } -/** 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 () => { +it('saves a startup pick without writing it to the opening child', async () => { await pick('picked-model') expect(store.getRecord(SESSION)?.options?.model).toBe('picked-model') - setOption.mockRejectedValueOnce(new Error('model not available')) + expect(setOption).not.toHaveBeenCalled() await started() - expect(setOption).toHaveBeenCalledWith(expect.objectContaining({ value: 'picked-model' })) - expect(store.getRecord(SESSION)?.options?.model).toBe('launched-model') + expect(setOption).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.options?.model).toBe('picked-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 () => { +it('hands a held message over with its accepted choice after a later startup pick', async () => { const held = await sendHeld('held while starting') await pick('picked-model') - writeHeldUntilAborted() + expect(setOption).not.toHaveBeenCalled() - const proving = started() - await vi.waitFor(() => expect(setOption).toHaveBeenCalledOnce()) - await vi.advanceTimersByTimeAsync(SILENCE_MS) - await proving + await started() - await vi.waitFor(async () => expect(await dispatchState(held)).toBe('rejected')) - expect(closeSession).toHaveBeenCalled() - expect(phases).not.toContain('ready') - expect(dispatch).not.toHaveBeenCalled() + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(setOption).not.toHaveBeenCalled() + expect(dispatch.mock.calls[0]?.[0].executionOptions).toEqual({ model: 'launched-model' }) + expect(await dispatchState(held)).toBe('unknown') expect(store.getRecord(SESSION)?.options?.model).toBe('picked-model') }) @@ -219,19 +204,3 @@ it('never accepts a start whose limit passed before its proof was settled', asyn 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-turns-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts deleted file mode 100644 index ffee03165f3f..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-options.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { refuse, type AgentSessionOptionResult } from '../../../shared/agent-session-wire' -import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' -import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' - -export async function performSetOption( - ctx: AgentSessionTurnContext, - input: { key: string; value: string }, - signal?: AbortSignal -): Promise> { - let applied: void | Readonly> - try { - applied = await ctx.adapter.setOption({ - sessionId: ctx.sessionId, - ...input, - fence: ctx.fence, - ...(signal ? { signal } : {}) - }) - } catch (error) { - if (isAgentSessionOptionRejectedError(error)) { - return { - ok: false, - refusal: refuse( - 'agent_session_operation_invalid', - { reason: error.refusalReason }, - error.message - ) - } - } - throw error - } - await ctx.persistOptions(applied ?? { [input.key]: input.value }) - ctx.publish() - return { ok: true, value: { ...input, ...(applied ? { options: { ...applied } } : {}) } } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index d5ca5b187ca9..8fab9ed810db 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 @@ -51,7 +51,6 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo import { isAgentSessionAttachmentExpiredError } from '../agent-session-attachments/agent-session-attachment-claims' import { CommandReceiptExistsError } from '../agent-session-journal/command-receipt-transaction' import type { StructuredAgentRegistry } from './structured-agent-registry' -export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' export { performCancel } from './structured-agent-session-turns-cancel' diff --git a/src/main/pi/rpc-message-dispatch.ts b/src/main/pi/rpc-message-dispatch.ts index 1c35cc0b72ce..14aa093c0520 100644 --- a/src/main/pi/rpc-message-dispatch.ts +++ b/src/main/pi/rpc-message-dispatch.ts @@ -52,9 +52,18 @@ export async function dispatchPiRpcMessage( ) } } + const model = input.executionOptions?.model ?? session.initialOptions.model for (const key of ['model', 'effort'] as const) { - const value = input.executionOptions?.[key] - if (value === undefined) { + const value = + input.executionOptions?.[key] ?? + (key === 'model' || model === session.initialOptions.model + ? session.initialOptions[key] + : undefined) + if ( + value === undefined || + session.selected.get(key) === value || + (!session.selected.has(key) && session.initialOptions[key] === value) + ) { continue } try { diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 8cd553e870a8..3825749522ba 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -172,7 +172,8 @@ describe('Pi RPC session ownership and delivery', () => { origin: 'created', mintedAtFence: 7 }) - expect(h.started.restoreSkippedOptions).toEqual(['unknown', 'effort']) + expect(h.started.restoreSkippedOptions).toEqual([]) + expect(h.connection.requests).not.toContain('set_thinking_level') expect(h.connection.requests).toContain('get_commands') }) @@ -215,6 +216,57 @@ describe('Pi RPC session ownership and delivery', () => { expect(h.connection.sent).toMatchObject([{ type: 'prompt', message: 'chosen' }]) }) + it('restores the opened model when a later snapshot omits its override', async () => { + const h = await setup() + await h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'override', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'first' }] }, + executionOptions: { model: 'anthropic/model-2' } + }) + await h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'default', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'second' }] }, + executionOptions: {} + }) + expect(h.connection.requests.filter((command) => command === 'set_model')).toEqual([ + 'set_model', + 'set_model' + ]) + expect(h.connection.sent).toMatchObject([ + { type: 'prompt', message: 'first' }, + { type: 'prompt', message: 'second' } + ]) + }) + + it('applies the displayed default model and effort after different message choices', async () => { + const h = await setup() + h.connection.requestOverride = (command) => + command === 'set_thinking_level' ? Promise.resolve({}) : undefined + for (const [clientMessageId, model, effort] of [ + ['override', 'anthropic/model-2', 'low'], + ['default', 'anthropic/model-1', 'high'] + ]) { + await h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, + executionOptions: { model, effort } + }) + } + expect(h.connection.requests.slice(-4)).toEqual([ + 'set_model', + 'set_thinking_level', + 'set_model', + 'set_thinking_level' + ]) + expect(h.connection.sent).toHaveLength(2) + }) + it('leaves the prompt unsent when Pi refuses a sent model', async () => { const h = await setup() h.connection.requestOverride = (command) => diff --git a/src/main/pi/rpc-session-startup.ts b/src/main/pi/rpc-session-startup.ts index af2d5f3a10b9..a1b2badef6c4 100644 --- a/src/main/pi/rpc-session-startup.ts +++ b/src/main/pi/rpc-session-startup.ts @@ -5,7 +5,6 @@ import type { AgentSessionOptionsResult, AgentSessionSlashCommand } from '../../shared/agent-session-wire' -import { JsonlRpcResponseError } from '../jsonl-rpc/peer' import { AgentSessionAcquisitionRefusal, type StructuredAgentSessionAcquireInput @@ -17,12 +16,7 @@ import { type AgentModelCatalogLiveListing } from '../native-chat/agent-model-catalog/agent-model-catalog-entry' import { piRpcProviderLink, type PiRpcResolvedLaunch } from './rpc-launch-resolution' -import { - applyPiRpcSessionOption, - piModelOptionId, - readPiRpcCommands, - readPiRpcSessionOptions -} from './rpc-options' +import { piModelOptionId, readPiRpcCommands, readPiRpcSessionOptions } from './rpc-options' import { piRpcStateSchema, type PiRpcState } from './rpc-protocol' import type { PiRpcConnection, PiRpcSessionDeps } from './rpc-session' import { STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' @@ -40,7 +34,6 @@ export class PiRpcSessionStartup { constructor( private readonly input: StructuredAgentSessionAcquireInput, private readonly connection: PiRpcConnection, - private readonly selected: Map, private readonly deps: PiRpcSessionDeps, /** A new Pi session starts on its config's model; a resumed or forked one keeps its own. */ private readonly resolvesConfig: boolean, @@ -70,31 +63,7 @@ export class PiRpcSessionStartup { model: state.model ? piModelOptionId(state.model.provider, state.model.id) : '', ...(state.thinkingLevel ? { effort: state.thinkingLevel } : {}) } - const saved = this.input.options ?? {} - const skipped = Object.keys(saved).filter((key) => key !== 'model' && key !== 'effort') - // Keys a restore sent, whether or not Pi took them. - const picked = new Set() - for (const key of ['model', 'effort'] as const) { - const value = saved[key] - if (value === undefined) { - continue - } - picked.add(key) - try { - this.assertLive(signal) - await this.wait( - applyPiRpcSessionOption(this.connection, this.selected, key, value, START_REQUEST), - signal - ) - current[key] = value - } catch (error) { - if (!(error instanceof JsonlRpcResponseError)) { - throw error - } - skipped.push(key) - this.warn('Pi rejected a saved option', 'pi-option-restore', error, { key }) - } - } + const skipped: string[] = [] this.assertLive(signal) current.confirmed = [...(current.model ? ['model'] : []), ...(current.effort ? ['effort'] : [])] // Part of the start, as other agents' handshakes answer with theirs: a Pi that lists no model @@ -110,7 +79,11 @@ export class PiRpcSessionStartup { this.options = options catalogListing = withLiveCatalogListing( options, - unpickedSessionConfiguredChoice({ resolvesConfig: this.resolvesConfig, picked, ...options }) + unpickedSessionConfiguredChoice({ + resolvesConfig: this.resolvesConfig, + picked: new Set(), + ...options + }) ).catalogListing } this.skipped = skipped diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts index 917b12eff7bd..2a93c34da9b6 100644 --- a/src/main/pi/rpc-session.ts +++ b/src/main/pi/rpc-session.ts @@ -64,6 +64,7 @@ export class PiRpcSession { readonly dialogs: PiRpcDialogCallbacks readonly selected = new Map() readonly startup: PiRpcSessionStartup + initialOptions: Readonly> = {} openedLink: AgentSessionProviderHandleLink | null = null requestedClose = false private publishedExit = false @@ -148,7 +149,6 @@ export class PiRpcSession { this.startup = new PiRpcSessionStartup( input, this.connection, - this.selected, deps, resolvesConfig, (state) => { @@ -159,6 +159,10 @@ export class PiRpcSession { async open(signal: AbortSignal): Promise { const result = await this.startup.run(this.resolvedLaunch, signal) + this.initialOptions = { + ...(result.reportedOptions.model ? { model: result.reportedOptions.model } : {}), + ...(result.reportedOptions.effort ? { effort: result.reportedOptions.effort } : {}) + } this.openedLink = result.link ?? null return result } From b564887d8c38bbd725b1e81a2099b594630f9c25 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:33:46 -0700 Subject: [PATCH 029/127] Reset Codex turn choices for each captured message --- .../codex-structured-message-options.test.ts | 275 ++++++++++++++++++ .../codex/codex-structured-message-options.ts | 60 ++++ ...-structured-option-session.test-fixture.ts | 31 ++ .../codex/codex-structured-session-adapter.ts | 4 +- .../codex-structured-session-options.test.ts | 75 +---- .../codex/codex-structured-session-options.ts | 19 -- ...ured-agent-session-message-choices.test.ts | 43 ++- ...tructured-agent-session-message-options.ts | 3 + 8 files changed, 415 insertions(+), 95 deletions(-) create mode 100644 src/main/codex/codex-structured-message-options.test.ts create mode 100644 src/main/codex/codex-structured-message-options.ts create mode 100644 src/main/codex/codex-structured-option-session.test-fixture.ts diff --git a/src/main/codex/codex-structured-message-options.test.ts b/src/main/codex/codex-structured-message-options.test.ts new file mode 100644 index 000000000000..98cbc57c81f7 --- /dev/null +++ b/src/main/codex/codex-structured-message-options.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it, vi } from 'vitest' +import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { optionSession } from './codex-structured-option-session.test-fixture' +import { resolvedCodexMessageOptions } from './codex-structured-message-options' +import { startCodexTurn } from './codex-structured-turn-start' +import type { CodexSession } from './codex-structured-session-state' +import { + acquireReadyCodexForTest, + USER_MESSAGE, + adapterFor, + fakeCodex, + identityFor +} from './codex-structured-session-adapter-fixture' + +function seedMessageDefaults(session: CodexSession): void { + const store = new AgentModelCatalogStore() + const fingerprint = 'codex-message-defaults' + store.recordSuccess( + fingerprint, + 'codex', + { + origin: 'probe', + models: [ + { + id: 'model-a', + label: 'Model A', + isDefault: false, + defaultEffort: 'low', + serviceTiers: [{ value: 'priority', label: 'Priority' }], + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }, + { + id: 'model-b', + label: 'Model B', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + } + ] + }, + 'discovery' + ) + session.catalogAccess = { store, fingerprint, accountHomePath: '/test-account' } +} + +describe('Codex captured message choices', () => { + it('uses each message’s choices on turn/start without changing the saved next pick', async () => { + const request = vi.fn(async (_method: string, _params?: Record) => ({ + turn: { id: 'turn-1' } + })) + const session = optionSession(request) + session.options.set('model', 'saved-next') + session.options.set('effort', 'medium') + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'go' }] + } + for (const [id, model, effort] of [ + ['first', 'model-a', 'high'], + ['second', 'model-b', 'low'] + ]) { + const turnOptions = await resolvedCodexMessageOptions(session, { model, effort }) + await startCodexTurn(session, { clientMessageId: id, body, turnOptions }) + } + expect( + request.mock.calls + .filter(([method]) => method === 'turn/start') + .map(([, params]) => [params?.model, params?.effort]) + ).toEqual([ + ['model-a', 'high'], + ['model-b', 'low'] + ]) + expect(Object.fromEntries(session.options)).toEqual({ model: 'saved-next', effort: 'medium' }) + }) + + it('resets an explicit empty choice after a thread launch and earlier turn override', async () => { + const request = vi.fn(async (method: string, _params?: Record) => + method === 'turn/start' ? { turn: { id: 'opened' } } : {} + ) + const session = optionSession(request) + session.reportedOptions = { model: 'model-a', effort: 'high' } + session.options = new Map([ + ['model', 'model-a'], + ['effort', 'high'], + ['personality', 'friendly'], + ['serviceTier', 'priority'] + ]) + seedMessageDefaults(session) + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'go' }] + } + + for (const [id, choices] of [ + [ + 'first', + { model: 'model-a', effort: 'high', personality: 'friendly', serviceTier: 'priority' } + ], + ['second', {}], + ['third', { model: 'model-a' }] + ] as const) { + const turnOptions = await resolvedCodexMessageOptions(session, choices) + await startCodexTurn(session, { clientMessageId: id, body, turnOptions }) + } + + expect( + request.mock.calls.filter(([method]) => method === 'turn/start').map(([, params]) => params) + ).toMatchObject([ + { model: 'model-a', effort: 'high', personality: 'friendly', serviceTier: 'priority' }, + { model: 'model-b', effort: 'medium', personality: 'none', serviceTier: 'default' }, + { model: 'model-a', effort: 'low', personality: 'none', serviceTier: 'default' } + ]) + expect(request.mock.calls.map(([method]) => method)).toEqual([ + 'turn/start', + 'turn/start', + 'turn/start' + ]) + expect(Object.fromEntries(session.options)).toEqual({ + model: 'model-a', + effort: 'high', + personality: 'friendly', + serviceTier: 'priority' + }) + }) + + it('resets to the configured model after opening the thread with an explicit model', async () => { + const codex = fakeCodex({ + 'thread/start': () => ({ + thread: { id: 'thread-abc' }, + model: 'model-a', + reasoningEffort: 'high' + }), + 'model/list': () => ({ + data: [ + { + model: 'model-a', + supportedReasoningEfforts: [{ reasoningEffort: 'high' }], + defaultReasoningEffort: 'high' + }, + { + model: 'model-b', + supportedReasoningEfforts: [{ reasoningEffort: 'medium' }], + defaultReasoningEffort: 'medium' + } + ], + nextCursor: null + }), + 'config/read': () => ({ + config: { model: 'model-b', model_reasoning_effort: 'medium' } + }), + 'turn/start': () => ({ turn: { id: 'opened' } }) + }) + const adapter = adapterFor(codex, { model: 'model-a' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-1', + options: { model: 'model-a', effort: 'high' } + }) + expect( + codex.connections[0]?.calls.find((call) => call.method === 'thread/start')?.params + ).toMatchObject({ + model: 'model-a' + }) + + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'a', + body: USER_MESSAGE, + executionOptions: { model: 'model-a', effort: 'high' }, + fence: 7 + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'b', + body: USER_MESSAGE, + executionOptions: {}, + fence: 7 + }) + expect( + codex.connections[0]?.calls + .filter((call) => call.method === 'turn/start') + .map((call) => call.params) + ).toMatchObject([ + { model: 'model-a', effort: 'high' }, + { model: 'model-b', effort: 'medium', personality: 'none', serviceTier: 'default' } + ]) + }) + + it('preserves the old host path when no message snapshot was supplied', async () => { + const request = vi.fn(async (_method: string, _params?: Record) => ({ + turn: { id: 'legacy' } + })) + const session = optionSession(request) + session.options.set('model', 'saved-next') + session.options.set('effort', 'high') + const turnOptions = await resolvedCodexMessageOptions(session, undefined) + await startCodexTurn(session, { + clientMessageId: 'legacy', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }, + turnOptions + }) + expect(request.mock.calls[0]?.[1]).toMatchObject({ model: 'saved-next', effort: 'high' }) + }) + + it('reads configured defaults when the scoped catalog has not been stored yet', async () => { + const request = vi.fn(async (method: string, _params?: Record) => { + if (method === 'model/list') { + return { + data: [ + { + model: 'model-b', + isDefault: false, + supportedReasoningEfforts: [{ reasoningEffort: 'medium' }], + defaultReasoningEffort: 'medium' + } + ], + nextCursor: null + } + } + return method === 'config/read' + ? { config: { model: 'model-b', model_reasoning_effort: 'medium' } } + : { turn: { id: 'default-turn' } } + }) + const session = optionSession(request) + session.reportedOptions = { model: 'model-a', effort: 'high' } + const turnOptions = await resolvedCodexMessageOptions(session, {}) + await startCodexTurn(session, { + clientMessageId: 'default', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }, + turnOptions + }) + expect(request.mock.calls.map(([method]) => method)).toEqual([ + 'model/list', + 'config/read', + 'turn/start' + ]) + expect(request.mock.calls[2]?.[1]).toMatchObject({ + model: 'model-b', + effort: 'medium', + personality: 'none', + serviceTier: 'default' + }) + }) + + it('refuses an unavailable message option before a protocol prompt', async () => { + const request = vi.fn(async (_method: string, _params?: Record) => ({ + turn: { id: 'turn-1' } + })) + const session = optionSession(request) + seedMessageDefaults(session) + await expect(resolvedCodexMessageOptions(session, { fastMode: 'true' })).rejects.toThrow( + 'does not support Fast mode' + ) + expect(request).not.toHaveBeenCalled() + }) + + it('refuses an unlisted captured model before prompt input', async () => { + const request = vi.fn(async () => ({ turn: { id: 'unexpected' } })) + const session = optionSession(request) + seedMessageDefaults(session) + await expect( + resolvedCodexMessageOptions(session, { model: 'unlisted', effort: 'high' }) + ).rejects.toThrow('does not offer model unlisted') + expect(request).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/codex/codex-structured-message-options.ts b/src/main/codex/codex-structured-message-options.ts new file mode 100644 index 000000000000..30b24156f6e8 --- /dev/null +++ b/src/main/codex/codex-structured-message-options.ts @@ -0,0 +1,60 @@ +import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { codexAcquireCatalogListing } from './codex-structured-catalog-entry' +import { CODEX_DEFAULT_SERVICE_TIER } from './codex-structured-service-tier' +import type { CodexSession } from './codex-structured-session-state' +import { + applyCodexStructuredSessionOption, + codexSessionCatalogListingForPicker +} from './codex-structured-session-options' + +/** Validate one message without changing the saved pick for the next send. */ +export async function resolvedCodexMessageOptions( + session: CodexSession, + choices: AgentSessionExecutionOptions | undefined +): Promise> | undefined> { + if (choices === undefined) { + return undefined + } + let listing = codexAcquireCatalogListing(session.catalogAccess) + if (choices.model === undefined || choices.effort === undefined) { + try { + listing = await codexSessionCatalogListingForPicker(session, 3_000) + } catch (error) { + throw new AgentSessionOptionRejectedError(error) + } + } + const model = choices.model ?? listing?.models.find((entry) => entry.isDefault)?.id + if (!model) { + throw new AgentSessionOptionRejectedError(new Error('codex default model is unavailable')) + } + // Turn overrides persist in Codex, so every captured message starts from its own model defaults. + const selected = { + ...session, + options: new Map([ + ['personality', 'none'], + ['serviceTier', CODEX_DEFAULT_SERVICE_TIER] + ]), + reportedOptions: { ...session.reportedOptions, effort: undefined } + } + await applyCodexStructuredSessionOption(selected, 'model', model) + const defaultEffort = listing?.models.find((entry) => entry.id === model)?.defaultEffort + if (choices.effort === undefined && defaultEffort && !selected.options.has('effort')) { + await applyCodexStructuredSessionOption(selected, 'effort', defaultEffort) + } + for (const key of ['effort', 'personality', 'fastMode', 'serviceTier'] as const) { + const value = choices[key] + if (value !== undefined) { + await applyCodexStructuredSessionOption(selected, key, value) + } + } + const effort = selected.options.get('effort') + const personality = selected.options.get('personality') + const serviceTier = selected.options.get('serviceTier') + return { + model, + ...(effort ? { effort } : {}), + ...(personality ? { personality } : {}), + ...(serviceTier ? { serviceTier } : {}) + } +} diff --git a/src/main/codex/codex-structured-option-session.test-fixture.ts b/src/main/codex/codex-structured-option-session.test-fixture.ts new file mode 100644 index 000000000000..7f1146a0d4f4 --- /dev/null +++ b/src/main/codex/codex-structured-option-session.test-fixture.ts @@ -0,0 +1,31 @@ +import type { CodexAppServerConnection } from './codex-app-server-connection' +import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' +import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' +import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo' +import { createCodexTurnOpenWaits } from './codex-structured-turn-open-wait' +import type { CodexSession } from './codex-structured-session-state' + +export function optionSession(request: CodexAppServerConnection['request']): CodexSession { + return { + connection: { + pid: 1, + closed: false, + request, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + }, + backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), + ended: false, + fence: 1, + acquisitionGeneration: 'generation-1', + threadId: 'thread-1', + prompts: new CodexAcquisitionWindow().prompts, + options: new Map(), + reportedOptions: { model: 'gpt-live', effort: 'high' }, + dispatchEchoes: createCodexDispatchEchoes(), + turnOpenWaits: createCodexTurnOpenWaits(), + translator: null + } +} diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 8bbc046ce049..27cc10206398 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -20,9 +20,9 @@ import { applyCodexStructuredSessionOption, prepareLiveCodexSessionOptions, readLiveCodexSessionOptions, - readCodexAcquisitionOptions, - resolvedCodexMessageOptions + readCodexAcquisitionOptions } from './codex-structured-session-options' +import { resolvedCodexMessageOptions } from './codex-structured-message-options' import { CodexAcquisitionRegistry, requireLiveCodexSession, diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index 9d76d870d2f3..a9bd52325e74 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -6,48 +6,18 @@ import { 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' -import type { CodexAppServerConnection } from './codex-app-server-connection' -import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' import { applyCodexStructuredSessionOption, readCodexStructuredSessionOptions, readLiveCodexSessionOptions, - restoredCodexSessionOptions, - resolvedCodexMessageOptions + restoredCodexSessionOptions } from './codex-structured-session-options' import { reportedCodexThreadOptions } from './codex-structured-service-tier' -import { CodexBackgroundTaskTracker } from './codex-background-task-tracker' import type { CodexSession } from './codex-structured-session-state' import { startCodexTurn } from './codex-structured-turn-start' import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' - -function optionSession(request: CodexAppServerConnection['request']): CodexSession { - return { - connection: { - pid: 1, - closed: false, - request, - notify: () => {}, - respond: () => {}, - respondWithError: () => {}, - close: async () => true - }, - backgroundTasks: new CodexBackgroundTaskTracker('thread-1'), - ended: false, - fence: 1, - acquisitionGeneration: 'generation-1', - threadId: 'thread-1', - prompts: new CodexAcquisitionWindow().prompts, - options: new Map(), - reportedOptions: { model: 'gpt-live', effort: 'high' }, - dispatchEchoes: createCodexDispatchEchoes(), - turnOpenWaits: createCodexTurnOpenWaits(), - translator: null - } -} +import { optionSession } from './codex-structured-option-session.test-fixture' async function primePicker(session: CodexSession): Promise { session.catalogAccess = { @@ -59,47 +29,6 @@ async function primePicker(session: CodexSession): Promise { } describe('structured Codex session options', () => { - it('uses each message’s choices on turn/start without changing the saved next pick', async () => { - const request = vi.fn(async (_method: string, _params?: Record) => ({ - turn: { id: 'turn-1' } - })) - const session = optionSession(request) - session.options.set('model', 'saved-next') - session.options.set('effort', 'medium') - const body = { - kind: 'message' as const, - role: 'user' as const, - blocks: [{ type: 'text' as const, text: 'go' }] - } - for (const [id, model, effort] of [ - ['first', 'model-a', 'high'], - ['second', 'model-b', 'low'] - ]) { - const turnOptions = await resolvedCodexMessageOptions(session, { model, effort }) - await startCodexTurn(session, { clientMessageId: id, body, turnOptions }) - } - expect( - request.mock.calls - .filter(([method]) => method === 'turn/start') - .map(([, params]) => [params?.model, params?.effort]) - ).toEqual([ - ['model-a', 'high'], - ['model-b', 'low'] - ]) - expect(Object.fromEntries(session.options)).toEqual({ model: 'saved-next', effort: 'medium' }) - }) - - it('refuses an unavailable message option before a protocol prompt', async () => { - const request = vi.fn(async (_method: string, _params?: Record) => ({ - turn: { id: 'turn-1' } - })) - const session = optionSession(request) - await expect(resolvedCodexMessageOptions(session, { fastMode: 'true' })).rejects.toThrow( - 'does not support Fast mode' - ) - expect(request).not.toHaveBeenCalled() - }) - it.each([ ['model', 'gpt-next'], ['effort', 'high'], diff --git a/src/main/codex/codex-structured-session-options.ts b/src/main/codex/codex-structured-session-options.ts index 6a20a51f3470..e9118f111ac1 100644 --- a/src/main/codex/codex-structured-session-options.ts +++ b/src/main/codex/codex-structured-session-options.ts @@ -1,7 +1,6 @@ import type { AgentSessionOptionsResult } from '../../shared/agent-session-wire' import type { CodexAppServerConnection } from './codex-app-server-connection' import type { CodexSession } from './codex-structured-session-state' -import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' import { isCodexTurnOptionKey } from './codex-structured-turn-start' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' @@ -188,24 +187,6 @@ export async function applyCodexStructuredSessionOption( } } -/** Validate one message against a separate option map, leaving the pick for the next send alone. */ -export async function resolvedCodexMessageOptions( - session: CodexSession, - choices: AgentSessionExecutionOptions | undefined -): Promise> | undefined> { - if (!choices) { - return undefined - } - const selected = { ...session, options: new Map(session.options) } - for (const key of ['model', 'effort', 'personality', 'fastMode', 'serviceTier'] as const) { - const value = choices[key] - if (value !== undefined) { - await applyCodexStructuredSessionOption(selected, key, value) - } - } - return Object.fromEntries(selected.options) -} - /** * With no listing known, a pick is kept as the next turn's intent rather than refused: catalog * bookkeeping must not gate it. Codex judges the model and tier on `turn/start`. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts index d5f0e18bfa80..5669ea0ae03f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, it } from 'vitest' import { providerDiagnostic, withProviderDiagnostic } from '../../../shared/agent-session-failure' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { createQueuedMessageTestRig, eventually, @@ -8,7 +9,8 @@ import { } from './structured-agent-session-queued-message-rig.test-fixture' import { HOST_TEST_SESSION as SESSION, - hostTestOperationId + hostTestOperationId, + HOST_TEST_THREAD as THREAD } from './structured-agent-session-host-test-data' import { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' @@ -121,3 +123,42 @@ it('keeps a draft’s accepted choices through a host restart and Send now', asy expect(await rig.sendNow(draftId)).toMatchObject({ ok: true }) expect((await rig.handoff(draftId))?.executionOptions).toEqual(choices) }) + +it('steers a matching choice but holds an empty default choice for a new turn', async () => { + const choices = { model: 'model-a', effort: 'high' } + const first = rig.send('first', undefined, { executionOptions: choices }) + await first.result + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(1)) + await rig.settleAccepted(first.id, 'a') + rig + .providerEvents() + .appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-a', ordinal: 0 }, + { kind: 'turn', turnId: 'turn-a', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rig.host.flushStreamedEvents(SESSION) + + const matching = rig.send('same choices', undefined, { executionOptions: choices }) + await matching.result + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) + await rig.host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: matching.id, + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-a', ordinal: 1 } + }) + const defaults = rig.send('default choices', undefined, { executionOptions: {} }) + await defaults.result + expect(rig.dispatch).toHaveBeenCalledTimes(2) + + rig + .providerEvents() + .appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-a', ordinal: 0 }, + { kind: 'turn', turnId: 'turn-a', state: 'completed' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rig.host.flushStreamedEvents(SESSION) + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(3)) + expect(rig.dispatch.mock.calls[2]?.[0].executionOptions).toEqual({}) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts index 1e797a840732..96f6375effd7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts @@ -9,6 +9,9 @@ function sameOptions( a: AgentSessionExecutionOptions | undefined, b: AgentSessionExecutionOptions | undefined ): boolean { + if ((a === undefined) !== (b === undefined)) { + return false + } return OPTION_ORDER.every((key) => a?.[key] === b?.[key]) } From d3fa3ff508efdde1e09314a8577c750c9f8813b1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:19:46 -0700 Subject: [PATCH 030/127] Resolve Codex message defaults from native facts --- .../codex-structured-message-options.test.ts | 210 +++++++++++++++--- .../codex/codex-structured-message-options.ts | 64 +++--- .../codex/codex-structured-session-acquire.ts | 10 +- .../codex/codex-structured-session-start.ts | 8 + .../codex/codex-structured-session-state.ts | 2 + src/main/codex/codex-structured-turn-start.ts | 43 +++- ...nt-session-startup-permission-mode.test.ts | 9 +- 7 files changed, 273 insertions(+), 73 deletions(-) diff --git a/src/main/codex/codex-structured-message-options.test.ts b/src/main/codex/codex-structured-message-options.test.ts index 98cbc57c81f7..57d87d9c6559 100644 --- a/src/main/codex/codex-structured-message-options.test.ts +++ b/src/main/codex/codex-structured-message-options.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' +import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' import { optionSession } from './codex-structured-option-session.test-fixture' import { resolvedCodexMessageOptions } from './codex-structured-message-options' import { startCodexTurn } from './codex-structured-turn-start' +import { CodexAppServerRequestError } from './codex-app-server-connection' import type { CodexSession } from './codex-structured-session-state' import { acquireReadyCodexForTest, @@ -50,7 +53,7 @@ function seedMessageDefaults(session: CodexSession): void { } describe('Codex captured message choices', () => { - it('uses each message’s choices on turn/start without changing the saved next pick', async () => { + it('uses each message’s choices on turn/start and tracks the native attempted choice', async () => { const request = vi.fn(async (_method: string, _params?: Record) => ({ turn: { id: 'turn-1' } })) @@ -77,7 +80,12 @@ describe('Codex captured message choices', () => { ['model-a', 'high'], ['model-b', 'low'] ]) - expect(Object.fromEntries(session.options)).toEqual({ model: 'saved-next', effort: 'medium' }) + expect(Object.fromEntries(session.options)).toEqual({ + model: 'model-b', + effort: 'low', + personality: 'none', + serviceTier: 'default' + }) }) it('resets an explicit empty choice after a thread launch and earlier turn override', async () => { @@ -125,13 +133,42 @@ describe('Codex captured message choices', () => { ]) expect(Object.fromEntries(session.options)).toEqual({ model: 'model-a', - effort: 'high', - personality: 'friendly', - serviceTier: 'priority' + effort: 'low', + personality: 'none', + serviceTier: 'default' }) }) it('resets to the configured model after opening the thread with an explicit model', async () => { + const store = new AgentModelCatalogStore() + const access = agentModelCatalogSessionAccess(store, CODEX_STRUCTURED_AGENT, '/test-account') + if (!access) { + throw new Error('expected Codex catalog access') + } + store.recordSuccess( + access.fingerprint, + 'codex', + { + origin: 'probe', + models: [ + { + id: 'model-a', + label: 'Model A', + isDefault: false, + defaultEffort: 'high', + efforts: [{ value: 'high', label: 'High' }] + }, + { + id: 'model-b', + label: 'Model B', + isDefault: true, + defaultEffort: 'medium', + efforts: [{ value: 'medium', label: 'Medium' }] + } + ] + }, + 'discovery' + ) const codex = fakeCodex({ 'thread/start': () => ({ thread: { id: 'thread-abc' }, @@ -158,7 +195,9 @@ describe('Codex captured message choices', () => { }), 'turn/start': () => ({ turn: { id: 'opened' } }) }) - const adapter = adapterFor(codex, { model: 'model-a' }) + const adapter = adapterFor(codex, { model: 'model-a', codexHome: '/test-account' }, [], { + modelCatalog: store + }) await acquireReadyCodexForTest(adapter, { identity: identityFor('session-1'), fence: 7, @@ -193,6 +232,60 @@ describe('Codex captured message choices', () => { { model: 'model-a', effort: 'high' }, { model: 'model-b', effort: 'medium', personality: 'none', serviceTier: 'default' } ]) + expect(codex.connections[0]?.calls.map((call) => call.method)).not.toContain('model/list') + expect(codex.connections[0]?.calls.map((call) => call.method)).not.toContain('config/read') + }) + + it('opens from the first captured model even when launch resolution saw a newer saved pick', async () => { + const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'opened' } }) }) + const adapter = adapterFor(codex, { model: 'model-b' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-1', + options: { model: 'model-a', effort: 'high' } + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'first', + body: USER_MESSAGE, + executionOptions: { model: 'model-a', effort: 'high' }, + fence: 7 + }) + expect( + codex.connections[0]?.calls.find((call) => call.method === 'thread/start')?.params + ).toMatchObject({ model: 'model-a' }) + expect( + codex.connections[0]?.calls.find((call) => call.method === 'turn/start')?.params + ).toMatchObject({ model: 'model-a', effort: 'high' }) + }) + + it('uses native defaults through a held catalog read and allows the child to close', async () => { + const codex = fakeCodex({ + 'model/list': () => new Promise(() => {}), + 'turn/start': () => ({ turn: { id: 'opened' } }) + }) + const adapter = adapterFor(codex, { model: 'newer-saved-model' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-1', + options: {} + }) + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'default', + body: USER_MESSAGE, + executionOptions: {}, + fence: 7 + }) + expect( + codex.connections[0]?.calls.find((call) => call.method === 'thread/start')?.params + ).not.toHaveProperty('model') + expect( + codex.connections[0]?.calls.find((call) => call.method === 'turn/start')?.params + ).not.toHaveProperty('model') + expect(await adapter.closeSession('session-1')).toBe(true) }) it('preserves the old host path when no message snapshot was supplied', async () => { @@ -211,44 +304,103 @@ describe('Codex captured message choices', () => { expect(request.mock.calls[0]?.[1]).toMatchObject({ model: 'saved-next', effort: 'high' }) }) - it('reads configured defaults when the scoped catalog has not been stored yet', async () => { + it('sends a fresh default while model discovery is held', async () => { const request = vi.fn(async (method: string, _params?: Record) => { if (method === 'model/list') { - return { - data: [ - { - model: 'model-b', - isDefault: false, - supportedReasoningEfforts: [{ reasoningEffort: 'medium' }], - defaultReasoningEffort: 'medium' - } - ], - nextCursor: null - } + return new Promise(() => {}) } - return method === 'config/read' - ? { config: { model: 'model-b', model_reasoning_effort: 'medium' } } - : { turn: { id: 'default-turn' } } + return { turn: { id: 'default-turn' } } }) const session = optionSession(request) session.reportedOptions = { model: 'model-a', effort: 'high' } + session.nativeDefaults = { model: 'model-a', effort: 'high' } const turnOptions = await resolvedCodexMessageOptions(session, {}) await startCodexTurn(session, { clientMessageId: 'default', body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }, turnOptions }) - expect(request.mock.calls.map(([method]) => method)).toEqual([ - 'model/list', - 'config/read', - 'turn/start' - ]) - expect(request.mock.calls[2]?.[1]).toMatchObject({ - model: 'model-b', - effort: 'medium', + expect(request.mock.calls.map(([method]) => method)).toEqual(['turn/start']) + expect(request.mock.calls[0]?.[1]).toMatchObject({ personality: 'none', serviceTier: 'default' }) + expect(request.mock.calls[0]?.[1]).not.toHaveProperty('model') + expect(request.mock.calls[0]?.[1]).not.toHaveProperty('effort') + }) + + it('restores opened native defaults after an explicit override without a catalog', async () => { + const request = vi.fn(async (_method: string, _params?: Record) => ({ + turn: { id: 'opened' } + })) + const session = optionSession(request) + session.reportedOptions = { model: 'native-model', effort: 'medium' } + session.nativeDefaults = { model: 'native-model', effort: 'medium' } + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'go' }] + } + await startCodexTurn(session, { + clientMessageId: 'override', + body, + turnOptions: await resolvedCodexMessageOptions(session, { + model: 'other-model', + effort: 'high' + }) + }) + await startCodexTurn(session, { + clientMessageId: 'reset', + body, + turnOptions: await resolvedCodexMessageOptions(session, {}) + }) + expect(request.mock.calls.map(([, params]) => params)).toMatchObject([ + { model: 'other-model', effort: 'high' }, + { model: 'native-model', effort: 'medium', personality: 'none', serviceTier: 'default' } + ]) + }) + + it('refuses a default reset with no trustworthy default before prompt input', async () => { + const request = vi.fn(async () => ({ turn: { id: 'unexpected' } })) + const session = optionSession(request) + session.options.set('model', 'old-override') + session.options.set('effort', 'high') + await expect(resolvedCodexMessageOptions(session, {})).rejects.toThrow( + 'default model is unavailable' + ) + expect(request).not.toHaveBeenCalled() + }) + + it('restores native options after a known refusal and retains them after an unknown answer', async () => { + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'go' }] + } + const known = optionSession( + vi.fn(async () => { + throw new CodexAppServerRequestError('turn/start', -32602, 'invalid model') + }) + ) + known.options.set('model', 'prior') + await expect( + startCodexTurn(known, { clientMessageId: 'known', body, turnOptions: { model: 'attempted' } }) + ).rejects.toThrow('invalid model') + expect(Object.fromEntries(known.options)).toEqual({ model: 'prior' }) + const unknown = optionSession( + vi.fn(async () => { + throw new Error('connection lost after write') + }) + ) + unknown.options.set('model', 'prior') + await expect( + startCodexTurn(unknown, { + clientMessageId: 'unknown', + body, + turnOptions: { model: 'attempted' } + }) + ).rejects.toThrow('connection lost after write') + expect(Object.fromEntries(unknown.options)).toEqual({ model: 'attempted' }) }) it('refuses an unavailable message option before a protocol prompt', async () => { diff --git a/src/main/codex/codex-structured-message-options.ts b/src/main/codex/codex-structured-message-options.ts index 30b24156f6e8..57b870e14040 100644 --- a/src/main/codex/codex-structured-message-options.ts +++ b/src/main/codex/codex-structured-message-options.ts @@ -3,12 +3,9 @@ import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wi import { codexAcquireCatalogListing } from './codex-structured-catalog-entry' import { CODEX_DEFAULT_SERVICE_TIER } from './codex-structured-service-tier' import type { CodexSession } from './codex-structured-session-state' -import { - applyCodexStructuredSessionOption, - codexSessionCatalogListingForPicker -} from './codex-structured-session-options' +import { applyCodexStructuredSessionOption } from './codex-structured-session-options' -/** Validate one message without changing the saved pick for the next send. */ +/** Validate one message against known facts without reading the provider or changing the next pick. */ export async function resolvedCodexMessageOptions( session: CodexSession, choices: AgentSessionExecutionOptions | undefined @@ -16,19 +13,26 @@ export async function resolvedCodexMessageOptions( if (choices === undefined) { return undefined } - let listing = codexAcquireCatalogListing(session.catalogAccess) - if (choices.model === undefined || choices.effort === undefined) { - try { - listing = await codexSessionCatalogListingForPicker(session, 3_000) - } catch (error) { - throw new AgentSessionOptionRejectedError(error) - } - } - const model = choices.model ?? listing?.models.find((entry) => entry.isDefault)?.id - if (!model) { + const listing = codexAcquireCatalogListing(session.catalogAccess) + const listedDefault = listing?.models.find((entry) => entry.isDefault) + const priorModel = session.options.get('model') + const priorEffort = session.options.get('effort') + const model = + choices.model ?? + listedDefault?.id ?? + session.nativeDefaults?.model ?? + (priorModel || priorEffort ? undefined : session.reportedOptions.model) + if (!model && (choices.effort || priorModel || priorEffort)) { throw new AgentSessionOptionRejectedError(new Error('codex default model is unavailable')) } - // Turn overrides persist in Codex, so every captured message starts from its own model defaults. + const listedModel = listing?.models.find((entry) => entry.id === model) + const defaultEffort = + listedModel?.defaultEffort ?? + (model === session.nativeDefaults?.model ? session.nativeDefaults?.effort : undefined) + if (choices.effort === undefined && priorEffort && !defaultEffort) { + throw new AgentSessionOptionRejectedError(new Error('codex default effort is unavailable')) + } + // Codex retains turn overrides, so defaults must be named after an earlier override. const selected = { ...session, options: new Map([ @@ -37,9 +41,21 @@ export async function resolvedCodexMessageOptions( ]), reportedOptions: { ...session.reportedOptions, effort: undefined } } - await applyCodexStructuredSessionOption(selected, 'model', model) - const defaultEffort = listing?.models.find((entry) => entry.id === model)?.defaultEffort - if (choices.effort === undefined && defaultEffort && !selected.options.has('effort')) { + if ( + model && + (choices.model || + priorModel || + priorEffort || + choices.effort || + choices.fastMode === 'true' || + (choices.serviceTier && choices.serviceTier !== CODEX_DEFAULT_SERVICE_TIER)) + ) { + await applyCodexStructuredSessionOption(selected, 'model', model) + if (!defaultEffort && choices.effort === undefined) { + selected.options.delete('effort') + } + } + if (choices.effort === undefined && defaultEffort && (priorEffort || choices.model)) { await applyCodexStructuredSessionOption(selected, 'effort', defaultEffort) } for (const key of ['effort', 'personality', 'fastMode', 'serviceTier'] as const) { @@ -48,13 +64,5 @@ export async function resolvedCodexMessageOptions( await applyCodexStructuredSessionOption(selected, key, value) } } - const effort = selected.options.get('effort') - const personality = selected.options.get('personality') - const serviceTier = selected.options.get('serviceTier') - return { - model, - ...(effort ? { effort } : {}), - ...(personality ? { personality } : {}), - ...(serviceTier ? { serviceTier } : {}) - } + return Object.fromEntries(selected.options) } diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 5a905b8b56c2..34da4da09e6b 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -103,7 +103,7 @@ export async function acquireCodexStructuredSession(input: { throw new Error(`codex app-server for session ${sessionId} could not be stopped`) } acquisitions.assertCurrent(sessionId, attempt) - const launch = await deps + const resolvedLaunch = await deps .resolveLaunch({ identity: acquireInput.identity, spawnToken: acquireInput.spawnToken, @@ -112,6 +112,14 @@ export async function acquireCodexStructuredSession(input: { .catch((error: unknown) => { throw new AgentSessionPreSpawnError(error) }) + const { model: _savedNextModel, ...launchSettings } = resolvedLaunch + const launch = + acquireInput.options === undefined + ? resolvedLaunch + : { + ...launchSettings, + ...(acquireInput.options.model ? { model: acquireInput.options.model } : {}) + } acquisitions.assertCurrent(sessionId, attempt) account = launch.codexHome ? deps.resolveAccountKind?.(launch.codexHome) : undefined const connection = await open( diff --git a/src/main/codex/codex-structured-session-start.ts b/src/main/codex/codex-structured-session-start.ts index 74975510502e..b32e4a3f9e81 100644 --- a/src/main/codex/codex-structured-session-start.ts +++ b/src/main/codex/codex-structured-session-start.ts @@ -119,6 +119,14 @@ export async function openCodexStructuredSession( prompts: attempt.window.prompts, options: restoredCodexSessionOptions(input.options), reportedOptions: reportedCodexThreadOptions(opened), + ...(launch.resumeThreadId === null && !launch.model && opened.model + ? { + nativeDefaults: { + model: opened.model, + ...(opened.effort ? { effort: opened.effort } : {}) + } + } + : {}), ...(catalogAccess ? { catalogAccess } : {}), dispatchEchoes: start.dispatchEchoes, translator, diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 6de26ff66ae2..9a6664c864c5 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -151,6 +151,8 @@ export type CodexSession = { serviceTier?: string | null serviceTierKnown?: true } + /** A fresh thread opened without a model override proved these native defaults. */ + nativeDefaults?: { model: string; effort?: string } /** Absent when the adapter runs without a host catalog store (tests). */ catalogAccess?: CodexSessionCatalogAccess /** Sends whose identity is still to be settled by the provider echo. */ diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 1728683b2114..75955eaf5ae9 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -134,17 +134,38 @@ export async function startCodexTurn( if (steered) { return steered } - const answer = await host.connection.request( - 'turn/start', - { - threadId: host.threadId, - clientUserMessageId: input.clientMessageId, - input: turnInputFor(input.body), - // Codex omits a tier the model does not list. - ...(input.turnOptions ?? Object.fromEntries(host.options)) - }, - { timeoutMs: input.timeoutMs } - ) + const previousOptions = input.turnOptions ? new Map(host.options) : null + if (input.turnOptions) { + host.options.clear() + for (const [key, value] of Object.entries(input.turnOptions)) { + host.options.set(key, value) + } + } + let answer: unknown + try { + answer = await host.connection.request( + 'turn/start', + { + threadId: host.threadId, + clientUserMessageId: input.clientMessageId, + input: turnInputFor(input.body), + // Codex omits a tier the model does not list. + ...(input.turnOptions ?? Object.fromEntries(host.options)) + }, + { timeoutMs: input.timeoutMs } + ) + } catch (error) { + if ( + previousOptions && + (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) + ) { + host.options.clear() + for (const [key, value] of previousOptions) { + host.options.set(key, value) + } + } + throw error + } return { turnId: readCodexTurnId(answer), via: 'start' } } 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 81d90583e9ee..571664ae003f 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 @@ -117,10 +117,11 @@ it.each([false, true])( optionRevision: -1 }) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) - expect(writes.mock.calls.map(([input]) => [input.key, input.value])).toEqual([ - ['model', 'launched-model'], - ['effort', 'low'] - ]) + expect(writes).not.toHaveBeenCalled() + expect(rig.adapter.dispatch.mock.calls[0]?.[0].executionOptions).toEqual({ + model: 'launched-model', + effort: 'low' + }) 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) From 4542252e069446b415654cfadae325020486dfb6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:06:12 -0700 Subject: [PATCH 031/127] Align message choice and Pi default fixtures with provider dispatch --- src/main/pi/rpc-configured-default.test.ts | 26 +++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/main/pi/rpc-configured-default.test.ts b/src/main/pi/rpc-configured-default.test.ts index 3091f3096f7b..c6e684dc4827 100644 --- a/src/main/pi/rpc-configured-default.test.ts +++ b/src/main/pi/rpc-configured-default.test.ts @@ -297,15 +297,25 @@ describe('Pi’s default comes from what a chat with no pick runs', () => { ).toEqual(NOTHING) }) - it('learns nothing from a chat that restored a pick or resumed a conversation', async () => { + it('learns its configured default despite a saved next-message pick, but not from a resumed conversation', async () => { const { catalog, chat } = await piHost() await catalog.read({ agent: 'pi', waitForListing: true }) const clean = workspace('clean') - expect((await chat(clean, { saved: { model: 'openai/gpt-6' } })).listing).not.toHaveProperty( - 'configuredDefault' - ) - expect((await chat(clean, { resumed: true })).listing).not.toHaveProperty('configuredDefault') - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual(NOTHING) + expect( + (await chat(clean, { saved: { model: 'openai/gpt-6' } })).listing.configuredDefault + ).toEqual({ + modelId: 'openai/gpt-6', + effort: 'high' + }) + expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ + model: 'openai/gpt-6', + effort: 'high' + }) + const resumed = await piHost() + await resumed.catalog.read({ agent: 'pi', waitForListing: true }) + expect( + (await resumed.chat(workspace('resumed'), { resumed: true })).listing + ).not.toHaveProperty('configuredDefault') }) it('saves no default from a signed-out Pi, which lists no model', async () => { @@ -399,10 +409,10 @@ describe('Pi’s default comes from what a chat with no pick runs', () => { }) }) - it('learns the model, but not the thinking level, from a chat that restored only its thinking level', async () => { + it('learns the configured model and thinking level despite a saved next-message thinking pick', async () => { const { chat } = await piHost() const { listing } = await chat(workspace('clean'), { saved: { effort: 'low' } }) - expect(listing.configuredDefault).toEqual({ modelId: 'openai/gpt-6' }) + expect(listing.configuredDefault).toEqual({ modelId: 'openai/gpt-6', effort: 'high' }) }) it('learns from a new Pi session standing in for a conversation whose file was gone', async () => { From 21eda73db402f8854ea0e2b244bfd5c22c2e299e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:14:02 -0700 Subject: [PATCH 032/127] Align option fixtures with saved message choices --- .../acp/acp-structured-host-live-listing.test.ts | 15 ++++++++++----- ...aude-structured-session-adapter-turns.test.ts | 11 ++++------- ...-session-claude-stop-exit-ends-record.test.ts | 16 +++++----------- 3 files changed, 19 insertions(+), 23 deletions(-) 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/claude/claude-structured-session-adapter-turns.test.ts b/src/main/claude/claude-structured-session-adapter-turns.test.ts index a48497b84166..0fa3c8f287eb 100644 --- a/src/main/claude/claude-structured-session-adapter-turns.test.ts +++ b/src/main/claude/claude-structured-session-adapter-turns.test.ts @@ -16,13 +16,7 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { const claude = fakeClaude({ replayUuid: 'chosen-uuid' }) const adapter = await acquired(claude) const connection = claude.connections[0]! - const send = connection.send - connection.send = async (...args) => { - const controls = connection.calls.map((call) => call.subtype) - expect(controls.indexOf('set_model')).toBeGreaterThan(-1) - expect(controls.indexOf('set_model')).toBeLessThan(controls.indexOf('apply_flag_settings')) - return send(...args) - } + const before = connection.calls.length await expect( adapter.dispatch({ sessionId: 'session-1', @@ -32,6 +26,9 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { executionOptions: { model: 'sonnet', effort: 'high' } }) ).resolves.toEqual({ state: 'admitted' }) + const controls = connection.calls.slice(before).map((call) => call.subtype) + expect(controls.indexOf('set_model')).toBeGreaterThan(-1) + expect(controls.indexOf('apply_flag_settings')).toBeGreaterThan(controls.indexOf('set_model')) }) it("admits a dispatch on the write and names it from Claude's replay", async () => { 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' }) }) From 9c11b4dd847309a4e53b65198240d481012fbd3c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:20:29 -0700 Subject: [PATCH 033/127] Match Codex message choices to the active turn origin --- .../structured-agent-session-message-options.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts index 96f6375effd7..81579f05c9f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts @@ -17,19 +17,18 @@ function sameOptions( /** A different Codex choice needs a new turn: steer has no choice parameters. */ export function waitsForCodexTurnOptions( - journal: Pick, + journal: Pick, next: AgentJournalSubmission ): boolean { - const activeTurnId = journal.activeTurnId() - if (!activeTurnId) { + const turn = journal.newestTurn() + if (turn?.state !== 'running') { return false } const active = journal.submissions().find((submission) => { if (submission.handedOverAt === undefined) { return false } - const item = journal.item(agentJournalSubmissionKey(submission.clientMessageId)) - return item?.turnScope?.kind === 'turn' && item.turnScope.turnItemId === activeTurnId + return agentJournalSubmissionKey(submission.clientMessageId) === turn.userItemId }) return active ? !sameOptions(active.executionOptions, next.executionOptions) : true } From 0d0df2bc13c13b19255654bd03ee80be8a35c2fb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:30:01 -0700 Subject: [PATCH 034/127] fix: let pending commands reach handover refusal --- ...tructured-agent-session-message-options.ts | 6 ++++- ...tructured-agent-session-stop-event.test.ts | 23 ++++++++++++------- ...structured-conversation-compaction.test.ts | 2 +- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts index 81579f05c9f0..41e9fe7a9898 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts @@ -17,9 +17,13 @@ function sameOptions( /** A different Codex choice needs a new turn: steer has no choice parameters. */ export function waitsForCodexTurnOptions( - journal: Pick, + journal: Pick, next: AgentJournalSubmission ): boolean { + const body = journal.itemBody(agentJournalSubmissionKey(next.clientMessageId)) + if (body?.kind !== 'message' || body.command) { + return false + } const turn = journal.newestTurn() if (turn?.state !== 'running') { return false diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index ea0dae59199e..424d1fd13c96 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -1,3 +1,4 @@ +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' // A Stop's event, through the real host: written in the Stop's serialized step once it takes // effect, before the interrupt and before anything that ends the child, naming the turn and who // asked; never by a Stop that stopped nothing. @@ -48,10 +49,16 @@ async function queuedDraft(text: string): Promise { } /** The provider's turn row for the working send, which may land after a Stop. */ -async function turnRow(turnId: string, state: 'running' | 'interrupted') { +async function turnRow(turnId: string, state: 'running' | 'interrupted', userId?: string) { return journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 }, - { kind: 'turn', turnId, state, startedAt: 1 }, + { + kind: 'turn', + turnId, + state, + startedAt: 1, + ...(userId ? { userItemId: agentJournalSubmissionKey(userId) } : {}) + }, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } @@ -185,7 +192,7 @@ describe("a Stop's event", () => { const working = await rig.workingSend() await rig.stop() const between = await queuedDraft('queued between the presses') - await turnRow('turn-1', 'running') + await turnRow('turn-1', 'running', working) await rig.stop() expect(stopEvents()).toHaveLength(1) await rig.settleAccepted(working, 'stopped') @@ -195,10 +202,10 @@ describe("a Stop's event", () => { it('a second press after a card sent into the turn settled unknown writes again', async () => { rig = await createQueuedMessageTestRig() - await rig.workingSend() + const working = await rig.workingSend() await rig.stop() // The turn the working send started opens after that turnless Stop; the card goes into it. - await turnRow('turn-1', 'running') + await turnRow('turn-1', 'running', working) const steered = await queuedDraft('sent into the turn between the presses') await rig.sendNow(steered) await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) @@ -217,7 +224,7 @@ describe("a Stop's event", () => { const working = await rig.workingSend() await rig.stop() // The turn the working send started opens after that turnless Stop; the card goes into it. - await turnRow('turn-1', 'running') + await turnRow('turn-1', 'running', working) const steered = await queuedDraft('sent into the turn between the presses') await rig.sendNow(steered) await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) @@ -233,7 +240,7 @@ describe("a Stop's event", () => { it('naming no turn, names the turn running when it takes effect', async () => { rig = await createQueuedMessageTestRig() await rig.workingSend() - await turnRow('turn-1', 'running') + await turnRow('turn-1', 'running', working) await rig.stop() expect(stopEvents()).toMatchObject([{ reason: 'user-stop', turnId: 'turn-1' }]) }) @@ -253,7 +260,7 @@ describe("a Stop's event", () => { it('names a turn that ended while the next card is sent but shows no turn yet: writes, and holds that card', async () => { rig = await createQueuedMessageTestRig() const working = await rig.workingSend() - await turnRow('turn-1', 'running') + await turnRow('turn-1', 'running', working) const next = await queuedDraft('sent when turn-1 ends') await rig.settleAccepted(working, 'working') await turnRow('turn-1', 'interrupted') diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index e671fc96d9f4..811736c0a332 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -378,7 +378,7 @@ it('refuses the command at handover when the provider opened a turn meanwhile (B void state.host['tasks'].serialize(SESSION, () => held.promise) const commanded = state.host.conversationCommand(CALLER, params) void state.host['tasks'].serialize(SESSION, async () => { - events?.appendItem( + await events?.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'provider-turn', ordinal: 0 }, { kind: 'turn', turnId: 'provider-turn', state: 'running' }, { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } From ac79c22c7c871ab1ce490bfe00230dba62121455 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:54:09 -0700 Subject: [PATCH 035/127] Abort pre-prompt message choice writes with host-owned wait --- src/main/acp/acp-message-execution-options.ts | 13 +++- .../acp-structured-host-option-hang.test.ts | 55 +++++++++++------ src/main/acp/acp-structured-options.ts | 10 ++- .../acp-structured-session-adapter.test.ts | 42 +++++++++++++ .../acp/acp-structured-session-adapter.ts | 11 +++- .../claude-structured-message-options.ts | 61 ++++++++++++++++--- .../claude-structured-session-adapter.ts | 26 +------- .../codex/codex-structured-session-adapter.ts | 22 +++---- ...structured-agent-session-acquire-aborts.ts | 17 +++--- .../structured-agent-session-adapter.ts | 2 + .../structured-agent-session-chat-stop.ts | 39 +++++++++++- .../structured-agent-session-delivery-loop.ts | 9 +-- .../structured-agent-session-host-delivery.ts | 5 +- ...structured-agent-session-host-mutations.ts | 15 ++--- .../structured-agent-session-host.ts | 6 +- .../structured-agent-session-turns.ts | 47 +++++++++++--- src/main/pi/rpc-message-dispatch.ts | 21 ++++++- src/main/pi/rpc-session-adapter.test.ts | 44 +++++++++++++ 18 files changed, 341 insertions(+), 104 deletions(-) diff --git a/src/main/acp/acp-message-execution-options.ts b/src/main/acp/acp-message-execution-options.ts index 9cdf026c687d..9fb2d6540a20 100644 --- a/src/main/acp/acp-message-execution-options.ts +++ b/src/main/acp/acp-message-execution-options.ts @@ -11,6 +11,9 @@ export async function applyAcpMessageOptions( setOption: (key: 'model' | 'effort', value: string) => Promise, isCurrent: () => boolean ): Promise { + if (!options) { + return true + } const initial = session.options.messageDefault() const model = options?.model ?? initial.model const defaultEffort = @@ -19,7 +22,15 @@ export async function applyAcpMessageOptions( : session.options.read().models.find((entry) => entry.id === model)?.defaultEffort for (const key of ['model', 'effort'] as const) { const value = options?.[key] ?? (key === 'model' ? initial.model : defaultEffort) - if (value === undefined || session.options.reported()[key] === value) { + if (value === undefined) { + if (session.options.reported()[key] !== undefined) { + throw new AgentSessionOptionRejectedError( + new Error(`The default ${key} for this model is unknown`) + ) + } + continue + } + if (session.options.reported()[key] === value) { continue } try { 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..3447d401199d 100644 --- a/src/main/acp/acp-structured-host-option-hang.test.ts +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -9,8 +9,9 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' import { HOST_TEST_SESSION as SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { GROK_CONFIG_OPTIONS, waitFor } from './acp-structured-adapter.test-support' -import { framesOf, openAttachedHostRig, stop } from './acp-structured-host.test-support' +import { framesOf, openAttachedHostRig, send } from './acp-structured-host.test-support' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { STOP_NOTE_CANCELLATION_REQUESTED } from '../native-chat/agent-session-wire/structured-agent-session-turn-stop-notes' afterEach(async () => { await closeProviderTimelineRigs() @@ -40,45 +41,59 @@ 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({ +describe('a Grok model pick Grok never answers during send', () => { + it('does not hold Stop, writes one request note, and ignores a late setter reply', async () => { + const { rig, host, rows } = await openAttachedHostRig({ script: (agent) => agent.on('session/set_config_option', () => {}) }) - const picking = pick(host, 'grok-4.6') + expect(await pick(host, 'grok-4.6')).toMatchObject({ ok: true }) + await send(host, 'hello') await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) - expect(await within(stop(host), 2_000)).toMatchObject({ settled: 'resolved' }) - expect(failed(await within(picking, 1_000))).toBe(true) + const invalid = host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', {}, { payloadFingerprint: 'invalid' }) + }) + expect(await within(invalid, 100)).toBe('pending') + const request = { envelope: envelope('agentSession.cancel', {}) } + expect(await within(host.cancel(CALLER, request), 2_000)).toMatchObject({ settled: 'resolved' }) + expect(await invalid).toMatchObject({ ok: false }) + expect(await host.cancel(CALLER, request)).toMatchObject({ ok: true }) + expect( + host.collaboratorsForTests().sessions.get(SESSION)?.journal.submissions()[0]?.dispatchState + ).toBe('rejected') + expect( + (await rows()).filter( + (row) => row.body.kind === 'status' && row.body.text === STOP_NOTE_CANCELLATION_REQUESTED + ) + ).toHaveLength(1) + const [frame] = framesOf(rig.child(), 'session/set_config_option') + rig.child().agent.reply(frame!, { configOptions: GROK_CONFIG_OPTIONS }) + expect(framesOf(rig.child(), 'session/prompt')).toEqual([]) expect(await within(host.close(SESSION, 'user-close'), 2_000)).not.toBe('pending') }) - it('does not hold a Close behind it; the pick fails', async () => { + it('does not hold a Close behind its write', async () => { const { rig, host } = await openAttachedHostRig({ script: (agent) => agent.on('session/set_config_option', () => {}) }) - const picking = pick(host, 'grok-4.6') + expect(await pick(host, 'grok-4.6')).toMatchObject({ ok: true }) + await send(host, 'hello') await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) expect(await within(host.close(SESSION, 'user-close'), 2_000)).toMatchObject({ settled: 'resolved' }) - expect(failed(await within(picking, 1_000))).toBe(true) + const [frame] = framesOf(rig.child(), 'session/set_config_option') + rig.child().agent.reply(frame!, { configOptions: GROK_CONFIG_OPTIONS }) + expect(framesOf(rig.child(), 'session/prompt')).toEqual([]) }) - it('fails at its bound, frees the chat, and keeps what Grok answers late', async () => { + it('bounds its write, 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') + expect(await pick(host, 'grok-4.6')).toMatchObject({ ok: true }) + await send(host, 'hello') await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(1)) - expect(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') diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index 662feed95f66..d4441044aaaa 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -2,6 +2,8 @@ // effort), the older model list, and its `/` commands — as Orca's option and command surfaces. // Kept as the agent last reported it; nothing here is guessed or listed by Orca itself. +import { abortSignalReason } from '../../shared/abort-signal-reason' + import type { AgentSessionModelOption, AgentSessionOptionChoice, @@ -243,7 +245,7 @@ export function writeAcpSessionOption( ): Promise { const { agent, timeoutMs, signal } = bound if (signal?.aborted) { - return Promise.reject(new Error(`${agent} option write abandoned before it was sent`)) + return Promise.reject(abortSignalReason(signal)) } const applied = write.method === 'config' @@ -256,7 +258,11 @@ export function writeAcpSessionOption( settle() reject(error) } - const onAbort = (): void => fail(new Error(`${agent} option write abandoned`)) + const onAbort = (): void => { + if (signal) { + fail(abortSignalReason(signal)) + } + } const timer = setTimeout( () => fail(new Error(`${agent} did not answer an option write within ${timeoutMs}ms`)), timeoutMs diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 2707cbc93447..54c542649140 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -205,6 +205,48 @@ describe('ACP structured session adapter: turns', () => { ]) }) + it('keeps legacy sends on the current selection when execution options are absent', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'override', + body: hello, + executionOptions: { model: 'grok-4.6', effort: 'low' } + }) + await rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'legacy', + body: hello + }) + expect(rig.sent('session/set_config_option')).toHaveLength(2) + expect(rig.sent('session/prompt')).toHaveLength(1) + }) + + it('refuses an omitted effort whose new model default cannot be confirmed', async () => { + const rig = await openAcpAdapterRig() + await rig.acquire() + await rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'override-effort', + body: hello, + executionOptions: { model: 'grok-4.7', effort: 'low' } + }) + await expect( + rig.adapter.dispatch({ + sessionId: SESSION, + fence: 1, + clientMessageId: 'unknown-default', + body: hello, + executionOptions: { model: 'grok-4.6' } + }) + ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) + expect(rig.sent('session/prompt')).toHaveLength(1) + }) + it('does not send a prompt when the agent refuses a message’s model', async () => { const rig = await openAcpAdapterRig({ script: (agent) => diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 1f4caa8a9b2d..bc23a94d7320 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -27,6 +27,7 @@ import type { ProviderStartAttempt } from '../provider-process/provider-acquisit import { waitForAcpExit, type AcpStructuredConnection } from './acp-structured-connection' import { AcpConnectionClosedError } from './acp-errors' import { applyAcpMessageOptions } from './acp-message-execution-options' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { awaitAcpTurnEnd, interruptAcpTurn } from './acp-structured-stop' import { acpDispatchPrompt } from './acp-prompt-content' import { @@ -179,18 +180,20 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte // The child ended while its attachments were read: nothing left Orca. return this.rejected(session, 'providerExited') } + const { sessionId, fence, signal } = input const optionsApplied = await applyAcpMessageOptions( session, input.executionOptions, - (key, value) => - this.setOption({ sessionId: input.sessionId, fence: input.fence, key, value }), + (key, value) => this.setOption({ sessionId, fence, key, value, signal }), () => this.children.sessions.get(input.sessionId) === session && session.journalClosed === null ) if (!optionsApplied) { return this.rejected(session, 'providerExited') } + input.signal?.throwIfAborted() await input.beforeDispatch?.() + input.signal?.throwIfAborted() session.turns.dispatch({ clientMessageId: input.clientMessageId, prompt, @@ -239,7 +242,9 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte const session = this.live(input.sessionId) const write = session.options.write(input.key, input.value) if (!write) { - throw new Error(`${session.spec.agent} offers no session option named ${input.key}`) + throw new AgentSessionOptionRejectedError( + `${session.spec.agent} offers no session option named ${input.key}` + ) } session.options.notePick(input.key) // Bounded, and abandoned by a close or Stop: the session's queue waits on it. diff --git a/src/main/claude/claude-structured-message-options.ts b/src/main/claude/claude-structured-message-options.ts index c33bd2e5e92b..8f96f77e8753 100644 --- a/src/main/claude/claude-structured-message-options.ts +++ b/src/main/claude/claude-structured-message-options.ts @@ -1,27 +1,39 @@ +import { dispatchClaudeTurn } from './claude-structured-dispatch' +import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { listedModels } from './claude-structured-model-catalog' import { setClaudeStructuredOption } from './claude-structured-options' import { readClaudeCurrentModel } from './claude-structured-session-options' import type { ClaudeSession } from './claude-structured-session-state' +import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' /** Applies the accepted message's choices while its prompt is still in the SDK input queue. */ export async function applyClaudeMessageOptions( session: ClaudeSession, options: AgentSessionExecutionOptions, - timeoutMs: number | undefined + timeoutMs: number | undefined, + signal?: AbortSignal ): Promise { - const model = options.model ?? (await defaultModelForMessage(session, timeoutMs)) + throwIfSignalAborted(signal) + const model = options.model ?? (await defaultModelForMessage(session, timeoutMs, signal)) if (model && readClaudeCurrentModel(session).id !== model) { - await setClaudeStructuredOption(session, { key: 'model', value: model }, timeoutMs) + await waitForPromiseWithSignal( + setClaudeStructuredOption(session, { key: 'model', value: model }, timeoutMs), + signal + ) } + throwIfSignalAborted(signal) const effort = options.effort ?? session.configuredDefaults?.effort if ( effort && (session.options.get('effort') !== effort || !session.confirmedOptions.has('effort')) ) { - await setClaudeStructuredOption(session, { key: 'effort', value: effort }, timeoutMs) + await waitForPromiseWithSignal( + setClaudeStructuredOption(session, { key: 'effort', value: effort }, timeoutMs), + signal + ) if ( effort !== 'max' && !session.confirmedOptions.has('effort') && @@ -36,22 +48,28 @@ export async function applyClaudeMessageOptions( throw new AgentSessionOptionRejectedError('Claude did not report its configured effort') } + throwIfSignalAborted(signal) const fastMode = options.fastMode ?? 'false' if ( (session.options.get('fastMode') !== fastMode || (session.fastModeAtStart && !session.confirmedOptions.has('fastMode'))) && (options.fastMode || session.options.has('fastMode')) ) { - await setClaudeStructuredOption(session, { key: 'fastMode', value: fastMode }, timeoutMs) + await waitForPromiseWithSignal( + setClaudeStructuredOption(session, { key: 'fastMode', value: fastMode }, timeoutMs), + signal + ) if (session.options.get('fastMode') !== fastMode) { throw new AgentSessionOptionRejectedError(`Claude did not apply Fast mode ${fastMode}`) } } + throwIfSignalAborted(signal) } async function defaultModelForMessage( session: ClaudeSession, - timeoutMs: number | undefined + timeoutMs: number | undefined, + signal?: AbortSignal ): Promise { if (session.configuredDefaults?.model) { return session.configuredDefaults.model @@ -59,7 +77,11 @@ async function defaultModelForMessage( if (!session.options.has('model')) { return undefined } - const response = await session.connection.supportedModels({ timeoutMs }).catch(() => null) + const response = await waitForPromiseWithSignal( + session.connection.supportedModels({ timeoutMs }), + signal + ).catch(() => null) + throwIfSignalAborted(signal) const listed = listedModels(response ? { models: response } : null) const fallback = listed.find((model) => model.isDefault)?.id if (!fallback) { @@ -67,3 +89,28 @@ async function defaultModelForMessage( } return fallback } + +export function dispatchClaudeMessage( + session: ClaudeSession, + input: Parameters[0], + timeoutMs: number | undefined +): ReturnType { + const messageOptions = input.executionOptions + return dispatchClaudeTurn( + session, + input, + messageOptions === undefined + ? input.beforeDispatch + : async () => { + await input.beforeDispatch?.() + try { + await applyClaudeMessageOptions(session, messageOptions, timeoutMs, input.signal) + input.signal?.throwIfAborted() + } catch (error) { + throw error instanceof AgentSessionOptionRejectedError + ? error + : new AgentSessionOptionRejectedError(error) + } + } + ) +} diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 384673af0171..25ea3b75254c 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -6,14 +6,12 @@ import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { stopCurrentClaudeBackgroundTasks } from './claude-structured-control-actions' -import { dispatchClaudeTurn } from './claude-structured-dispatch' import { claudeHoldsDispatch } from './claude-command-lifecycle' import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' import { setClaudeStructuredSessionOption } from './claude-structured-options' -import { applyClaudeMessageOptions } from './claude-structured-message-options' -import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { dispatchClaudeMessage } from './claude-structured-message-options' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' import { ClaudeAcquisitionRegistry, @@ -164,26 +162,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda this.sessions.get(sessionId)?.prompts.bindJournalItemId(journalItemId, promptKey) } - dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => { - const session = this.session(input.sessionId) - const messageOptions = input.executionOptions - return dispatchClaudeTurn( - session, - input, - messageOptions === undefined - ? input.beforeDispatch - : async () => { - await input.beforeDispatch?.() - try { - await applyClaudeMessageOptions(session, messageOptions, this.deps.requestTimeoutMs) - } catch (error) { - throw error instanceof AgentSessionOptionRejectedError - ? error - : new AgentSessionOptionRejectedError(error) - } - } - ) - } + dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => + dispatchClaudeMessage(this.session(input.sessionId), input, this.deps.requestTimeoutMs) compact: NonNullable = (input) => dispatchClaudeCommand(this.session(input.sessionId), input.command) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 27cc10206398..dc239fdb91b7 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -1,6 +1,5 @@ import * as codexRewind from './codex-structured-rewind' -import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' -import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { isCodexAppServerRequestError } from './codex-app-server-connection' import type { @@ -225,20 +224,19 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap turnId ) - async dispatch(input: { - sessionId: string - clientMessageId: string - body: AgentJournalMessageItem - executionOptions?: AgentSessionExecutionOptions - fence: number - requestedAt?: number - beforeDispatch?: () => Promise - }): Promise { + async dispatch( + input: Parameters[0] + ): Promise { const session = this.session(input.sessionId) session.dispatchPending = true try { await input.beforeDispatch?.() - const turnOptions = await resolvedCodexMessageOptions(session, input.executionOptions) + input.signal?.throwIfAborted() + const turnOptions = await waitForPromiseWithSignal( + resolvedCodexMessageOptions(session, input.executionOptions), + input.signal + ) + input.signal?.throwIfAborted() return await dispatchCodexTurn(session, { ...input, turnOptions }, this.deps.requestTimeoutMs) } finally { session.dispatchPending = false 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..a92dee125dc7 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 @@ -4,21 +4,21 @@ // instead of waiting behind a provider that may never answer. 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 /** For the attach about to run; `end` once it settles. */ - begin(sessionId: string): { signal: AbortSignal; end: () => void } { + begin(sessionId: string, handover = false): { signal: AbortSignal; end: () => void } { const controller = new AbortController() if (this.quitReason) { controller.abort(this.quitReason) } - this.inFlight.set(sessionId, controller) + this.inFlight.set(sessionId, { controller, handover }) return { signal: controller.signal, end: () => { - if (this.inFlight.get(sessionId) === controller) { + if (this.inFlight.get(sessionId)?.controller === controller) { this.inFlight.delete(sessionId) } } @@ -26,14 +26,17 @@ 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) + abort(sessionId: string, reason: string | Error): boolean { + const active = this.inFlight.get(sessionId) + const stoppedHandover = active?.handover === true && !active.controller.signal.aborted + active?.controller.abort(typeof reason === 'string' ? new Error(reason) : reason) + return stoppedHandover } /** 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()) { + for (const { controller } of this.inFlight.values()) { 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 8e909cbdc7ea..15a9b7df110f 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 @@ -306,6 +306,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & body: AgentJournalMessageItem /** Immutable choices of this submission; adapters apply them only to this dispatch. */ executionOptions?: AgentSessionExecutionOptions + /** Host-local cancellation while choices are applied before prompt admission. */ + signal?: AbortSignal fence: number /** Host clock on the submission row this send came from; the origin the turn * it opens records as `requestedAt`. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index b6cd948fa4dc..2eea6a534293 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -33,6 +33,11 @@ import { type StructuredAgentSessionStopWindDown } from './structured-agent-session-stop-wind-down' import { isMainAgentWorking, performCancel } from './structured-agent-session-turns-cancel' +import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' +import { + STOP_NOTE_CANCELLATION_REQUESTED, + structuredAgentSessionStopNoteScope +} from './structured-agent-session-turn-stop-notes' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' type ChatStopOutcome = TurnOutcome @@ -44,7 +49,7 @@ export type StructuredAgentSessionChatStopRun = { outcome: ChatStopOutcome; ends export function mutateWithChatStop( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, - params: { envelope: AgentSessionMutationEnvelope; turnId?: string }, + params: { envelope: AgentSessionMutationEnvelope; turnId?: string; stoppedPrePrompt?: boolean }, plan: MutationPlan, run: ( ctx: AgentSessionTurnContext, @@ -86,6 +91,21 @@ export function mutateWithChatStop( ) ) const child = context.sessions.get(ctx.sessionId)?.child + const recordRequest = () => { + const stoppedTurn = turnId ?? ctx.journal.activeTurnId() + return ctx.journal.appendItem( + structuredAgentSessionStopNoteIdentity(stoppedTurn ?? envelope.clientOperationId), + { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED }, + { + fence: ctx.fence, + turnScope: structuredAgentSessionStopNoteScope( + ctx.journal, + stoppedTurn ?? undefined, + ctx.journal.liveTurnScope() + ) + } + ) + } if (child?.close) { // A close an earlier stop began: this Stop joins it, retrying the exit's proof, rather // than asking a child that takes no input to stop again. Its event, issued ahead of that @@ -120,11 +140,24 @@ export function mutateWithChatStop( const record = context.deps.store.getRecord(ctx.sessionId) if (!child || !inFlight) { // Nothing to interrupt, so the answer may wait for the withdrawal. + const noteWrite = params.stoppedPrePrompt + ? recordRequest().catch((error: unknown) => + context.deps.logger.warn('writing the Stop note failed', { + scope: 'chat-stop-note', + sessionId, + error + }) + ) + : Promise.resolve() const withdrewAny = await withdrew - if (withdrewAny) { + if (withdrewAny || params.stoppedPrePrompt) { await tookEffect() } - return { ok: true, value: { ...named, cancelled: withdrewAny } } + await noteWrite + return { + ok: true, + value: { ...named, cancelled: withdrewAny || params.stoppedPrePrompt === true } + } } const reach = hadQueued ? 'unrecorded' : stopReachesUnrecordedWork(ctx, turnId) const priorStop = ctx.journal.stopMarks.latest() 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 2f53fa7ef76a..be29deec69b2 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 @@ -42,6 +42,7 @@ import type { StructuredAgentSessionStartReadiness } from './structured-agent-se import { holdRestartedStructuredAgentSessionSends } from './structured-agent-session-host-lifetime' import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' import { waitsForCodexTurnOptions } from './structured-agent-session-message-options' +import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap @@ -75,10 +76,9 @@ export type StructuredAgentSessionDeliveryLoopDeps = { /** A person's Stop is still ending the session's work: the status feed's own reading. */ stopping: (sessionId: string) => boolean now: () => number + acquireAborts: Pick } -type Step = 'continue' | 'stop' - type RefusedStart = Extract type Prepared = @@ -232,7 +232,7 @@ export class StructuredAgentSessionDeliveryLoop { sessionId: string, awaited: StructuredAgentSessionProviderChildIdentity | null, waited: boolean - ): Promise { + ): Promise<'continue' | 'stop'> { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { return this.stop(sessionId) @@ -297,7 +297,8 @@ export class StructuredAgentSessionDeliveryLoop { failureTextContext: this.deps.failureTextContext(sessionId), record: () => this.deps.record(sessionId), childWork: () => this.deps.readChildWork(sessionId), - now: this.deps.now + now: this.deps.now, + acquireAborts: this.deps.acquireAborts }, next ) 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 8ea0b209246e..ef168d7ce0b3 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 @@ -26,6 +26,7 @@ import { structuredAgentSessionFailureWordsContext } from './structured-agent-se import { recoverStructuredRewind } from './structured-rewind-recovery' import { retireOutgrownStructuredAgentSessionChild } from './structured-agent-session-outgrown-child' import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' +import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' export type StructuredAgentSessionConversationDelivery = { loop: StructuredAgentSessionDeliveryLoop @@ -52,6 +53,7 @@ export type StructuredAgentSessionConversationDelivery = { export function createStructuredAgentSessionConversationDelivery(input: { deps: StructuredAgentSessionHostDeps + acquireAborts: Pick sessions: Map serialize: (sessionId: string, task: () => Promise) => Promise trackStart: (start: Promise) => Promise @@ -122,7 +124,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { record: (sessionId) => deps.store.getRecord(sessionId), readChildWork: input.clientDelivery.readChildWork, stopping: input.clientDelivery.readStopping, - now: () => deps.now?.() ?? Date.now() + now: () => deps.now?.() ?? Date.now(), + acquireAborts: input.acquireAborts }) const adoptOpened = async ( sessionId: string, 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 e695ca8af89b..6501040da1a7 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 @@ -125,12 +125,13 @@ export function cancelStructuredAgentSessionTurn( } const plan = cancelPlan(params) const { prompt } = params - if (!prompt && params.turnId === undefined) { - abortAcquireForStop(context, caller, params.envelope, plan) - } + const stoppedPrePrompt = + !prompt && params.turnId === undefined + ? abortAcquireForStop(context, caller, params.envelope, plan) + : false // A card's Cancel stops whatever the chat has in flight, as the Stop button does; it reaches the // Stop only for a card the live turn raised (`cancelStructuredAgentSessionPrompt`). - const stopped = prompt ? { envelope: params.envelope } : params + const stopped = prompt ? { envelope: params.envelope } : { ...params, stoppedPrePrompt } return mutateWithChatStop(context, caller, stopped, plan, (ctx, stop) => prompt ? cancelStructuredAgentSessionPrompt( @@ -151,7 +152,7 @@ function abortAcquireForStop( caller: StructuredAgentSessionCaller, envelope: AgentSessionMutationEnvelope, plan: MutationPlan -): void { +): boolean { const { store } = context.deps if ( !agentSessionMutationAdmitsNow({ @@ -162,9 +163,9 @@ function abortAcquireForStop( now: context.now }) ) { - return + return false } - context.acquireAborts.abort(envelope.sessionId, 'stopped while starting') + return context.acquireAborts.abort(envelope.sessionId, 'stopped while starting') } export function respondToStructuredAgentSessionPrompt( 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..c8c5efd1eae9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -6,11 +6,10 @@ import { StructuredConversationCommandController } from './structured-conversati // only through `conversation`, which opens it at rest; an agent is started only by work that needs // it, and the idle sweep is the one thing that puts it to rest. -import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type * as SessionWire from '../../../shared/agent-session-wire' import { createRestartReconciler } from './structured-agent-session-restart-reconcile' -import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' import * as reveal from './structured-agent-session-reveal' @@ -117,6 +116,7 @@ export class StructuredAgentSessionHost { }) this.conversationDelivery = conversation.createStructuredAgentSessionConversationDelivery({ deps, + acquireAborts: this.runtimeState.acquireAborts, sessions: this.sessions, serialize: (sessionId, task) => this.serialize(sessionId, task), // Quit drains a delivery start before it evicts, so the child it produces is stopped. @@ -332,7 +332,7 @@ export class StructuredAgentSessionHost { /** The fully reduced timeline, for readers that cannot tolerate a page's ambiguity — rows are * revised or tombstoned in place, so an item's ABSENCE from a bounded page proves nothing. */ - journalSnapshot = async (sessionId: string): Promise => + journalSnapshot = async (sessionId: string) => (await this.lifetime.conversation(sessionId)).journal.snapshot() subscribe = (input: AgentSessionSubscribeInput) => this.backgroundTasks.subscribe(input) 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 8fab9ed810db..da1a4d5c21e0 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 @@ -13,6 +13,8 @@ import { import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { captureStructuredExecutionOptions } from './structured-agent-session-execution-options' import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' +import { abortSignalReason } from '../../../shared/abort-signal-reason' +import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { agentSessionFailureWords, type AgentSessionFailureWordsContext @@ -109,18 +111,34 @@ async function dispatchSafely( clientMessageId: string, body: AgentJournalMessageItem, requestedAt: number, - executionOptions: AgentSessionExecutionOptions | undefined + executionOptions: AgentSessionExecutionOptions | undefined, + signal?: AbortSignal ): Promise { try { - return await ctx.adapter.dispatch({ + const outcome = await ctx.adapter.dispatch({ sessionId: ctx.sessionId, clientMessageId, body, ...(executionOptions ? { executionOptions } : {}), + ...(signal ? { signal } : {}), fence: ctx.fence, requestedAt }) + return signal?.aborted && outcome.state === 'rejected' + ? { + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }) + } + : outcome } catch (error) { + if (signal?.aborted && error === abortSignalReason(signal)) { + return { + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + } + } if (isAgentSessionOptionRejectedError(error)) { return { state: 'rejected', @@ -215,7 +233,9 @@ export async function performSend( } } -export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverContext +export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverContext & { + acquireAborts?: Pick +} /** * Hands one queued submission to the provider. The `dispatch{pending}` row goes first: a crash @@ -249,13 +269,20 @@ export async function handOverSubmission( }) // The handover row's instant on the host clock; the turn this dispatch opens records it so the // live counter never re-anchors at turn-open. - const outcome = await dispatchSafely( - ctx, - clientMessageId, - body, - structuredAgentSessionHandoverOrigin(ctx.journal, submission), - submission.executionOptions - ) + const attempt = ctx.acquireAborts?.begin(ctx.sessionId, true) + let outcome: AgentSessionDispatchOutcome + try { + outcome = await dispatchSafely( + ctx, + clientMessageId, + body, + structuredAgentSessionHandoverOrigin(ctx.journal, submission), + submission.executionOptions, + attempt?.signal + ) + } finally { + attempt?.end() + } // An admission needs no dispatch row: the submission is already pending. if (outcome.state === 'admitted') { return diff --git a/src/main/pi/rpc-message-dispatch.ts b/src/main/pi/rpc-message-dispatch.ts index 14aa093c0520..c172ba451233 100644 --- a/src/main/pi/rpc-message-dispatch.ts +++ b/src/main/pi/rpc-message-dispatch.ts @@ -6,6 +6,7 @@ import { import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import { ClaudeDispatchContentError } from '../claude/claude-structured-dispatch-content' import { JsonlRpcResponseError } from '../jsonl-rpc/peer' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter @@ -53,12 +54,26 @@ export async function dispatchPiRpcMessage( } } const model = input.executionOptions?.model ?? session.initialOptions.model + if (!input.executionOptions) { + input.signal?.throwIfAborted() + return session.turns.submit( + input.clientMessageId, + input.requestedAt ?? Date.now(), + { ...prompt }, + input.beforeDispatch + ) + } for (const key of ['model', 'effort'] as const) { const value = input.executionOptions?.[key] ?? (key === 'model' || model === session.initialOptions.model ? session.initialOptions[key] : undefined) + if (value === undefined && session.selected.has(key)) { + throw new AgentSessionOptionRejectedError( + new Error(`The default ${key} for this model is unknown`) + ) + } if ( value === undefined || session.selected.get(key) === value || @@ -67,7 +82,10 @@ export async function dispatchPiRpcMessage( continue } try { - await applyPiRpcSessionOption(session.connection, session.selected, key, value) + await waitForPromiseWithSignal( + applyPiRpcSessionOption(session.connection, session.selected, key, value), + input.signal + ) } catch (error) { if (session.connection.closed) { return exited() @@ -81,6 +99,7 @@ export async function dispatchPiRpcMessage( throw error } } + input.signal?.throwIfAborted() if (!isCurrent() || session.connection.closed) { return exited() } diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 3825749522ba..4e0dd224f75e 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -242,6 +242,50 @@ describe('Pi RPC session ownership and delivery', () => { ]) }) + it('keeps legacy sends on the current selection when execution options are absent', async () => { + const h = await setup() + await h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'override', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'first' }] }, + executionOptions: { model: 'anthropic/model-2' } + }) + await h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'legacy', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'second' }] } + }) + expect(h.connection.requests.filter((command) => command === 'set_model')).toEqual([ + 'set_model' + ]) + expect(h.connection.sent).toHaveLength(2) + }) + + it('refuses an omitted effort whose new model default cannot be confirmed', async () => { + const h = await setup() + h.connection.requestOverride = (command) => + command === 'set_thinking_level' ? Promise.resolve({}) : undefined + await h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'override-effort', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'first' }] }, + executionOptions: { model: 'anthropic/model-1', effort: 'low' } + }) + await expect( + h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'unknown-default', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'second' }] }, + executionOptions: { model: 'anthropic/model-2' } + }) + ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) + expect(h.connection.sent).toHaveLength(1) + }) + it('applies the displayed default model and effort after different message choices', async () => { const h = await setup() h.connection.requestOverride = (command) => From b2913b203a1387199818c2241a2d9fdce5c44ada Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:24:01 -0700 Subject: [PATCH 036/127] Verify message choices through provider input boundaries --- ...claude-context-usage-unloaded-turn.test.ts | 10 +- .../claude-structured-session-adapter.test.ts | 92 +------------------ .../claude-structured-start-fast-mode.test.ts | 35 ++++++- .../codex/codex-background-terminals.test.ts | 5 +- .../codex/codex-subagent-interrupts.test.ts | 3 +- .../structured-agent-session-chat-stop.ts | 11 +-- ...-agent-session-claude-option-queue.test.ts | 33 ++++--- ...ed-agent-session-conversation-stop.test.ts | 23 +++-- ...ed-agent-session-option-settlement.test.ts | 2 +- .../structured-agent-session-options-read.ts | 9 +- ...ed-agent-session-owed-work-release.test.ts | 6 +- ...sion-startup-attempt-registrations.test.ts | 39 ++------ ...tructured-agent-session-stop-event.test.ts | 2 +- src/main/pi/rpc-session-startup.test.ts | 7 +- 14 files changed, 108 insertions(+), 169 deletions(-) diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index 6ad422fa1606..43fdcdee9c4c 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -1,3 +1,4 @@ +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -98,7 +99,14 @@ function readOptions(journal: AgentSessionJournal, agents: StructuredAgentRegist readOptions: async () => ({ models: [], current: { model: 'opus' } }) }, agents, - store: { getRecord: () => undefined } + store: { + getRecord: () => ({ + ...agentSessionRecordFixture(), + sessionId: SESSION, + provider: 'claude', + options: { model: 'opus' } + }) + } }, serialize: (_sessionId: string, task: () => Promise) => task(), openConversation: async () => running, diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 8145374dd960..0250722cb2a5 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -7,10 +7,6 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV as SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env' -import { - ClaudeControlRequestError, - ClaudeControlRequestTimeoutError -} from './claude-agent-sdk-control-requests' import type { ClaudeStructuredSessionAdapter, ClaudeStructuredSessionEvent @@ -129,97 +125,16 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { expect( claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') ).toEqual([]) - // Left out, not refused: the record takes the provider's own Fast, as before. - expect(adapter.readOptionRestoreFailures('session-1')).toEqual([]) expect(events.find((event) => event.type === 'options-reported')).toMatchObject({ - reportedOptions: { fastMode: false } + reportedOptions: { fastMode: true } }) }) - // No message waits on it: the launch carries no Fast, and the start applies it once the CLI's - // settings show no per-session opt-in, as the live CLI reports with no settings file at all. - it('applies a saved Fast on to a new conversation once its settings allow it, and keeps the pick', async () => { + it('leaves Fast for message input when the launch does not carry it', async () => { const claude = fakeClaude({ settings: { applied: {}, effective: {}, sources: {} }, initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }] }) - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = adapterFor(claude, {}, events) - - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - options: { model: 'opus', fastMode: 'true' } - }) - - expect(claude.connections[0].launch.options.settings).toBeUndefined() - expect(claude.connections[0].calls.map((call) => call.subtype)).toEqual([ - 'initialize', - 'get_settings', - 'apply_flag_settings' - ]) - expect(claude.connections[0].calls.at(-1)).toEqual({ - subtype: 'apply_flag_settings', - params: { settings: { fastMode: true } } - }) - expect(await claudeOpened(adapter, 'session-1')).toMatchObject({ - reportedOptions: { fastMode: true }, - restoreSkippedOptions: [] - }) - }) - - it.each([ - ['refuses', new ClaudeControlRequestError('apply_flag_settings', 'not for this org'), false], - ['never answers', new ClaudeControlRequestTimeoutError('apply_flag_settings'), true] - ] as const)( - 'settles a saved Fast on a new conversation when the CLI %s its apply', - async (_case, failure, kept) => { - let answerApply: (error: Error) => void = () => {} - const claude = fakeClaude({ - settings: { applied: {}, effective: {}, sources: {} }, - initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: true }], - routes: { - apply_flag_settings: () => new Promise((_, reject) => (answerApply = reject)) - } - }) - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = adapterFor(claude, {}, events) - - await adapter.acquire({ - identity: identityFor(), - fence: 7, - spawnToken: 'spawn-9', - options: { model: 'opus', fastMode: 'true' } - }) - - // Started, with the pick, while the apply is still unanswered: nothing waits on it. - expect(claude.connections[0].calls.at(-1)?.subtype).toBe('apply_flag_settings') - expect(await claudeOpened(adapter, 'session-1')).toMatchObject({ - reportedOptions: { fastMode: true }, - restoreSkippedOptions: [] - }) - 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' } })] - ) - expect(adapter.readOptionRestoreFailures('session-1')).toEqual(kept ? [] : ['fastMode']) - const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) - expect(options.current.fastMode === true).toBe(kept) - expect(options.current.confirmed ?? []).not.toContain('fastMode') - } - ) - - it('drops a saved Fast on from a new conversation whose model the CLI lists without it', async () => { - const claude = fakeClaude({ - settings: { applied: {}, effective: {}, sources: {} }, - initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: false }] - }) const adapter = adapterFor(claude) await adapter.acquire({ @@ -228,11 +143,12 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { spawnToken: 'spawn-9', options: { model: 'opus', fastMode: 'true' } }) + await claudeOpened(adapter, 'session-1') + expect(claude.connections[0].launch.options.settings).toBeUndefined() expect( claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') ).toEqual([]) - expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['fastMode']) }) // The chat's permission mode is the start's, never a saved option the CLI is handed. diff --git a/src/main/claude/claude-structured-start-fast-mode.test.ts b/src/main/claude/claude-structured-start-fast-mode.test.ts index 24057e865fca..16f9693f0117 100644 --- a/src/main/claude/claude-structured-start-fast-mode.test.ts +++ b/src/main/claude/claude-structured-start-fast-mode.test.ts @@ -1,11 +1,23 @@ // A saved Fast the launch leaves out beside the agent Arguments' own `--settings`, applied by the -// start once the child answers. +// message input once the child answers. import { expect, it, vi } from 'vitest' -import { adapterFor, fakeClaude, identityFor } from './claude-structured-session-test-support' +import { + adapterFor, + fakeClaude, + identityFor, + claudeOpened, + USER_MESSAGE +} from './claude-structured-session-test-support' -it("applies a resumed conversation's saved Fast off after start beside the Arguments' settings", async () => { +it("applies a resumed conversation's saved Fast off before message input beside the Arguments' settings", async () => { + let fastMode = true const claude = fakeClaude({ + routes: { + apply_flag_settings: () => { + fastMode = false + } + }, settings: { effective: { fastMode: true, fastModePerSessionOptIn: false } } }) const adapter = adapterFor(claude, { @@ -25,8 +37,23 @@ it("applies a resumed conversation's saved Fast off after start beside the Argum expect(claude.connections[0].launch.options.extraArgs).toEqual({ settings: '/repo/claude.json' }) + await claudeOpened(adapter, 'session-1') + claude.connections[0].getSettings = async () => ({ + effective: { fastMode, fastModePerSessionOptIn: false } + }) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'message-fast', + body: USER_MESSAGE, + executionOptions: { fastMode: 'false' }, + fence: 7 + }) + ).resolves.toMatchObject({ state: 'admitted' }) await vi.waitFor(() => - expect(claude.connections[0].calls.at(-1)).toEqual({ + expect( + claude.connections[0].calls.find((call) => call.subtype === 'apply_flag_settings') + ).toEqual({ subtype: 'apply_flag_settings', params: { settings: { fastMode: false } } }) diff --git a/src/main/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, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index 2eea6a534293..2706302f938e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -34,10 +34,7 @@ import { } from './structured-agent-session-stop-wind-down' import { isMainAgentWorking, performCancel } from './structured-agent-session-turns-cancel' import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn' -import { - STOP_NOTE_CANCELLATION_REQUESTED, - structuredAgentSessionStopNoteScope -} from './structured-agent-session-turn-stop-notes' +import { STOP_NOTE_CANCELLATION_REQUESTED } from './structured-agent-session-turn-stop-notes' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' type ChatStopOutcome = TurnOutcome @@ -98,11 +95,7 @@ export function mutateWithChatStop( { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED }, { fence: ctx.fence, - turnScope: structuredAgentSessionStopNoteScope( - ctx.journal, - stoppedTurn ?? undefined, - ctx.journal.liveTurnScope() - ) + turnScope: ctx.journal.liveTurnScope() } ) } 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..ebd23c423e30 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 { @@ -63,7 +64,6 @@ function adapter(): StructuredAgentSessionAdapter { 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: { @@ -76,6 +76,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 +103,7 @@ beforeEach(async () => { resetHostTestOperationIds() activeModel = DEFAULT_MODEL order = [] + releaseOpen = () => {} store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ agents: claudeAndCodexDeclared(), @@ -125,7 +135,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('captures picks made while opening for the first message', async () => { const pick = (key: string, value: string) => host.setOption(CALLER, { envelope: envelope('agentSession.setOption', { key, value }), @@ -144,19 +154,14 @@ 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(dispatch.mock.calls[0]?.[0].executionOptions).toEqual({ + model: PICKED_MODEL, + effort: 'high' + }) + 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-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index 294faaa86d0d..790865387d89 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,11 @@ describe('a Stop that names no turn', () => { cancelTurn.mockImplementation((input) => adapter.cancelTurn(input)) setOption.mockImplementation((input) => adapter.setOption(input)) + const listing = adapter.readOptions({ sessionId: SESSION, fence: 1 }) + const catalogReads = codex.connections[0].calls.filter( + (call) => call.method === 'model/list' + ).length + await pickOption('serviceTier', 'priority') const first = send('first send') await first.result await eventually(() => @@ -225,7 +231,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') @@ -248,6 +254,7 @@ describe('a Stop that names no turn', () => { codex.connections[0].calls.filter((call) => call.method === 'turn/start')[1]?.params ).toMatchObject({ model: 'gpt-next', 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 @@ -273,7 +280,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', @@ -315,7 +322,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', @@ -344,7 +351,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', @@ -384,7 +391,11 @@ describe('a Stop that names no turn', () => { const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog: new AgentModelCatalogStore() }) - await adapter.acquire({ identity: identityFor(SESSION), fence: 1, spawnToken: 'spawn-bad' }) + await acquireReadyCodexForTest(adapter, { + identity: identityFor(SESSION), + fence: 1, + spawnToken: 'spawn-bad' + }) dispatch.mockImplementation((input) => adapter.dispatch(input)) setOption.mockImplementation((input) => adapter.setOption(input)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index a25de059ee78..fc2e3b89ab7a 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 @@ -195,7 +195,7 @@ describe('structured session options and close', () => { await host.close(SESSION, 'evict') - expect(closeNativeSession).toHaveBeenCalledWith(SESSION, 'evict') + expect(closeNativeSession).toHaveBeenCalledWith(SESSION) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null, 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 b2293f125ba1..e6bff05ff6d2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -1,9 +1,8 @@ +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 — -// and the list is the host's model catalog, the same one the picker already reads before a chat -// exists. A pick made at rest is written to the record as intent, through the same transition a live -// pick takes, so the next start applies it. +// The record keeps the next message's choices; the catalog names known defaults. A provider's +// reports remain execution facts and never replace the saved selection. import { refuse, @@ -222,7 +221,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 { 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 f4ffba515285..21244820e413 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -201,12 +201,10 @@ describe('a Claude chat whose CLI is still starting', () => { }) it('gives a message held while starting a full idle window after startup lands to open its turn', async () => { + claude = fakeClaude({ replayUuid: null }) await attachStarting() const connection = claude.connections[0] // Claude echoes a prompt only when it starts that turn, which a loaded machine delays. - connection.send = async (message) => { - connection.sent.push(message) - } const held = await send('sent while starting') expect(connection.sent).toEqual([]) expect(await dispatchState(held)).toBe('pending') @@ -234,7 +232,7 @@ describe('a Claude chat whose CLI is still starting', () => { await host.flushStreamedEvents(SESSION) await waitOutSeveralSweeps() expect(host.hasSession(SESSION)).toBe(true) - expect(await dispatchState(held)).toBe('accepted') + await vi.waitFor(async () => expect(await dispatchState(held)).toBe('accepted')) }) it('stops the agent and closes the conversation once its turn has finished and it idled', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts index 64c681c9f9ed..8c55910b3de7 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 @@ -32,13 +32,6 @@ import { createStructuredAgentSessionLogger } from './structured-agent-session-l const CALLER = { callerKey: 'client-1' } -// Temporary, removed when every adapter publishes at spawn: adapters whose own acquire already does, -// with the test that drives the real one. Every other registration still runs its handshake inside -// acquire (the bridge) until then. -const REAL_ADAPTER_PUBLISHES_AT_SPAWN: Readonly> = { - claude: 'structured-agent-session-claude-hung-start-stop.test.ts' -} - let root: string | null = null afterEach(async () => { @@ -64,6 +57,7 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ store: AgentSessionRecordStore acquire: Mock dispatch: Mock + ready: () => void }> { root = await mkdtemp(join(tmpdir(), 'orca-startup-registrations-')) resetHostTestOperationIds() @@ -90,7 +84,12 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ const dispatch: Mock = vi.fn(async () => ({ state: 'admitted' as const })) + let ready = () => {} const adapter: StructuredAgentSessionAdapter = { + open: () => + new Promise((resolve) => { + ready = () => resolve({ reportedOptions: {}, restoreSkippedOptions: [], optionRevision: 0 }) + }), acquire, dispatch, closeSession: vi.fn(async () => true), @@ -114,7 +113,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, ready: () => ready() } } describe.each( @@ -123,7 +122,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, ready } = await hostFor(definition) try { const attached = await host.attach( CALLER, @@ -160,21 +159,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', - reportedOptions: { model: 'default' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) - }) + ready() await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) } finally { @@ -182,11 +171,3 @@ describe.each( } }) }) - -describe.each( - STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent).filter( - (agent) => !REAL_ADAPTER_PUBLISHES_AT_SPAWN[agent] - ) -)('%s with its own adapter', () => { - it.todo('publishes at spawn instead of finishing its handshake first') -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index 424d1fd13c96..95d62a6baa80 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -239,7 +239,7 @@ describe("a Stop's event", () => { it('naming no turn, names the turn running when it takes effect', async () => { rig = await createQueuedMessageTestRig() - await rig.workingSend() + const working = await rig.workingSend() await turnRow('turn-1', 'running', working) await rig.stop() expect(stopEvents()).toMatchObject([{ reason: 'user-stop', turnId: 'turn-1' }]) diff --git a/src/main/pi/rpc-session-startup.test.ts b/src/main/pi/rpc-session-startup.test.ts index eb426dd06b2e..eb36ecfcd1cf 100644 --- a/src/main/pi/rpc-session-startup.test.ts +++ b/src/main/pi/rpc-session-startup.test.ts @@ -11,7 +11,7 @@ import { afterEach(closePiRpcAdapterFixtures) describe('Pi host awaited open', () => { - it('owns the process before RPC and restores model before effort during open', async () => { + it('owns the process before RPC and leaves message choices for input', async () => { const h = await setup() const stateReply = Promise.withResolvers() const { acquired, child } = await restart( @@ -39,9 +39,8 @@ describe('Pi host awaited open', () => { await expect(opening).resolves.toMatchObject({ reportedOptions: { model: 'anthropic/model-1' } }) - expect(child.requests.indexOf('set_model')).toBeLessThan( - child.requests.indexOf('set_thinking_level') - ) + expect(child.requests).not.toContain('set_model') + expect(child.requests).not.toContain('set_thinking_level') expect(h.lifecycle).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) }) From e200450039192d70d35c14c82fff305b4973c86c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:31:20 -0700 Subject: [PATCH 037/127] Keep legacy mobile test hosts explicit about message choices --- .../mobile-native-chat-merged-snapshot-parity-hooks.test.tsx | 1 + .../use-mobile-structured-agent-session-prompt-cancel.test.tsx | 1 + .../use-mobile-structured-agent-session-queued.test-fixture.ts | 1 + mobile/src/session/use-mobile-structured-turn-indicator.test.tsx | 1 + 4 files changed, 4 insertions(+) diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx index 5e933c94e49b..1d50b12df350 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx @@ -90,6 +90,7 @@ const HOST_SUPPORT = { queuedMessages: true, queuedCommands: false, statusFeed: true, + messageChoices: false, quietRepeatedStop: true } diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx index 7a14895fd66e..d46c48b8590d 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -124,6 +124,7 @@ function Harness({ queuedMessages: false, queuedCommands: false, statusFeed: false, + messageChoices: false, quietRepeatedStop: false }, onSendError: vi.fn() diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts index 7a0c57b9b352..41868ff7e11f 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts @@ -18,6 +18,7 @@ export const CAPABLE: StructuredAgentSessionHostSupport = { queuedMessages: true, queuedCommands: false, statusFeed: false, + messageChoices: false, quietRepeatedStop: false } export const LEGACY: StructuredAgentSessionHostSupport = { ...CAPABLE, queuedMessages: false } diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx index 79f761f5511e..8f7fa99947ff 100644 --- a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -266,6 +266,7 @@ describe("useMobileStructuredAgentSession and the host's Stopping", () => { questionAnswers: false, queuedMessages: false, queuedCommands: false, + messageChoices: false, quietRepeatedStop: false, statusFeed }, 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 038/127] 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 039/127] 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 040/127] 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 041/127] 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 042/127] 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 043/127] 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 044/127] 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 0a282e3c11aa3287ad9955c73621e3e95c69fc62 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:47:33 -0700 Subject: [PATCH 045/127] Restore signed-out restart and fail Pi startup faults --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + ...structured-agent-session-adapter-router.ts | 3 - .../structured-agent-session-adapter.ts | 8 +- ...tured-agent-session-outgrown-child.test.ts | 189 +++++++++++++----- ...structured-agent-session-outgrown-child.ts | 60 +++++- .../structured-agent-session-rest-test-rig.ts | 2 - src/main/pi/rpc-session-adapter.test.ts | 1 - src/main/pi/rpc-session-startup.test.ts | 18 ++ src/main/pi/rpc-session-startup.ts | 7 +- src/main/pi/rpc-session.ts | 8 +- ...uctured-agent-session-options-read.test.ts | 71 ------- .../structured-agent-session-options-read.ts | 8 +- ...gent-session-adapter-start-failure.test.ts | 128 ++++++++++++ 13 files changed, 352 insertions(+), 152 deletions(-) create mode 100644 src/main/runtime/structured-agent-session-adapter-start-failure.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index b0c01a5ec51b..de59ab40f710 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -251,6 +251,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-codex-start-deadline.test.ts', 'src/main/runtime/structured-agent-session-pi-startup.test.ts', 'src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts', + 'src/main/runtime/structured-agent-session-adapter-start-failure.test.ts', 'src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts', 'src/main/runtime/structured-agent-session-codex-settlement-order.test.ts', 'src/main/runtime/structured-agent-session-integration-replay.test.ts', 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..34b3b6d4009b 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 @@ -136,9 +136,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi this.liveOwnerOrNull(sessionId)?.holdsLiveProviderProcess?.(sessionId, acquisitionGeneration) ?? false - startUnavailable: NonNullable = (sessionId) => - this.liveOwnerOrNull(sessionId)?.startUnavailable?.(sessionId) - stopEndsSession = (sessionId: string): boolean => this.liveOwnerOrNull(sessionId)?.stopEndsSession?.(sessionId) ?? false 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 cd8c7e125eb8..cbfa0c9b790d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -1,7 +1,4 @@ -import type { - AgentSessionAccountKind, - AgentSessionUnavailable -} from '../../../shared/agent-session-availability' +import type { AgentSessionAccountKind } from '../../../shared/agent-session-availability' import type { AgentSessionRewindReason, AgentSessionRewindSupport @@ -381,9 +378,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** The adapter's own child for this exact acquisition has a pid and its root exit has not been * seen: first-hand proof of life for lease renewal. Absent or false falls back to a PID probe. */ holdsLiveProviderProcess?(sessionId: string, acquisitionGeneration: string): boolean - /** Why the session's running child can take no turn, as its own start found; undefined when - * unknown or no child runs. Lives and dies with that child. */ - startUnavailable?(sessionId: string): AgentSessionUnavailable | undefined /** The `/` surface the running provider reports for itself. Undefined when the * provider never reports one, which is what keeps the client on its catalog. */ readCommands?(sessionId: string): AgentSessionSlashCommand[] | undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts index 8c8d4fdb960b..216c6fa72371 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts @@ -1,9 +1,23 @@ -// An idle child launched under a different permission mode is replaced before the next send. +// A child the chat outgrew is replaced before the next send: one that reported it is not signed in, +// so a sign-in made since reaches a new child, and one launched with a permission mode the chat no +// longer has, so the new mode reaches a new child. -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { retireOutgrownStructuredAgentSessionChild } from './structured-agent-session-outgrown-child' +import { + acceptedDispatch, + createRestTestRig, + foundRestTestChat, + REST_TEST_CALLER as CALLER, + REST_TEST_SESSION as SESSION, + restTestSend, + type RestTestRig +} from './structured-agent-session-rest-test-rig' +import { + retireOutgrownStructuredAgentSessionChild, + structuredAgentSessionChildReportedSignedOut +} from './structured-agent-session-outgrown-child' import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' @@ -14,23 +28,97 @@ import type { AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import type { JournalItemLinkageVisitor } from '../agent-session-journal/journal-store-contracts' import type { StructuredAgentSessionChildWorkReads } from './structured-agent-session-idle-sweep' +describe('a send after the agent said it is not signed in', () => { + let rig: RestTestRig + + beforeEach(async () => { + rig = await createRestTestRig() + }) + + afterEach(async () => { + await rig.dispose() + }) + + const fence = () => rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 + + it('goes to a new agent, and the new agent is kept for the sends after it', async () => { + await foundRestTestChat(rig) + rig.adapter.dispatch.mockImplementationOnce(async () => ({ + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), { + surface: 'rejection' + }) + })) + expect((await rig.host.send(CALLER, restTestSend('signed out', fence()))).ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + expect(rig.adapter.acquire).toHaveBeenCalledTimes(1) + rig.adapter.closeSession.mockClear() + + expect((await rig.host.send(CALLER, restTestSend('signed in now', fence()))).ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(3)) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) + expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) + + // The earlier child's report does not follow the new one. + expect((await rig.host.send(CALLER, restTestSend('and again', fence()))).ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(4)) + expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) + }) + + it('keeps the agent after any other rejection', async () => { + await foundRestTestChat(rig) + rig.adapter.dispatch.mockImplementationOnce(async () => ({ + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { + surface: 'rejection' + }) + })) + expect((await rig.host.send(CALLER, restTestSend('refused', fence()))).ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) + rig.adapter.dispatch.mockImplementation(async () => acceptedDispatch()) + + expect((await rig.host.send(CALLER, restTestSend('next', fence()))).ok).toBe(true) + await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(3)) + expect(rig.adapter.acquire).toHaveBeenCalledTimes(1) + }) +}) + type Row = { itemId: string; fence: number; agentId?: string; body: AgentJournalItemBody } +const statusRow = ( + fence: number, + kind: 'notSignedIn' | 'providerExited', + agentId?: string +): Row => ({ + itemId: `status-${fence}-${kind}-${agentId ?? 'root'}`, + fence, + ...(agentId ? { agentId } : {}), + body: { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'row' }) + } +}) +const rejected = (fence: number) => ({ + fence, + dispatchState: 'rejected' as const, + rejection: agentSessionFailureFact('notSignedIn') +}) const childAt = ( overrides: Partial = {} ): StructuredAgentSessionProviderChild => ({ generation: 'generation-2', fence: 2, phase: 'ready', - permissionMode: 'ask', ...overrides }) const conversation = ( input: { child?: StructuredAgentSessionProviderChild | null items?: Row[] + submissions?: ReturnType[] activeTurnId?: string newestTurnEndedAt?: number } = {} @@ -39,8 +127,14 @@ const conversation = ( return { child: input.child === undefined ? childAt() : input.child, journal: { + submissions: () => input.submissions ?? [], visitItems: (visit: (itemId: string, sequence: number, body: AgentJournalItemBody) => void) => items.forEach((item, index) => visit(item.itemId, index, item.body)), + visitItemsWithLinkage: (visit: JournalItemLinkageVisitor) => + items.forEach((item, index) => + visit(item.itemId, index, item.body, item.agentId ? { agentId: item.agentId } : {}) + ), + itemFence: (itemId: string) => items.find((item) => item.itemId === itemId)?.fence, activeTurnId: () => input.activeTurnId ?? null, newestTurn: (): AgentJournalTurnLifecycle | null => input.newestTurnEndedAt === undefined @@ -50,39 +144,33 @@ const conversation = ( } } -describe('retireOutgrownStructuredAgentSessionChild', () => { - it('keeps a signed-out child until normal failure settlement closes it', async () => { - const stopAgent = vi.fn(async () => undefined) - await retireOutgrownStructuredAgentSessionChild( - 'session', - conversation({ - child: childAt({ permissionMode: undefined }), - items: [ - { - itemId: 'signed-out', - fence: 2, - body: { - kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('notSignedIn'), { - surface: 'row' - }) - } - } - ] - }), - { - work: { - childWork: () => [], - hasOpenDispatch: () => false, - providerHoldsDispatch: () => false - }, - launchPermission: async () => 'full-access', - stopAgent, - logger: { warn: vi.fn(), error: vi.fn() } - } - ) - expect(stopAgent).not.toHaveBeenCalled() +describe('structuredAgentSessionChildReportedSignedOut', () => { + const reported = (input: Parameters[0]) => + structuredAgentSessionChildReportedSignedOut(conversation(input)) + + it('reads a not-signed-in row the running child wrote', () => { + expect(reported({ items: [statusRow(2, 'notSignedIn')] })).toBe(true) + }) + + it('ignores one an earlier child or a subagent wrote, and any other failure', () => { + expect(reported({ items: [statusRow(1, 'notSignedIn')] })).toBe(false) + expect(reported({ items: [statusRow(2, 'notSignedIn', 'subagent-1')] })).toBe(false) + expect(reported({ items: [statusRow(2, 'providerExited')] })).toBe(false) + }) + + it('reads a send rejected as not signed in at this child, not at an earlier one', () => { + expect(reported({ submissions: [rejected(2)] })).toBe(true) + expect(reported({ submissions: [rejected(1)] })).toBe(false) }) + + it('leaves a child that is starting, closing, or absent', () => { + const items = [statusRow(2, 'notSignedIn')] + expect(reported({ items, child: childAt({ phase: 'starting' }) })).toBe(false) + expect(reported({ items, child: null })).toBe(false) + }) +}) + +describe('retireOutgrownStructuredAgentSessionChild', () => { const liveChildWork: AgentChildWorkView = { id: 'subagent-1', kind: 'agent', @@ -98,30 +186,34 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { hasOpenDispatch: () => false, providerHoldsDispatch: () => false } - const outgrown = { child: childAt({ permissionMode: 'ask' }) } + const signedOut = { submissions: [rejected(2)] } const retire = async ( input: Parameters[0], work: Partial = {}, stopAgent = vi.fn(async () => undefined), - launchPermission: () => Promise = async () => 'full-access' + launchPermission?: () => Promise ) => { const warn = vi.fn() await retireOutgrownStructuredAgentSessionChild('session', conversation(input), { work: { ...idle, ...work }, - launchPermission, + ...(launchPermission ? { launchPermission } : {}), stopAgent, logger: { warn, error: vi.fn() } }) return { stopAgent, warn } } + it('stops a signed-out child that owes nothing', async () => { + expect((await retire(signedOut)).stopAgent).toHaveBeenCalledWith('session') + }) + it('keeps it while it owes work the idle sweep also protects', async () => { // A pending prompt is the shared check's too; the idle sweep's tests cover that case. for (const kept of [ - await retire({ ...outgrown, activeTurnId: 'turn-1' }), - await retire(outgrown, { childWork: () => [liveChildWork] }), - await retire(outgrown, { hasOpenDispatch: () => true }), - await retire(outgrown, { providerHoldsDispatch: () => true }) + await retire({ ...signedOut, activeTurnId: 'turn-1' }), + await retire(signedOut, { childWork: () => [liveChildWork] }), + await retire(signedOut, { hasOpenDispatch: () => true }), + await retire(signedOut, { providerHoldsDispatch: () => true }) ]) { expect(kept.stopAgent).not.toHaveBeenCalled() } @@ -136,12 +228,12 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { settledAt: 200 } const after = await retire( - { ...outgrown, newestTurnEndedAt: 100 }, + { ...signedOut, newestTurnEndedAt: 100 }, { childWork: () => [settled] } ) expect(after.stopAgent).not.toHaveBeenCalled() const before = await retire( - { ...outgrown, newestTurnEndedAt: 300 }, + { ...signedOut, newestTurnEndedAt: 300 }, { childWork: () => [settled] } ) expect(before.stopAgent).toHaveBeenCalledWith('session') @@ -149,7 +241,7 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { it('logs a stop that fails and lets the send go on', async () => { const { warn } = await retire( - outgrown, + signedOut, {}, vi.fn(async () => { throw new Error('exit not proven') @@ -268,10 +360,7 @@ describe('retireOutgrownStructuredAgentSessionChild', () => { it('never reads the mode for a child launched with none', async () => { const current = now('full-access') - expect( - (await retire({ child: childAt({ permissionMode: undefined }) }, {}, undefined, current)) - .stopAgent - ).not.toHaveBeenCalled() + expect((await retire({}, {}, undefined, current)).stopAgent).not.toHaveBeenCalled() expect(current).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts index 3c0d7fff55e1..03f3a32e0c70 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.ts @@ -1,5 +1,9 @@ -// An idle child launched with another permission mode is replaced before the next send. +// A child the chat outgrew (signed out, or launched with another permission mode) is replaced before +// the next send: agents read their login and permission mode only when they start. +import { readWholeAgentSessionFailureFact } from '../../../shared/agent-session-failure' +import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -12,7 +16,48 @@ import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permiss /** What the check reads of a conversation; every read skips building a snapshot. */ type OutgrownChildReading = Pick & { - journal: Pick + journal: Pick< + AgentSessionJournal, + 'visitItems' | 'visitItemsWithLinkage' | 'itemFence' | 'activeTurnId' | 'newestTurn' + > & { + submissions(): readonly Pick[] + } +} + +const signedOut = (failure: unknown): boolean => + readWholeAgentSessionFailureFact(failure)?.kind === 'notSignedIn' + +/** The running child itself said it is not signed in through a rejected send or a row it wrote. + * Its fence scopes both to this child, so a new one has nothing. */ +export function structuredAgentSessionChildReportedSignedOut( + session: OutgrownChildReading +): boolean { + const { child, journal } = session + if (!child || child.phase === 'starting' || child.close) { + return false + } + if ( + journal + .submissions() + .some( + (submission) => + submission.dispatchState === 'rejected' && + submission.fence === child.fence && + signedOut(submission.rejection) + ) + ) { + return true + } + // The session's own agent only: a subagent's sign-in failure says nothing about its parent. + let found = false + journal.visitItemsWithLinkage((itemId, _sequence, body, attribution) => { + found ||= + body.kind === 'status' && + isRootAgentJournalItem(attribution) && + signedOut(body.failure) && + journal.itemFence(itemId) === child.fence + }) + return found } /** Child work that settled after the lead's newest turn ended: the lead may still owe the turn @@ -40,9 +85,9 @@ async function launchedWithOtherPermissionMode( return mode !== null && mode !== child.permissionMode } -/** For a caller inside the session's serialize, before it hands the next send to - * the child. Work the child still owes keeps it, as it keeps an idle one from the sweep. A stop that fails - * leaves the close begun, which the start joins. */ +/** For a caller inside the session's serialize, before it hands the next send to the child. Work + * the child still owes keeps it, as it keeps an idle one from the sweep. A stop that fails leaves + * the close begun, which the start joins. */ export async function retireOutgrownStructuredAgentSessionChild( sessionId: string, session: OutgrownChildReading | undefined, @@ -56,7 +101,10 @@ export async function retireOutgrownStructuredAgentSessionChild( ): Promise { if ( !session || - !(await launchedWithOtherPermissionMode(session.child, deps.launchPermission)) || + !( + structuredAgentSessionChildReportedSignedOut(session) || + (await launchedWithOtherPermissionMode(session.child, deps.launchPermission)) + ) || structuredAgentSessionChildHasOpenWork(session.journal, deps.work) || owesWakeUp(session.journal, deps.work.childWork()) ) { 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 21e35eebb9a8..37e13a2c71e4 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 @@ -50,7 +50,6 @@ export type RestTestAdapter = { NonNullable > holdsDispatch: Mock> - startUnavailable: Mock> readOptions: Mock> } @@ -166,7 +165,6 @@ export async function createRestTestRig( dispatch: vi.fn(async () => acceptedDispatch()), acknowledgeSessionRelease: vi.fn(), holdsDispatch: vi.fn(() => false), - startUnavailable: vi.fn(() => undefined), readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })) } const starts = structuredAgentSessionHeldStarts(() => rig.host) diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index e401cbed2096..7d08cec2014d 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -81,7 +81,6 @@ describe('Pi RPC session ownership and delivery', () => { expect(h.lifecycle).toHaveBeenCalledWith( expect.objectContaining({ type: 'ended', - startupUnproven: true, cause: 'requested-close' }) ) diff --git a/src/main/pi/rpc-session-startup.test.ts b/src/main/pi/rpc-session-startup.test.ts index eb426dd06b2e..cf54e06637a1 100644 --- a/src/main/pi/rpc-session-startup.test.ts +++ b/src/main/pi/rpc-session-startup.test.ts @@ -135,4 +135,22 @@ describe('Pi host awaited open', () => { expect(child.rootVerdict).toBe('exited') expect(h.lifecycle).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) }) + + it('fails an open if its lane fails while the state request is pending', async () => { + const h = await setup() + const stateReply = Promise.withResolvers() + const { acquired, child } = await restart(h, (connection) => { + connection.requestOverride = (command) => + command === 'get_state' ? stateReply.promise : undefined + }) + const opening = h.adapter.open({ + sessionId, + fence: 8, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + child.handlers.onClose?.(new Error('lane failed during startup')) + stateReply.resolve(state) + await expect(opening).rejects.toThrow('lane failed during startup') + }) }) diff --git a/src/main/pi/rpc-session-startup.ts b/src/main/pi/rpc-session-startup.ts index af2d5f3a10b9..d7dd34baadf3 100644 --- a/src/main/pi/rpc-session-startup.ts +++ b/src/main/pi/rpc-session-startup.ts @@ -44,7 +44,8 @@ export class PiRpcSessionStartup { private readonly deps: PiRpcSessionDeps, /** A new Pi session starts on its config's model; a resumed or forked one keeps its own. */ private readonly resolvesConfig: boolean, - private readonly setState: (state: PiRpcState) => void + private readonly setState: (state: PiRpcState) => void, + private readonly failedCause: () => Error | undefined ) {} async run( @@ -148,6 +149,10 @@ export class PiRpcSessionStartup { } private assertLive(signal: AbortSignal): void { + const failure = this.failedCause() + if (failure) { + throw failure + } if (signal.aborted || this.connection.closed) { throw new Error('Pi closed while starting') } diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts index 917b12eff7bd..406a7cfb08df 100644 --- a/src/main/pi/rpc-session.ts +++ b/src/main/pi/rpc-session.ts @@ -153,7 +153,8 @@ export class PiRpcSession { resolvesConfig, (state) => { this.lane.apply(this.turns.context.setModel(state.model ?? undefined, Date.now())) - } + }, + () => this.failedCause ) } @@ -261,15 +262,14 @@ export class PiRpcSession { observedAt: Date.now(), cause: this.requestedClose ? 'requested-close' : 'unexpected-exit', reason: cause.message, - failure: agentSessionFailureFact(this.openedLink ? 'providerExited' : 'providerStartFailed', { + failure: agentSessionFailureFact('providerExited', { detail: providerDiagnosticOf(cause) ?? providerDiagnosticOf(error) ?? (cause instanceof JsonlRpcResponseError ? providerDiagnostic(cause.message, 'person') : undefined) - }), - ...(this.openedLink ? {} : { startupUnproven: true }) + }) }) } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts index 56db442ce2c2..2efcd0e0d000 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts @@ -2,15 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import type { AgentSessionUnavailable } from '../../../../shared/agent-session-availability' import type { AgentSessionRecord } from '../../../../shared/agent-session-record' -import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' import { agentSessionRecordFixture } from '../../../../shared/agent-session-record.test-fixture' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { - PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY -} from '../../../../shared/protocol-version' import { call, clearStructuredHostStub, @@ -166,68 +160,3 @@ describe('agentSession.modelCatalog before anything built the host', () => { expect(reply).toMatchObject({ ok: false }) }) }) - -const PI_CLIENT = { - ...STRUCTURED_CLIENT, - clientCapabilities: [ - ...STRUCTURED_CLIENT.clientCapabilities, - STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, - PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY - ] -} - -describe("agentSession.modelCatalog with what the running agent's start said", () => { - const read = vi.fn(async (): Promise => ({ origin: 'unknown' })) - const startUnavailable = vi.fn((_sessionId: string): AgentSessionUnavailable | undefined => ({ - reason: 'notSignedIn' - })) - const record: AgentSessionRecord = { ...agentSessionRecordFixture(), provider: 'pi' } - const readFor = (agent: string) => - call('agentSession.modelCatalog', { agent, sessionId: record.sessionId }, PI_CLIENT) - - beforeEach(() => { - read.mockReset() - read.mockResolvedValue({ origin: 'unknown' }) - startUnavailable.mockClear() - setStructuredAgentSessionHost( - Object.assign(hostStub(), { - deps: { - modelCatalog: { read }, - store: { getRecord: () => record }, - adapter: { startUnavailable } - } - }) - ) - }) - - it("answers a Pi chat's catalog with its signed-out start", async () => { - const reply = await readFor('pi') - expect(startUnavailable).toHaveBeenCalledWith(record.sessionId) - expect(reply).toMatchObject({ - ok: true, - result: { origin: 'unknown', unavailable: { reason: 'notSignedIn' } } - }) - }) - - it('keeps the reason the catalog itself found', async () => { - read.mockResolvedValue({ origin: 'unknown', unavailable: { reason: 'cliMissing' } }) - expect(await readFor('pi')).toMatchObject({ - ok: true, - result: { unavailable: { reason: 'cliMissing' } } - }) - }) - - it("never lends one agent's start to another agent's read", async () => { - const reply = await readFor('codex') - expect(startUnavailable).not.toHaveBeenCalled() - expect(reply).toMatchObject({ ok: true, result: { origin: 'unknown' } }) - expect(reply).not.toMatchObject({ result: { unavailable: expect.anything() } }) - }) - - it('stays quiet while the start said nothing', async () => { - startUnavailable.mockReturnValueOnce(undefined) - const reply = await readFor('pi') - expect(reply).toMatchObject({ ok: true }) - expect(reply).not.toMatchObject({ result: { unavailable: expect.anything() } }) - }) -}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index 346b17ee8f0f..3d18c9b9dc31 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -88,13 +88,7 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ .catch(() => null) return catalog.read({ ...params, workspacePath }) } - const answer = await read() - // The chat's running agent can say what no listing does: a Pi that started with no model. - const started = - record?.provider === params.agent - ? host.deps.adapter.startUnavailable?.(record.sessionId) - : undefined - return started && !answer.unavailable ? { ...answer, unavailable: started } : answer + return read() } }) ] diff --git a/src/main/runtime/structured-agent-session-adapter-start-failure.test.ts b/src/main/runtime/structured-agent-session-adapter-start-failure.test.ts new file mode 100644 index 000000000000..fd2a38dab5da --- /dev/null +++ b/src/main/runtime/structured-agent-session-adapter-start-failure.test.ts @@ -0,0 +1,128 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { readWholeAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + hostTestAttachParams, + hostTestMessage, + HOST_TEST_SESSION as SESSION +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { piScriptedChild } from '../pi/pi-scripted-child.test-fixture' +import { codexScriptedChild } from '../codex/codex-scripted-child.test-fixture' +import { acpScriptedChild } from '../acp/acp-scripted-child.test-fixture' +import type { ScriptedAgentChild } from './structured-agent-scripted-child.test-fixture' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +let directory: string | undefined +let operations = 0 +const operationId = () => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` +const caller = { callerKey: 'adapter-start-failure-test' } + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (directory) { + await rm(directory, { recursive: true, force: true }) + directory = undefined + } +}) + +async function failedStart(agent: 'pi' | 'codex' | 'grok', script: ScriptedAgentChild) { + directory = await mkdtemp(join(tmpdir(), 'orca-adapter-start-failure-')) + const root = directory + const host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ account: 'managed' }), + resolveLaunchArgs: () => [], + resolveEnvironment: async () => ({ PATH: '' }), + ...script.deps + }) + const params = hostTestAttachParams( + null, + agent === 'codex' + ? { providerHandle: undefined } + : { + provider: agent, + agent, + providerHandle: undefined, + accountHome: + agent === 'pi' + ? { variable: 'PI_CODING_AGENT_DIR', path: root } + : { variable: 'GROK_HOME', path: root }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-1', + workspaceKind: 'folder' + } + } + ) + params.envelope.clientOperationId = operationId() + const attached = await host.attach(caller, params) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + const body = hostTestMessage('held during start') + const sent = await host.send(caller, { + envelope: { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: attached.value.fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + await vi.waitFor(() => expect(script.spawns()).toBe(1)) + script.failHandshake?.('provider config is invalid: bad key') + const submission = async () => { + await host.flushStreamedEvents(SESSION) + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === sent.value.clientMessageId + ) + } + await vi.waitFor(async () => expect((await submission())?.dispatchState).toBe('rejected'), { + timeout: 5_000 + }) + const settled = await submission() + const snapshot = await host.journalSnapshot(SESSION) + const failureKind = agent === 'codex' ? 'startFailed' : 'providerStartFailed' + expect(readWholeAgentSessionFailureFact(settled?.rejection)).toMatchObject({ + kind: failureKind, + detail: { text: 'provider config is invalid: bad key' } + }) + expect( + snapshot.items.filter( + (item) => + item.body.kind === 'status' && + readWholeAgentSessionFailureFact(item.body.failure)?.kind === failureKind + ) + ).toHaveLength(1) + expect(script.prompts()).toEqual([]) + expect(script.spawns()).toBe(1) +} + +it('Pi records one failure without dispatch after a failed start', async () => { + await failedStart('pi', piScriptedChild()) +}) +it('Codex records one failure without dispatch after a failed start', async () => { + await failedStart('codex', codexScriptedChild()) +}) +it('ACP records one failure without dispatch after a failed start', async () => { + await failedStart('grok', acpScriptedChild('grok')()) +}) From 6ff10a68530f2896b6d3bdc74da8ef6abee98fbf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:24:33 -0700 Subject: [PATCH 046/127] Run Pi 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 de59ab40f710..130cf64d0229 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/pi/rpc-session-adapter.test.ts', + 'src/main/pi/rpc-session-startup.test.ts', 'src/main/acp/acp-structured-publish-at-spawn.test.ts', ...SQLITE_DATABASE_RUNTIME_INCLUDE, 'src/main/acp/acp-structured-host-live-listing.test.ts', From 955f83c6832334a73326e7bf8d251ee8a373d01f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:49:33 -0700 Subject: [PATCH 047/127] Preserve message choices across cold clients and kept cards --- .../mobile-structured-option-snapshot.ts | 27 +++++++++++ ...ructured-agent-options.first-frame.test.ts | 44 +++++++++++++++--- .../use-mobile-structured-agent-options.ts | 8 ++-- .../use-mobile-structured-agent-session.ts | 7 +-- ...use-mobile-structured-send-with-outcome.ts | 7 +-- .../use-mobile-structured-sends.test.tsx | 19 ++++++-- ...-mobile-structured-session-option-stamp.ts | 24 ---------- ...mobile-structured-session-option-status.ts | 43 +++++++++++++++++ src/main/acp/acp-structured-acquire.ts | 5 +- .../acp-structured-adapter.test-support.ts | 2 - src/main/acp/acp-structured-handshake.ts | 3 -- .../acp-structured-publish-at-spawn.test.ts | 8 +--- ...claude-structured-dispatch-test-support.ts | 1 - .../claude-structured-event-delivery.ts | 7 --- .../claude/claude-structured-options.test.ts | 1 - .../claude/claude-structured-retired-model.ts | 10 ---- .../claude-structured-session-acquisition.ts | 1 - .../claude/claude-structured-session-open.ts | 4 +- .../claude-structured-session-startup.test.ts | 3 +- .../claude-structured-session-startup.ts | 29 +++--------- .../claude/claude-structured-session-state.ts | 7 +-- .../codex-structured-message-options.test.ts | 34 ++++++++++++++ .../codex-structured-publish-at-spawn.test.ts | 4 +- .../codex/codex-structured-session-start.ts | 5 +- .../agent-model-catalog-entry.ts | 6 +-- .../journal-unsent-send-hold.test.ts | 19 +++++++- .../journal-unsent-send-hold.ts | 3 ++ ...-agent-session-acquisition-options.test.ts | 11 ----- ...-session-acquisition-runtime-stamp.test.ts | 1 - .../structured-agent-session-acquisition.ts | 1 - .../structured-agent-session-adapter.ts | 19 -------- ...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 | 4 +- ...red-agent-session-conversation-lifetime.ts | 6 +-- .../structured-agent-session-create.test.ts | 4 +- ...ructured-agent-session-drivability.test.ts | 1 - ...structured-agent-session-event-recovery.ts | 4 -- ...d-agent-session-held-start.test-support.ts | 7 +-- ...ctured-agent-session-host-runtime-state.ts | 2 - .../structured-agent-session-host.ts | 1 - ...gent-session-instant-start.test-support.ts | 8 +--- ...ructured-agent-session-mutation-context.ts | 2 - ...ructured-agent-session-option-revisions.ts | 46 ------------------- ...ed-agent-session-option-settlement.test.ts | 6 +-- ...ent-session-pre-spawn-first-answer.test.ts | 1 - ...nt-session-processless-reservation.test.ts | 5 -- ...gent-session-provider-child-record.test.ts | 4 +- ...ed-agent-session-provider-open-contract.ts | 3 -- ...ctured-agent-session-provider-open.test.ts | 4 +- ...t-session-provider-started-catalog.test.ts | 4 +- ...ructured-agent-session-provider-started.ts | 12 +---- ...session-queued-message-rig.test-fixture.ts | 4 +- ...session-send-restarts-failed-start.test.ts | 4 +- ...-agent-session-startup-attempt-contract.ts | 2 - ...agent-session-startup-attempt-host.test.ts | 13 +----- ...sion-startup-attempt-registrations.test.ts | 2 +- ...ured-agent-session-startup-attempt.test.ts | 3 +- ...tructured-agent-session-startup-attempt.ts | 4 +- .../structured-conversation-command.test.ts | 4 +- src/main/pi/rpc-session-adapter.test.ts | 1 - src/main/pi/rpc-session-startup.ts | 4 -- ...ude-structured-retired-saved-model.test.ts | 42 +++++++---------- ...ured-agent-session-failure-logging.test.ts | 4 +- ...tructured-agent-session-pi-startup.test.ts | 5 +- .../structured-claude-runtime-adapter.ts | 2 +- .../NativeChatStructuredSession.tsx | 7 ++- ...se-structured-agent-session-sends.test.tsx | 35 +++++++++++++- .../use-structured-agent-session-sends.ts | 7 +-- ...-session-execution-option-snapshot.test.ts | 32 +++++++++++++ ...agent-session-execution-option-snapshot.ts | 21 +++++---- 72 files changed, 332 insertions(+), 355 deletions(-) create mode 100644 mobile/src/session/mobile-structured-option-snapshot.ts delete mode 100644 mobile/src/session/use-mobile-structured-session-option-stamp.ts create mode 100644 mobile/src/session/use-mobile-structured-session-option-status.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-option-revisions.ts create mode 100644 src/shared/agent-session-execution-option-snapshot.test.ts diff --git a/mobile/src/session/mobile-structured-option-snapshot.ts b/mobile/src/session/mobile-structured-option-snapshot.ts new file mode 100644 index 000000000000..0ef50143b92c --- /dev/null +++ b/mobile/src/session/mobile-structured-option-snapshot.ts @@ -0,0 +1,27 @@ +import { createNativeChatSessionOptionRecord } from '../../../src/shared/native-chat-session-option-state' +import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options' +import { + commitStructuredAgentSessionOptionValues, + structuredAgentSessionOptionSnapshot, + type StructuredAgentSessionOptionState +} from '../../../src/shared/structured-agent-session-options' + +/** The host's saved choice with this phone's still-pending pick on top. */ +export function mobileStructuredOptionSnapshot( + agent: string, + state: StructuredAgentSessionOptionState, + hostSelection: Readonly> | undefined +): SessionOptionDescriptor[] { + const selected = hostSelection + ? commitStructuredAgentSessionOptionValues( + { ...state, record: createNativeChatSessionOptionRecord(agent) }, + hostSelection + ) + : state + const pending = state.pendingPick + return structuredAgentSessionOptionSnapshot( + pending + ? commitStructuredAgentSessionOptionValues(selected, { [pending.id]: pending.value }) + : selected + ) +} diff --git a/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts b/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts index 0779304fb026..2e6630465146 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import type { AgentSessionModelCatalogResult } from '../../../src/shared/agent-session-wire' import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options' import type { RpcClient } from '../transport/rpc-client' @@ -9,7 +9,10 @@ import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-o const HOST_LIST: AgentSessionModelCatalogResult = { origin: 'probe', - models: [{ id: 'gpt-host', label: 'GPT Host', isDefault: true, efforts: [] }], + models: [ + { id: 'gpt-host', label: 'GPT Host', isDefault: true, efforts: [] }, + { id: 'gpt-alt', label: 'GPT Alt', isDefault: false, efforts: [] } + ], fetchedAt: 1_000 } @@ -36,11 +39,14 @@ function modelChoices(snapshot: readonly SessionOptionDescriptor[]): string[] { return model?.kind.type === 'select' ? model.kind.choices.map((choice) => choice.value) : [] } -type Props = { rpc: RpcClient; fence: number } +type Props = { rpc: RpcClient; fence: number; hostSelection?: Readonly> } async function mount(initial: Props) { const frames: string[][] = [] - const mutate: StructuredAgentSessionMutate = vi.fn() + const snapshots: SessionOptionDescriptor[][] = [] + const mutate: StructuredAgentSessionMutate = async () => new Promise(() => {}) + let pick: ReturnType['setStructuredOption'] = async () => + false function Probe(props: Props): null { const options = useMobileStructuredAgentOptions({ agent: 'codex', @@ -48,9 +54,12 @@ async function mount(initial: Props) { sessionId: 'session-1', enabled: true, fence: props.fence, + hostSelection: props.hostSelection, mutate }) frames.push(modelChoices(options.optionSnapshot)) + snapshots.push(options.optionSnapshot) + pick = options.setStructuredOption return null } let renderer: ReactTestRenderer | null = null @@ -59,6 +68,8 @@ async function mount(initial: Props) { }) return { frames, + snapshots, + pick: (id: string, value: string) => pick(id, value), update: (next: Props) => act(async () => { renderer?.update(createElement(Probe, next)) @@ -74,10 +85,10 @@ describe('the phone picker’s first frame', () => { const harness = await mount({ rpc, fence: 1 }) expect(harness.frames.every((choices) => choices.length === 0)).toBe(true) await act(async () => answer(HOST_LIST)) - expect(harness.frames.at(-1)).toEqual(['gpt-host']) + expect(harness.frames.at(-1)).toEqual(['gpt-host', 'gpt-alt']) // Only the host's list was ever offered. expect(harness.frames.filter((choices) => choices.length > 0)).toEqual( - harness.frames.filter((choices) => choices.length > 0).map(() => ['gpt-host']) + harness.frames.filter((choices) => choices.length > 0).map(() => ['gpt-host', 'gpt-alt']) ) await harness.unmount() }) @@ -86,7 +97,7 @@ describe('the phone picker’s first frame', () => { let reads = 0 const rpc = client(() => (reads++ === 0 ? Promise.resolve(HOST_LIST) : new Promise(() => {}))) const harness = await mount({ rpc, fence: 1 }) - expect(harness.frames.at(-1)).toEqual(['gpt-host']) + expect(harness.frames.at(-1)).toEqual(['gpt-host', 'gpt-alt']) const before = harness.frames.length await harness.update({ rpc, fence: 2 }) expect(harness.frames.slice(before).every((choices) => choices[0] === 'gpt-host')).toBe(true) @@ -99,4 +110,23 @@ describe('the phone picker’s first frame', () => { expect(harness.frames.at(-1)?.length).toBeGreaterThan(0) await harness.unmount() }) + + it('uses the host’s live selection when the options read has not answered', async () => { + const rpc = client(() => Promise.resolve(HOST_LIST)) + const harness = await mount({ rpc, fence: 1, hostSelection: { model: 'gpt-host' } }) + const model = harness.snapshots.at(-1)?.find((entry) => entry.id === 'model') + expect(model?.kind.currentValue).toBe('gpt-host') + await harness.unmount() + }) + + it('includes a pick still waiting for the host in the next message snapshot', async () => { + const rpc = client(() => Promise.resolve(HOST_LIST)) + const harness = await mount({ rpc, fence: 1, hostSelection: { model: 'gpt-host' } }) + await act(async () => { + void harness.pick('model', 'gpt-alt') + }) + const model = harness.snapshots.at(-1)?.find((entry) => entry.id === 'model') + expect(model?.kind.currentValue).toBe('gpt-alt') + await harness.unmount() + }) }) diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index dc08a9d31a45..d47f0d60fb4f 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -34,6 +34,7 @@ import { mobileCreatedStructuredSession } from './mobile-created-structured-sessions' import { encodeStructuredAgentSessionOptionValue } from '../../../src/shared/structured-agent-session-option-codec' +import { mobileStructuredOptionSnapshot } from './mobile-structured-option-snapshot' type StructuredOptionsController = { optionPickerRequest: { id: string; sequence: number } | null @@ -56,8 +57,9 @@ export function useMobileStructuredAgentOptions(args: { mutate: StructuredAgentSessionMutate /** Moves when the host's status says a session option changed; each move re-reads them. */ optionStamp?: string + hostSelection?: Readonly> }): StructuredOptionsController { - const { agent, client, enabled, fence, mutate, optionStamp, sessionId } = args + const { agent, client, enabled, fence, mutate, optionStamp, hostSelection, sessionId } = args // Every agent's seed, as on the desktop: a built-in list or the provider-default pill. const optionCatalog = useMemo( () => (agent ? structuredAgentSessionSeedCatalog(agent) : null), @@ -171,8 +173,8 @@ export function useMobileStructuredAgentOptions(args: { ]) const optionSnapshot = useMemo( - () => structuredAgentSessionOptionSnapshot(optionState), - [optionState] + () => mobileStructuredOptionSnapshot(agent ?? 'codex', optionState, hostSelection), + [agent, hostSelection, optionState] ) const setStructuredOption = useCallback( diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index b8649d56310f..c9ddffa50557 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -25,7 +25,7 @@ import { useMobileStructuredSessionHostStopping } from './use-mobile-structured- import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses' import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' -import { useMobileStructuredSessionOptionStamp } from './use-mobile-structured-session-option-stamp' +import { useMobileStructuredSessionOptionStatus } from './use-mobile-structured-session-option-status' import { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing' import { pendingStructuredPromptIdentity, @@ -93,7 +93,7 @@ export function useMobileStructuredAgentSession(args: { onSendError }) - const optionStamp = useMobileStructuredSessionOptionStamp({ + const optionStatus = useMobileStructuredSessionOptionStatus({ client, sessionId, enabled: enabled && connected && hostSupport?.statusFeed === true @@ -105,7 +105,8 @@ export function useMobileStructuredAgentSession(args: { enabled, fence: state.fence, mutate, - optionStamp + optionStamp: optionStatus.stamp, + hostSelection: optionStatus.selection }) const { conversationCommands, invokeStructuredOption, optionSnapshot, setStructuredOption } = options diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index 3482b1cfd9d4..62eb2fe2a256 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -141,6 +141,9 @@ export function useMobileStructuredSendWithOutcome(args: { queuedAt: Date.now(), ...(stopping ? { sentWhileStopping: true as const } : {}) } + const executionOptions = messageChoices + ? agentSessionExecutionOptionsFromSnapshot(controller.snapshot) + : undefined const outcome = await sendMobileStructuredAgentSessionMessage({ client, sessionId, @@ -150,9 +153,7 @@ export function useMobileStructuredSendWithOutcome(args: { ...(mobileStructuredSendQueues(queueCapable, stateRef.current.items) ? { delivery: 'queue-if-active' as const } : {}), - ...(messageChoices - ? { executionOptions: agentSessionExecutionOptionsFromSnapshot(controller.snapshot) } - : {}), + ...(executionOptions ? { executionOptions } : {}), deadline, clientMessageId: send.clientMessageId, onError: onSendError diff --git a/mobile/src/session/use-mobile-structured-sends.test.tsx b/mobile/src/session/use-mobile-structured-sends.test.tsx index 09385e83a905..cc10d90c1fd8 100644 --- a/mobile/src/session/use-mobile-structured-sends.test.tsx +++ b/mobile/src/session/use-mobile-structured-sends.test.tsx @@ -22,6 +22,8 @@ import { } from './use-mobile-structured-agent-session-queued.test-fixture' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' import { useMobileStructuredSends } from './use-mobile-structured-sends' +import { structuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' +import { AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' const asyncStorage = vi.hoisted(() => ({ getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() })) vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) @@ -81,7 +83,7 @@ describe('a structured send in the open chat', () => { close: () => {} } - function Harness(): null { + function Harness(props: { messageChoices?: boolean }): null { hook = useMobileStructuredAgentSession({ client, sessionId: 'session-1', @@ -89,15 +91,17 @@ describe('a structured send in the open chat', () => { enabled: true, connected: true, agent: 'codex', - hostSupport: null, + hostSupport: props.messageChoices + ? structuredAgentSessionHostSupport([AGENT_SESSION_MESSAGE_CHOICES_RUNTIME_CAPABILITY]) + : null, onSendError }) return null } - async function mount(): Promise { + async function mount(messageChoices = false): Promise { act(() => { - renderer = create(createElement(Harness)) + renderer = create(createElement(Harness, { messageChoices })) }) await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) act(() => listener?.(snapshotEvent())) @@ -168,6 +172,13 @@ describe('a structured send in the open chat', () => { expect(onSendError).not.toHaveBeenCalled() }) + it('omits choices when Send precedes the picker’s model answer', async () => { + await mount(true) + await send('before picker answer') + const call = sendRequest.mock.calls.find(([method]) => method === 'agentSession.send') + expect(call?.[1]).not.toHaveProperty('executionOptions') + }) + // Same words as a not-sent row: once recorded, the resend is one bubble and hides the earlier copy. it('resends the words of a not-sent row as one bubble, with no stray copy', async () => { const failed = recorded('failed', 'rejected', { resolvedAt: 5, submittedAt: 4 }) diff --git a/mobile/src/session/use-mobile-structured-session-option-stamp.ts b/mobile/src/session/use-mobile-structured-session-option-stamp.ts deleted file mode 100644 index c1696161e89b..000000000000 --- a/mobile/src/session/use-mobile-structured-session-option-stamp.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useCallback, useSyncExternalStore } from 'react' -import type { RpcClient } from '../transport/rpc-client' -import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed' - -const noSubscription = (): (() => void) => () => {} - -/** The host status's option values as one stamp; another device's pick moves it, off `enabled` never. */ -export function useMobileStructuredSessionOptionStamp(args: { - client: RpcClient | null - sessionId: string | null - enabled: boolean -}): string { - const { client, sessionId, enabled } = args - const feed = enabled && client && sessionId ? mobileStructuredSessionStatusFeed(client) : null - const subscribe = useCallback( - (listener: () => void) => (feed ? feed.subscribe(listener) : noSubscription()), - [feed] - ) - const readStamp = (): string => { - const summary = feed && sessionId ? feed.getSnapshot().get(sessionId) : undefined - return `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}` - } - return useSyncExternalStore(subscribe, readStamp, readStamp) -} diff --git a/mobile/src/session/use-mobile-structured-session-option-status.ts b/mobile/src/session/use-mobile-structured-session-option-status.ts new file mode 100644 index 000000000000..a5211ce922b4 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-session-option-status.ts @@ -0,0 +1,43 @@ +import { useCallback, useMemo, useSyncExternalStore } from 'react' +import { structuralValuesEqual } from '../../../src/shared/structural-value-equality' +import type { AgentSessionStatusSummary } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed' + +const noSubscription = (): (() => void) => () => {} + +type OptionStatus = { + stamp: string + selection: AgentSessionStatusSummary['optionSelection'] +} + +const EMPTY_OPTION_STATUS: OptionStatus = { stamp: '', selection: undefined } + +/** A live selection from a capable host, with the legacy stamp for older hosts. */ +export function useMobileStructuredSessionOptionStatus(args: { + client: RpcClient | null + sessionId: string | null + enabled: boolean +}): OptionStatus { + const { client, sessionId, enabled } = args + const feed = enabled && client && sessionId ? mobileStructuredSessionStatusFeed(client) : null + const subscribe = useCallback( + (listener: () => void) => (feed ? feed.subscribe(listener) : noSubscription()), + [feed] + ) + const readStatus = useMemo(() => { + let previous = EMPTY_OPTION_STATUS + return (): OptionStatus => { + const summary = feed && sessionId ? feed.getSnapshot().get(sessionId) : undefined + const stamp = `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}` + const selection = structuralValuesEqual(previous.selection, summary?.optionSelection) + ? previous.selection + : summary?.optionSelection + if (previous.stamp !== stamp || previous.selection !== selection) { + previous = { stamp, selection } + } + return previous + } + }, [feed, sessionId]) + return useSyncExternalStore(subscribe, readStatus, () => EMPTY_OPTION_STATUS) +} diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 308c75610068..071458ba0a89 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) } @@ -175,9 +174,7 @@ export async function acquireAcpStructuredSession(input: { sessionId, fence: acquire.fence, acquisitionGeneration: generation, - reportedOptions: options.read().current, - restoreSkippedOptions: [], - optionRevision + reportedOptions: options.read().current }) } }) 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 9fee50bcd31d..66c34fe2cd63 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() @@ -155,8 +154,6 @@ export async function initializeAcpStructuredSession( return { link, reportedOptions: options.read().current, - restoreSkippedOptions: restoreSkipped, - optionRevision, 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 94bfea7ed7f0..33e04d733eaa 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) { @@ -59,9 +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.7', effort: 'high' }, - restoreSkippedOptions: [], - optionRevision: 7 + reportedOptions: { model: 'grok-4.7', effort: 'high' } }) expect(writes).toEqual([]) rig.child().agent.notify('session/update', { @@ -167,7 +164,6 @@ describe('ACP child published before its handshake', () => { }) await rig.acquire({ options: { model: 'retired-model' } }) expect(rig.started()).toMatchObject({ - restoreSkippedOptions: [], reportedOptions: { model: 'grok-4.7', effort: 'high' } }) expect(rig.sent('session/set_config_option')).toEqual([]) 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-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-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-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 ffbd756b7930..a36d490ecc4a 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -34,8 +34,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 9131138ad40c..b4c9c3924d08 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' @@ -18,7 +17,6 @@ import { claudeStructuredSessionPublicationOptions, prepareClaudeStructuredSessionAcquisitionOptions } from './claude-structured-session-acquisition-options' -import { claudeRetiredOptions } from './claude-structured-retired-model' import { claudeResolvedCatalogListing, claudeStructuredSessionOptionsFrom, @@ -35,19 +33,13 @@ import type { export type ClaudeOpenFacts = Pick< StructuredAgentSessionProviderOpenResult, - | 'reportedOptions' - | 'restoreSkippedOptions' - | 'retiredOptions' - | 'optionRevision' - | 'catalogListing' + 'reportedOptions' | '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 = { @@ -203,7 +195,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 { @@ -223,10 +214,7 @@ export async function openClaudeSession(input: { session, readClaudeModels(initialized.initialization), session.fastModeAtStart ? session.optionMutationSequence - 1 : undefined - ), - restoreSkippedOptions: [...session.restoreSkippedOptions], - ...claudeRetiredOptions(session), - optionRevision: input.optionRevision() + ) } void settleClaudeStartupSettings(input, initialized) return result @@ -238,8 +226,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 @@ -269,9 +255,6 @@ 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), - optionRevision + ...claudeResolvedCatalogListing(session, readClaudeModels(facts.initialization)) }) } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 228453adb69e..1a120add4083 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 @@ -208,7 +204,6 @@ export type ClaudeSession = { proof: ClaudeInitProof account: ClaudeStructuredLaunch['account'] resumesTranscript: boolean - optionRevision: () => number onLateRefusal: (error: Error) => void } } diff --git a/src/main/codex/codex-structured-message-options.test.ts b/src/main/codex/codex-structured-message-options.test.ts index 57d87d9c6559..3f35777c4848 100644 --- a/src/main/codex/codex-structured-message-options.test.ts +++ b/src/main/codex/codex-structured-message-options.test.ts @@ -1,4 +1,10 @@ import { describe, expect, it, vi } from 'vitest' +import { agentSessionExecutionOptionsFromSnapshot } from '../../shared/agent-session-execution-option-snapshot' +import { + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../shared/structured-agent-session-options' +import { structuredAgentSessionSeedCatalog } from '../../shared/structured-agent-session-seed-catalog' import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' @@ -304,6 +310,34 @@ describe('Codex captured message choices', () => { expect(request.mock.calls[0]?.[1]).toMatchObject({ model: 'saved-next', effort: 'high' }) }) + it('uses a saved Codex choice when the picker has not loaded', async () => { + const request = vi.fn(async (_method: string, _params?: Record) => ({ + turn: { id: 'saved-turn' } + })) + const session = optionSession(request) + session.options.set('model', 'gpt-saved-unlisted') + session.options.set('effort', 'high') + session.reportedOptions = { model: 'gpt-saved-unlisted', effort: 'high' } + delete session.catalogAccess + delete session.nativeDefaults + const seed = structuredAgentSessionSeedCatalog('codex') + const snapshot = structuredAgentSessionOptionSnapshot( + createStructuredAgentSessionOptionState('codex', seed) + ) + const captured = agentSessionExecutionOptionsFromSnapshot(snapshot) + expect(captured).toBeUndefined() + const turnOptions = await resolvedCodexMessageOptions(session, captured) + await startCodexTurn(session, { + clientMessageId: 'saved', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }, + turnOptions + }) + expect(request.mock.calls[0]?.[1]).toMatchObject({ + model: 'gpt-saved-unlisted', + effort: 'high' + }) + }) + it('sends a fresh default while model discovery is held', async () => { const request = vi.fn(async (method: string, _params?: Record) => { if (method === 'model/list') { 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..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,9 +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: [], - optionRevision: 0 + 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 2e316ca40e51..b98887ece3b6 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 @@ -163,8 +162,6 @@ export async function openCodexStructuredSession( linkId: deps.mintLinkId?.(), observedAt: deps.now?.() ?? Date.now() }), - reportedOptions: reportedCodexSessionOptions(session), - restoreSkippedOptions: [], - optionRevision + reportedOptions: reportedCodexSessionOptions(session) } } diff --git a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts index da7593e91c7a..33b62355768a 100644 --- a/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts +++ b/src/main/native-chat/agent-model-catalog/agent-model-catalog-entry.ts @@ -65,7 +65,7 @@ export type AgentModelCatalogConfiguredDefault = { * session can't say (it listed or runs no model); null when it runs a model it didn't list. */ export function unpickedSessionConfiguredChoice(session: { resolvesConfig: boolean - picked: ReadonlySet + picked?: ReadonlySet models: readonly { id: string }[] current: { model?: string; effort?: string } }): AgentModelCatalogConfiguredChoice | null | undefined { @@ -73,7 +73,7 @@ export function unpickedSessionConfiguredChoice(session: { // An empty listing (a signed-out agent) is doubt, not a catalog. if ( !session.resolvesConfig || - session.picked.has('model') || + session.picked?.has('model') || !current.model || models.length === 0 ) { @@ -85,7 +85,7 @@ export function unpickedSessionConfiguredChoice(session: { return { modelId: current.model, // An effort this session picked is its own and says nothing of the config's. - ...(session.picked.has('effort') ? {} : { effort: current.effort ?? null }) + ...(session.picked?.has('effort') ? {} : { effort: current.effort ?? null }) } } diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts index 29fb7eb35fa2..89d49de68923 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts @@ -99,6 +99,7 @@ async function accept( body?: AgentJournalMessageItem origin?: 'client' | 'host' source?: AgentSessionMessageSource + executionOptions?: { model: string; effort: string } } = {} ): Promise { const body = fields.body ?? message(`text of ${id}`) @@ -109,7 +110,8 @@ async function accept( fence: 0, handoverRecorded: true, ...(fields.origin ? { origin: fields.origin } : {}), - ...(fields.source ? { source: fields.source } : {}) + ...(fields.source ? { source: fields.source } : {}), + ...(fields.executionOptions ? { executionOptions: fields.executionOptions } : {}) }) } @@ -159,6 +161,21 @@ afterEach(async () => { }) describe('which sends an earlier host process left unsent are kept', () => { + it('keeps an accepted message’s choices on its card through restart', async () => { + const executionOptions = { model: 'model-a', effort: 'high' } + const journal = await afterRestart((earlier) => + accept(earlier, 'chosen', { + origin: 'client', + source: USER_MESSAGE_SOURCE, + executionOptions + }) + ) + await hold(journal) + await journal.close() + const reopened = await open() + expect(reopened.queuedMessages.get('chosen')?.executionOptions).toEqual(executionOptions) + }) + it('keeps a person’s and a launch’s text, and an older build’s client send', async () => { const journal = await afterRestart(async (earlier) => { await accept(earlier, 'person', { origin: 'client', source: USER_MESSAGE_SOURCE }) diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts index fd5a774c7bf2..2b862e6705dd 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts @@ -140,6 +140,9 @@ export async function holdUnsentSends( ? { messageId: clientMessageId, body: card.body, + ...(submission.executionOptions + ? { executionOptions: submission.executionOptions } + : {}), fingerprint: agentSessionSendBodyFingerprint( journal.queuedMessages.sessionId, card.body 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 774f87be548d..43a00cda8106 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: () => {} @@ -381,7 +375,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' @@ -418,7 +411,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: () => {} @@ -456,7 +448,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(CREATE_OPERATION, null, options), now: () => NOW, onAttached: () => {} @@ -543,7 +534,6 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params: attachParams(operationId, fence), now: () => NOW, onAttached: () => {} @@ -644,7 +634,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 168d41831162..d239c2c0c599 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 @@ -74,7 +74,6 @@ export async function acquireOwner( ...(permissionMode ? { permissionMode } : {}), ...(input.eventSink ? { events: input.eventSink } : {}), ...(input.acquireSignal ? { signal: input.acquireSignal } : {}), - optionRevision: input.optionRevision, ...(input.startupOptions ? { startupOptions: input.startupOptions } : {}) }) const progress = input.onStartupAttempt?.(attempt) 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 5f6b287a6502..56f8f78fb13c 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 @@ -216,26 +216,8 @@ 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 - /** 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 @@ -249,7 +231,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 63f6aba1953e..3f49fbdd0c23 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 startupOptions?: Readonly> /** The permission mode a start of this record launches with; absent or undefined for none. */ launchPermissionMode?: ( 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 71f50ed697b0..bde49559a843 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 @@ -206,7 +206,6 @@ async function runAttachUnderAbort( attempt.startup = startup return startupAttempts.track(sessionId, startup) }, - optionRevision: () => context.runtimeState.optionRevisions.current(sessionId), ...(options.startupOptions ? { startupOptions: options.startupOptions } : {}), launchPermissionMode: async (record) => (await structuredAgentSessionLaunchPermission(context.deps, record)) ?? 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 1c2b6a5eb8c0..f35fb1d995a7 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 @@ -81,9 +81,7 @@ function adapter(): StructuredAgentSessionAdapter { new Promise((resolve) => { releaseOpen = () => resolve({ - reportedOptions: { model: DEFAULT_MODEL }, - restoreSkippedOptions: [], - optionRevision: 0 + reportedOptions: { model: DEFAULT_MODEL } }) }), acquire, 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..9db7a657354f 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 @@ -68,7 +68,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { if (await releaseLeaseOfEndedStructuredAgentSessionChild(host.context(), sessionId)) { return false } - const closed = await closeStructuredAgentSessionConversationUnderSerialize( + return closeStructuredAgentSessionConversationUnderSerialize( { sessions, closeStatus: (id) => { @@ -80,10 +80,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { sessionId, atRest ) - if (closed) { - host.context().runtimeState.optionRevisions.forget(sessionId) - } - return closed } const idleSweep = new StructuredAgentSessionIdleSweep({ 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..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,9 +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: [], - optionRevision: rig.host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + 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-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..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 @@ -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 @@ -51,9 +48,7 @@ export function structuredAgentSessionHeldStarts( return adapter.open ? adapter.open(input) : { - reportedOptions: { model: '' }, - restoreSkippedOptions: [], - optionRevision: NO_OPTIONS_REPORTED + reportedOptions: { model: '' } } } return { ...adapter, acquire, open } 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..80050667e277 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 @@ -12,7 +12,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 { @@ -25,7 +24,6 @@ export class StructuredAgentSessionHostRuntimeState { private readonly eventSinks = new Map() 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.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index c8c5efd1eae9..17a83e2463d8 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 @@ -293,7 +293,6 @@ export class StructuredAgentSessionHost { 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..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 @@ -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 } @@ -25,9 +21,7 @@ export function startsWhenPublished( open: adapter.open ?? (async () => ({ - reportedOptions: { model: '' }, - restoreSkippedOptions: [], - optionRevision: NO_OPTIONS_REPORTED + reportedOptions: { model: '' } })) } } 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..cb9a3d3f499f 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,6 @@ 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 { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' import type { StructuredAgentSessionCaller, @@ -52,7 +51,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 867207fb4e06..0556f181d787 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,11 +64,7 @@ function adapter(): StructuredAgentSessionAdapter { sessionId: identity.sessionId, fence, acquisitionGeneration, - reportedOptions: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) }, - restoreSkippedOptions: [], - optionRevision: host - .collaboratorsForTests() - .runtimeState.optionRevisions.current(identity.sessionId) + reportedOptions: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) } }) return { providerChildPhase: 'starting' as const, 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 8cbd7e4e5d92..5d31de269803 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,9 +224,7 @@ function prove(): Promise { type: 'started', ...child, acquisitionGeneration, - reportedOptions: { model: 'default' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + 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 3e60c6ee17a1..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,10 +13,7 @@ export type StructuredAgentSessionProviderOpenInput = { export type StructuredAgentSessionProviderOpenResult = { link?: AgentSessionProviderHandleLink reportedOptions: AgentSessionOptionsResult['current'] - 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..d5cd92b1e4a1 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,9 +18,7 @@ import { } from './structured-agent-session-host-test-data' const OPENED: StructuredAgentSessionProviderOpenResult = { - reportedOptions: { model: 'gpt-test' }, - restoreSkippedOptions: [], - optionRevision: -1 + reportedOptions: { model: 'gpt-test' } } function awaitOpen() { 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..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,9 +87,7 @@ function proveStart(): Promise { sessionId: SESSION, fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'sonnet' }, - restoreSkippedOptions: [], - optionRevision: 0 + reportedOptions: { model: 'sonnet' } }) } 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..00892757a22b 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 { @@ -160,8 +159,7 @@ export function noteStructuredAgentSessionProviderStarted( } } -/** What a ready child reports later, such as a read that came after its start: persisted as the - * start's report is, never ahead of a send. */ +/** A ready child's later read can enrich the saved account catalog. */ export async function settleStructuredAgentSessionOptionsReported( context: StructuredAgentSessionProviderStartedContext, event: StructuredAgentSessionOptionsReportedEvent @@ -181,11 +179,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-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 9af72d823e5e..f78d27579045 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 @@ -269,9 +269,7 @@ export async function createQueuedMessageTestRig( sessionId: SESSION, fence: store.getRecord(SESSION)!.lease.runtimeFence, acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'default' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + reportedOptions: { model: 'default' } }) } 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 f347dc738615..ae7359e7566f 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,9 +101,7 @@ function proveStarted(): Promise { return host.handleAdapterEvent({ type: 'started', ...currentChild(), - reportedOptions: { model: 'sonnet' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + reportedOptions: { model: 'sonnet' } }) } 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 350c842858d6..f32d72449f16 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,21 +170,13 @@ 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 8c55910b3de7..936a2bd202f8 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 @@ -88,7 +88,7 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ const adapter: StructuredAgentSessionAdapter = { open: () => new Promise((resolve) => { - ready = () => resolve({ reportedOptions: {}, restoreSkippedOptions: [], optionRevision: 0 }) + ready = () => resolve({ reportedOptions: {} }) }), acquire, dispatch, 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 4884f1a3e39e..aa5a7e59979f 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 @@ -26,7 +26,6 @@ export function mintStructuredAgentSessionStartupAttempt(input: { permissionMode?: AgentChatPermissionMode events?: StructuredAgentSessionEventSink signal?: AbortSignal - optionRevision: () => number startupOptions?: Readonly> }): StructuredAgentSessionStartupAttempt { const { record } = input @@ -44,8 +43,7 @@ export function mintStructuredAgentSessionStartupAttempt(input: { ...(options ? { options } : {}), ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}), ...(input.events ? { events: input.events } : {}), - ...(input.signal ? { signal: input.signal } : {}), - optionRevision: input.optionRevision + ...(input.signal ? { signal: input.signal } : {}) } } 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..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,10 +113,8 @@ beforeEach(async () => { sessionId: input.identity.sessionId, fence: input.fence, acquisitionGeneration, - reportedOptions: { model: '' }, - restoreSkippedOptions: [], + reportedOptions: { model: '' } // Never admitted, so the empty report persists nothing. - optionRevision: -1 }) return { providerChildPhase: 'starting' as const, diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 589658e81d9f..f88f043977fa 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -171,7 +171,6 @@ describe('Pi RPC session ownership and delivery', () => { origin: 'created', mintedAtFence: 7 }) - expect(h.started.restoreSkippedOptions).toEqual([]) expect(h.connection.requests).not.toContain('set_thinking_level') expect(h.connection.requests).toContain('get_commands') }) diff --git a/src/main/pi/rpc-session-startup.ts b/src/main/pi/rpc-session-startup.ts index 7531eeea9aff..0dce12c54244 100644 --- a/src/main/pi/rpc-session-startup.ts +++ b/src/main/pi/rpc-session-startup.ts @@ -45,7 +45,6 @@ export class PiRpcSessionStartup { launch: PiRpcResolvedLaunch, signal: AbortSignal ): Promise { - const optionRevision = this.input.optionRevision?.() ?? 0 const answer = await this.wait(this.connection.request('get_state', {}, START_REQUEST), signal) this.assertLive(signal) const state = piRpcStateSchema.parse(answer) @@ -82,7 +81,6 @@ export class PiRpcSessionStartup { options, unpickedSessionConfiguredChoice({ resolvesConfig: this.resolvesConfig, - picked: new Set(), ...options }) ).catalogListing @@ -93,8 +91,6 @@ export class PiRpcSessionStartup { return { link, reportedOptions: options?.current ?? current, - restoreSkippedOptions: skipped, - optionRevision, ...(catalogListing ? { catalogListing } : {}) } } 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..53a346f18be9 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,18 @@ 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.adapter.readOptionRestoreFailures?.(SESSION)).toEqual(['model']) + 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 +97,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 +124,9 @@ 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.adapter.readOptionRestoreFailures?.(SESSION)).toEqual(['model']) + 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 +134,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..33d03da50fc2 100644 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -110,9 +110,7 @@ describe('failures the desktop host used to drop', () => { sessionId: SESSION, fence: 1, acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'gpt-5' }, - restoreSkippedOptions: [], - optionRevision: 0 + reportedOptions: { model: 'gpt-5' } }) lifecycle.deliver({ type: 'ended', diff --git a/src/main/runtime/structured-agent-session-pi-startup.test.ts b/src/main/runtime/structured-agent-session-pi-startup.test.ts index 19403cfa5aa8..7ad84c5024f8 100644 --- a/src/main/runtime/structured-agent-session-pi-startup.test.ts +++ b/src/main/runtime/structured-agent-session-pi-startup.test.ts @@ -140,11 +140,8 @@ it('keeps a model picked during startup when the optional state read predates ap const wire = script.wire() expect(wire.indexOf('set_model')).toBeGreaterThan(-1) expect(wire.indexOf('set_model')).toBeLessThan(wire.indexOf('prompt:first message')) - const { runtimeState, sessions, serialize } = host.collaboratorsForTests() + const { sessions, serialize } = host.collaboratorsForTests() await vi.waitFor(() => expect(sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe('ready')) - await vi.waitFor(() => - expect(runtimeState.optionRevisions.current(HOST_TEST_SESSION)).toBeGreaterThan(1) - ) await serialize(HOST_TEST_SESSION, async () => {}) expect(peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.options?.model).toBe( fields.value 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. diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 7ce911de145c..863bce1c3f51 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -37,7 +37,10 @@ import { structuredSessionNotices } from './native-chat-structured-session-notic import { NativeChatPromptSlotNotices } from './NativeChatComposerNotices' import { useNativeChatComposerError } from './use-native-chat-composer-notice' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' -import { useStructuredAgentSessionHostExecution } from './StructuredAgentSessionStatusBridge' +import { + useStructuredAgentSessionHostExecution, + useStructuredAgentSessionOptionStamp +} from './StructuredAgentSessionStatusBridge' import { useNativeChatRewindHost } from './use-native-chat-rewind-host' import { NativeChatRewindContext } from './native-chat-rewind-context' import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' @@ -70,6 +73,7 @@ export function NativeChatStructuredSession( const { sendThroughLaunch } = provisionalLaunch // The host's own word on whether the provider child has answered startup yet. const hostExecution = useStructuredAgentSessionHostExecution(props.sessionId, props.target) + const hostOptionStamp = useStructuredAgentSessionOptionStamp(props.sessionId, props.target) const paneKey = useMemo( () => structuredAgentSessionPaneKey(props.tabId, props.sessionId), [props.sessionId, props.tabId] @@ -87,6 +91,7 @@ export function NativeChatStructuredSession( queueFollowUps, hostStopping: hostExecution.stopping, optionSelection: hostExecution.optionSelection, + hostOptionStamp, providerStarting: hostExecution.phase === 'starting', // A start's own verdict (a signed-out agent) exists only once that start is proven. providerRunning: hostExecution.phase === 'ready', diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.test.tsx index 8ff5c03ae37b..0b4b18dbf371 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { act, renderHook } from '@testing-library/react' +import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ call: vi.fn() })) @@ -11,11 +11,44 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { resetStructuredAgentSessionSendsForTests } from './structured-agent-session-message-sender' import { useStructuredAgentSessionSends } from './use-structured-agent-session-sends' +import { + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../../../shared/structured-agent-session-options' +import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' const QUEUEING = { capability: 'supported', enabled: true } as const afterEach(() => { resetStructuredAgentSessionSendsForTests() + mocks.call.mockReset() +}) + +it('omits choices when Send precedes the picker’s model answer', async () => { + mocks.call.mockImplementation(() => new Promise(() => {})) + const snapshot = structuredAgentSessionOptionSnapshot( + createStructuredAgentSessionOptionState('codex', structuredAgentSessionSeedCatalog('codex')) + ) + const { result } = renderHook(() => + useStructuredAgentSessionSends({ + sessionId: 'session-1', + target: { kind: 'local' }, + fence: 1, + submissions: [], + queuedMessageIds: [], + queue: QUEUEING, + historyLoaded: false, + executionOptionSnapshot: snapshot + }) + ) + act(() => { + result.current.send('before picker answer') + }) + await waitFor(() => + expect(mocks.call.mock.calls.some(([, method]) => method === 'agentSession.send')).toBe(true) + ) + const call = mocks.call.mock.calls.find(([, method]) => method === 'agentSession.send') + expect(call?.[2]).not.toHaveProperty('executionOptions') }) // The composer moves the reader only for a message the transcript draws as a bubble. diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts index 04cc1eba9490..2c24b7b77622 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts @@ -95,15 +95,16 @@ export function useStructuredAgentSessionSends(args: { return false } const delivery = structuredAgentSessionQueueRequest(queue, attachments) + const executionOptions = executionOptionSnapshot + ? agentSessionExecutionOptionsFromSnapshot(executionOptionSnapshot) + : undefined const sent = sendStructuredAgentSessionMessage({ sessionId, target, text, attachments, ...(delivery ? { delivery } : {}), - ...(executionOptionSnapshot - ? { executionOptions: agentSessionExecutionOptionsFromSnapshot(executionOptionSnapshot) } - : {}), + ...(executionOptions ? { executionOptions } : {}), ...(stopping ? { sentWhileStopping: true as const } : {}) }) // Refused while the chat's send is out: the text stays in the box. diff --git a/src/shared/agent-session-execution-option-snapshot.test.ts b/src/shared/agent-session-execution-option-snapshot.test.ts new file mode 100644 index 000000000000..999c63d7d87c --- /dev/null +++ b/src/shared/agent-session-execution-option-snapshot.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { agentSessionExecutionOptionsFromSnapshot } from './agent-session-execution-option-snapshot' +import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog' +import { + commitStructuredAgentSessionOptionValues, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from './structured-agent-session-options' + +describe('message choices while the picker loads', () => { + it.each(['codex', 'claude', 'pi'])('omits unknown %s choices', (agent) => { + const seed = structuredAgentSessionSeedCatalog(agent) + const state = createStructuredAgentSessionOptionState(agent, seed) + expect( + agentSessionExecutionOptionsFromSnapshot(structuredAgentSessionOptionSnapshot(state)) + ).toBeUndefined() + }) + + it.each([ + ['pi', 'anthropic/claude-sonnet-4-5'], + ['codex', 'some-unlisted-model'] + ])('omits %s choices until its saved model is listed', (agent, model) => { + const seed = structuredAgentSessionSeedCatalog(agent) + const state = commitStructuredAgentSessionOptionValues( + createStructuredAgentSessionOptionState(agent, seed), + { model, effort: 'high' } + ) + expect( + agentSessionExecutionOptionsFromSnapshot(structuredAgentSessionOptionSnapshot(state)) + ).toBeUndefined() + }) +}) diff --git a/src/shared/agent-session-execution-option-snapshot.ts b/src/shared/agent-session-execution-option-snapshot.ts index 1e80ee67151e..d0ffac39a05f 100644 --- a/src/shared/agent-session-execution-option-snapshot.ts +++ b/src/shared/agent-session-execution-option-snapshot.ts @@ -4,16 +4,19 @@ import type { AgentSessionExecutionOptions } from './agent-session-execution-opt /** What the picker displayed at Send, excluding launch-only permission. */ export function agentSessionExecutionOptionsFromSnapshot( snapshot: readonly SessionOptionDescriptor[] -): AgentSessionExecutionOptions { +): AgentSessionExecutionOptions | undefined { + const choices = ['model', 'effort', 'personality', 'fastMode', 'serviceTier'] + const descriptors = snapshot.filter( + (descriptor) => descriptor.transport === 'agent-session' && choices.includes(descriptor.id) + ) + if ( + descriptors.length === 0 || + descriptors.some((descriptor) => descriptor.valueSource === 'unknown') + ) { + return undefined + } const selected: Record = {} - for (const descriptor of snapshot) { - if ( - descriptor.transport !== 'agent-session' || - descriptor.valueSource === 'unknown' || - !['model', 'effort', 'personality', 'fastMode', 'serviceTier'].includes(descriptor.id) - ) { - continue - } + for (const descriptor of descriptors) { const value = descriptor.kind.currentValue if (value !== undefined) { selected[descriptor.id] = String(value) From 9d553b114fa4979261f5d88113292a82c9f3443d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:39:35 -0700 Subject: [PATCH 048/127] Remove the unused provider option restore ledger --- ...claude-structured-dispatch-test-support.ts | 1 - ...claude-structured-effort-reporting.test.ts | 1 - .../claude-structured-model-preflight.test.ts | 1 - .../claude/claude-structured-options.test.ts | 1 - .../claude-structured-retired-model.test.ts | 5 +---- .../claude/claude-structured-retired-model.ts | 5 ++--- .../claude-structured-session-adapter.ts | 3 --- .../claude-structured-session-publication.ts | 1 - .../claude/claude-structured-session-state.ts | 1 - .../claude-structured-spawn-options.test.ts | 5 ----- .../claude/claude-structured-spawn-options.ts | 21 ++++--------------- ...-agent-session-acquisition-options.test.ts | 9 ++------ ...structured-agent-session-adapter-router.ts | 3 --- .../structured-agent-session-adapter.ts | 2 -- ...ctured-agent-session-option-restoration.ts | 7 +------ .../agent-session-record-options.test.ts | 14 ------------- ...ude-structured-retired-saved-model.test.ts | 2 -- 17 files changed, 10 insertions(+), 72 deletions(-) diff --git a/src/main/claude/claude-structured-dispatch-test-support.ts b/src/main/claude/claude-structured-dispatch-test-support.ts index 4b9718ae45fe..1ba74cca6b62 100644 --- a/src/main/claude/claude-structured-dispatch-test-support.ts +++ b/src/main/claude/claude-structured-dispatch-test-support.ts @@ -29,7 +29,6 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C reportedOptions: {}, reportedModelMutation: 0, confirmedOptions: new Set(), - restoreSkippedOptions: new Set(), launchedModel: null, fastModeAtStart: false, capabilities: [], diff --git a/src/main/claude/claude-structured-effort-reporting.test.ts b/src/main/claude/claude-structured-effort-reporting.test.ts index 3305a40d77a3..c5adc15c2d6f 100644 --- a/src/main/claude/claude-structured-effort-reporting.test.ts +++ b/src/main/claude/claude-structured-effort-reporting.test.ts @@ -26,7 +26,6 @@ function sessionWith( optionMutationSequence: 0, reportedModelMutation: 0, confirmedOptions: new Set(), - restoreSkippedOptions: new Set(), connection: { supportedModels: async () => { calls.push('list_models') diff --git a/src/main/claude/claude-structured-model-preflight.test.ts b/src/main/claude/claude-structured-model-preflight.test.ts index a44ca0232b87..c02a9e528c64 100644 --- a/src/main/claude/claude-structured-model-preflight.test.ts +++ b/src/main/claude/claude-structured-model-preflight.test.ts @@ -22,7 +22,6 @@ function sessionWith(catalog: readonly Record[] | 'unavailable' optionMutationSequence: 0, reportedModelMutation: 0, confirmedOptions: new Set(), - restoreSkippedOptions: new Set(), connection: { supportedModels: async () => { calls.push('list_models') diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index 64ada44671d7..2f4f84e48c8d 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -38,7 +38,6 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe reportedOptions: {}, reportedModelMutation: 0, confirmedOptions: new Set(), - restoreSkippedOptions: new Set(), launchedModel: null, fastModeAtStart: false, capabilities: [], diff --git a/src/main/claude/claude-structured-retired-model.test.ts b/src/main/claude/claude-structured-retired-model.test.ts index bc8e49065b65..d846553af1de 100644 --- a/src/main/claude/claude-structured-retired-model.test.ts +++ b/src/main/claude/claude-structured-retired-model.test.ts @@ -41,8 +41,7 @@ function modelNotFound(parentToolUseId: string | null = null): Record() + options: new Map(picked === null ? [] : [['model', picked]]) } } @@ -52,7 +51,6 @@ describe("the CLI's word that a launched model does not exist", () => { expect(retireClaudeLaunchedModel(session, modelNotFound())).toBe(RETIRED) expect(session.options.has('model')).toBe(false) - expect([...session.restoreSkippedOptions]).toEqual(['model']) expect(retireClaudeLaunchedModel(session, modelNotFound())).toBeNull() }) @@ -63,7 +61,6 @@ describe("the CLI's word that a launched model does not exist", () => { ['a launch that named no model', sessionLaunchedWith(null), modelNotFound()] ])('keeps the saved model for %s', (_case, session, message) => { expect(retireClaudeLaunchedModel(session, message)).toBeNull() - expect([...session.restoreSkippedOptions]).toEqual([]) }) }) diff --git a/src/main/claude/claude-structured-retired-model.ts b/src/main/claude/claude-structured-retired-model.ts index 3b233201722b..ce574cb680fe 100644 --- a/src/main/claude/claude-structured-retired-model.ts +++ b/src/main/claude/claude-structured-retired-model.ts @@ -8,9 +8,9 @@ import type { } from './claude-structured-session-state' /** The launched model, once, when a root reply says it does not exist and the chat still has it - * picked: the session stops holding it, so the record drops it. Null otherwise. */ + * picked: the live session stops holding it. Saved picks remain the user's. */ export function retireClaudeLaunchedModel( - session: Pick, + session: Pick, message: Record ): string | null { const launched = session.launchedModel @@ -24,7 +24,6 @@ export function retireClaudeLaunchedModel( return null } session.options.delete('model') - session.restoreSkippedOptions.add('model') return launched } diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 25ea3b75254c..774e42397397 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -243,9 +243,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda }) readOptions = (input: { sessionId: string; fence: number }) => readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) - readOptionRestoreFailures = (sessionId: string): readonly string[] => [ - ...(this.sessions.get(sessionId)?.restoreSkippedOptions ?? []) - ] releaseAcquisition = (input: { sessionId: string }): Promise => this.afterClose(input.sessionId, () => this.releaseProviderSession(input.sessionId)) diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index 5630e0f95986..77e0cb83a36a 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -63,7 +63,6 @@ export function createClaudeSessionPublication(input: { reportedOptions: {}, reportedModelMutation: 0, confirmedOptions: new Set(), - restoreSkippedOptions: new Set(), launchedModel: null, fastModeAtStart: false, translator: input.translator, diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 1a120add4083..2068b5d64fe1 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -168,7 +168,6 @@ export type ClaudeSession = { reportedModelMutation: number /** Options whose recorded value the provider reported, not merely accepted. */ confirmedOptions: Set - restoreSkippedOptions: Set /** The model the child was launched with, as `--model`; null when it runs the CLI's own. */ launchedModel: string | null /** The launch left the saved Fast out, so the start applies it once the settings are read. */ diff --git a/src/main/claude/claude-structured-spawn-options.test.ts b/src/main/claude/claude-structured-spawn-options.test.ts index d155d423c957..49731ee5168c 100644 --- a/src/main/claude/claude-structured-spawn-options.test.ts +++ b/src/main/claude/claude-structured-spawn-options.test.ts @@ -44,7 +44,6 @@ describe('a Claude chat launched with its saved options', () => { effort: 'high', fastMode: 'true' }) - expect(spawn.skipped).toEqual([]) }) // A resolved id, a model newer than any listing Orca holds, a retired one: the CLI's own answer @@ -55,7 +54,6 @@ describe('a Claude chat launched with its saved options', () => { const spawn = launched({ model, effort: 'xhigh' }) expect(spawn.sdkOptions).toMatchObject({ model, effort: 'xhigh' }) - expect(spawn.skipped).toEqual([]) } ) @@ -64,7 +62,6 @@ describe('a Claude chat launched with its saved options', () => { expect(spawn.sdkOptions).not.toHaveProperty('effort') expect(spawn.sdkOptions).not.toHaveProperty('settings') - expect(spawn.skipped).toEqual(['effort', 'fastMode']) }) it('keeps a saved Fast on for a new conversation, for its start to apply', () => { @@ -73,7 +70,6 @@ describe('a Claude chat launched with its saved options', () => { expect(spawn.sdkOptions).not.toHaveProperty('settings') expect(spawn.options.get('fastMode')).toBe('true') expect(spawn.fastModeAtStart).toBe(true) - expect(spawn.skipped).toEqual([]) }) it('passes a saved Fast off to a new conversation', () => { @@ -128,7 +124,6 @@ describe('a Claude chat launched with its saved options', () => { expect(spawn.sdkOptions).toEqual(FULL_ACCESS_LAUNCH) expect(spawn.options.size).toBe(0) - expect(spawn.skipped).toEqual([]) } ) }) diff --git a/src/main/claude/claude-structured-spawn-options.ts b/src/main/claude/claude-structured-spawn-options.ts index fe28be8f83c7..f279b6377313 100644 --- a/src/main/claude/claude-structured-spawn-options.ts +++ b/src/main/claude/claude-structured-spawn-options.ts @@ -39,8 +39,6 @@ export type ClaudeStructuredSpawnOptions = { sdkOptions: ClaudeStructuredSdkOptions /** The chat's options as the session holds them: what was launched, and a Fast the start applies. */ options: Map - /** Saved options left out: the provider's own value wins and is re-persisted. */ - skipped: readonly string[] /** A saved Fast the launch does not carry, which the start applies. */ fastModeAtStart: boolean } @@ -56,7 +54,6 @@ export function claudeStructuredSpawnOptions(input: { }): ClaudeStructuredSpawnOptions { const saved = restoredClaudeStructuredSessionOptions(input.saved) const options = new Map() - const skipped: string[] = [] let sdkOptions: ClaudeStructuredSdkOptions = { ...input.launch.options } let fastModeAtStart = false const model = saved.get('model') @@ -69,16 +66,12 @@ export function claudeStructuredSpawnOptions(input: { if (isEffortLevel(effort)) { options.set('effort', effort) sdkOptions = { ...withoutConfiguredFlag(sdkOptions, 'effort'), effort } - } else { - skipped.push('effort') } } const fastMode = saved.get('fastMode') if (fastMode !== undefined) { const decoded = decodeStructuredAgentSessionOptionValue('fastMode', fastMode) - if (typeof decoded !== 'boolean') { - skipped.push('fastMode') - } else { + if (typeof decoded === 'boolean') { options.set('fastMode', fastMode) // A new CLI session may opt in to Fast per session; the first message applies a saved Fast // when launch settings cannot carry it. @@ -92,22 +85,16 @@ export function claudeStructuredSpawnOptions(input: { } } } - return { sdkOptions, options, skipped, fastModeAtStart } + return { sdkOptions, options, fastModeAtStart } } /** The published session takes on what its child was launched with. */ export function adoptClaudeStructuredSpawnOptions( - session: Pick< - ClaudeSession, - 'restoreSkippedOptions' | 'translator' | 'launchedModel' | 'fastModeAtStart' - >, - spawn: Pick + session: Pick, + spawn: Pick ): void { session.launchedModel = spawn.options.get('model') ?? null session.fastModeAtStart = spawn.fastModeAtStart - for (const key of spawn.skipped) { - session.restoreSkippedOptions.add(key) - } const model = spawn.options.get('model') if (model !== undefined) { session.translator?.modelWritten(model) diff --git a/src/main/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 43a00cda8106..6da0e33acc0d 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 @@ -78,7 +78,6 @@ function attachParams( function adapter(input: { origin: 'created' | 'resumed' options?: AgentSessionOptionsResult - restoreFailures?: readonly string[] }): StructuredAgentSessionAdapter { return { acquire: vi @@ -99,9 +98,6 @@ function adapter(input: { } })), ...(input.options ? { readOptions: vi.fn(async () => input.options!) } : {}), - ...(input.restoreFailures - ? { readOptionRestoreFailures: vi.fn(() => input.restoreFailures!) } - : {}), dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), @@ -353,13 +349,12 @@ describe('structured session acquisition options', () => { }) }) - it('keeps a saved Fast choice when the provider skips restoring it', async () => { + it('keeps a saved Fast choice without an acquisition report', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-fast-restore-')) const store = await openTestAgentSessionRecordStore(root) const sessionAdapter = adapter({ origin: 'created', - options: { current: { model: 'gpt-standard' }, models: [] }, - restoreFailures: ['fastMode'] + options: { current: { model: 'gpt-standard' }, models: [] } }) const created = await performAttach({ 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 34b3b6d4009b..50e104886bff 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 @@ -195,9 +195,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi : readNativeSessionOptions({ adapter, ...input }) } - readOptionRestoreFailures = (sessionId: string): readonly string[] => - this.owner(sessionId).readOptionRestoreFailures?.(sessionId) ?? [] - providerHistoryWindow = (input: { identity: AgentSessionJournalIdentity accountHome: AgentSessionAccountHome diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 56f8f78fb13c..faa659eff8b7 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 @@ -406,8 +406,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & | Promise> | undefined> | Readonly> | undefined - /** Option keys skipped after a provider rejected their persisted restore value. */ - readOptionRestoreFailures?(sessionId: string): readonly string[] /** Provider history for restart reconciliation, bounded to what the provider * recorded after the journal's last committed item. Only the adapter can say * whether the read has a proven start and whether a turn is still running, so diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts index b1ec8d997a93..4374e3e0b5ef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts @@ -3,7 +3,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-a import { encodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' export async function readNativeSessionOptions(input: { - adapter: Pick + adapter: Pick sessionId: string fence: number priorOptions?: Readonly> @@ -15,7 +15,6 @@ export async function readNativeSessionOptions(input: { } return nativeSessionOptionsFromReport({ reported: reported.current, - restoreSkipped: adapter.readOptionRestoreFailures?.(sessionId) ?? [], ...(priorOptions ? { priorOptions } : {}) }) } @@ -24,7 +23,6 @@ export async function readNativeSessionOptions(input: { * replace the saved ones, other saved options stay, and any the child could not take are dropped. */ export function nativeSessionOptionsFromReport(input: { reported: AgentSessionOptionsResult['current'] - restoreSkipped: readonly string[] priorOptions?: Readonly> /** Values the child showed it cannot run: its report of the same value is not kept. */ retired?: Readonly> @@ -35,9 +33,6 @@ export function nativeSessionOptionsFromReport(input: { delete restored.effort delete restored.fastMode delete restored.serviceTier - for (const key of input.restoreSkipped) { - delete restored[key] - } // A reported tier is the speed itself; the Fast it implies for older clients is not kept. const fastMode = reported.fastMode === undefined || reported.serviceTier !== undefined diff --git a/src/main/runtime/agent-session-record-options.test.ts b/src/main/runtime/agent-session-record-options.test.ts index bef2bc424efa..86e87486cca0 100644 --- a/src/main/runtime/agent-session-record-options.test.ts +++ b/src/main/runtime/agent-session-record-options.test.ts @@ -39,20 +39,6 @@ it('fails option hydration before ownership can be proved', async () => { ).rejects.toThrow('model list unavailable') }) -it('drops provider-rejected persisted options before the next owner proof', async () => { - await expect( - readNativeSessionOptions({ - adapter: { - readOptions: async () => ({ models: [], current: { model: 'provider-model' } }), - readOptionRestoreFailures: () => ['permissionMode'] - }, - sessionId: SESSION, - fence: 2, - priorOptions: { permissionMode: 'retired-mode', other: 'keep' } - }) - ).resolves.toEqual({ model: 'provider-model', other: 'keep' }) -}) - it('omits empty model values from other option producers', async () => { await expect( readNativeSessionOptions({ 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 53a346f18be9..05730f9b6835 100644 --- a/src/main/runtime/claude-structured-retired-saved-model.test.ts +++ b/src/main/runtime/claude-structured-retired-saved-model.test.ts @@ -61,7 +61,6 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) await host.flushStreamedEvents(SESSION) - expect(host.deps.adapter.readOptionRestoreFailures?.(SESSION)).toEqual(['model']) expect(host.deps.store.getRecord(SESSION)?.options).toMatchObject({ model: RETIRED, effort: 'high' @@ -125,7 +124,6 @@ describe("a Claude chat whose saved model the CLI says doesn't exist", () => { }) claude.child(SESSION).handlers.onMessage?.(modelNotFoundReply('reply-1')) await host.flushStreamedEvents(SESSION) - expect(host.deps.adapter.readOptionRestoreFailures?.(SESSION)).toEqual(['model']) expect(host.deps.store.getRecord(SESSION)?.options?.model).toBe(RETIRED) // The settings read gives up at its deadline and reports what the child showed. From e15f2f3089e158b63f5161608a393101cc1daf1a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:40:58 -0700 Subject: [PATCH 049/127] Clarify launch choices and legacy option hydration --- src/main/claude/claude-structured-spawn-options.ts | 4 +--- .../structured-agent-session-option-restoration.ts | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/main/claude/claude-structured-spawn-options.ts b/src/main/claude/claude-structured-spawn-options.ts index f279b6377313..da5417039e72 100644 --- a/src/main/claude/claude-structured-spawn-options.ts +++ b/src/main/claude/claude-structured-spawn-options.ts @@ -1,6 +1,4 @@ -// A chat's saved options as the Claude child's launch options. The child starts already running -// them, so its first message can be written at once instead of waiting for initialize to answer and -// a control request to apply each one. +// Saved choices seed the CLI launch; message choices are applied again before input. import type { EffortLevel } from '@anthropic-ai/claude-agent-sdk' import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts index 4374e3e0b5ef..7eccfe5f22ba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts @@ -19,8 +19,7 @@ export async function readNativeSessionOptions(input: { }) } -/** The record's options once the provider has reported: its model, effort, Fast and service tier - * replace the saved ones, other saved options stay, and any the child could not take are dropped. */ +/** Legacy acquisition facts replace execution choices; unrelated saved launch fields survive. */ export function nativeSessionOptionsFromReport(input: { reported: AgentSessionOptionsResult['current'] priorOptions?: Readonly> From 38a82587dc2bcca389ddc1e1021a62cadc1307c5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:43:10 -0700 Subject: [PATCH 050/127] Remove remaining restore-ledger forwarding and assertions --- src/main/acp/acp-structured-missing-model.test.ts | 3 +-- src/main/claude/claude-structured-acquisition-launch.ts | 1 - .../claude-structured-effort-default-at-rest.test.ts | 4 +--- .../claude-structured-options-context-window.test.ts | 1 - src/main/claude/claude-structured-session-adapter.test.ts | 3 +-- src/main/codex/codex-structured-service-tier.test.ts | 7 +++---- 6 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/main/acp/acp-structured-missing-model.test.ts b/src/main/acp/acp-structured-missing-model.test.ts index c8df21f6ce6e..0aa9dca3876b 100644 --- a/src/main/acp/acp-structured-missing-model.test.ts +++ b/src/main/acp/acp-structured-missing-model.test.ts @@ -47,8 +47,7 @@ function writtenOptions(provider: string, reader: AcpStructuredOptions) { } // What the host writes from the report a started child sends. const options = nativeSessionOptionsFromReport({ - reported: reader.read().current, - restoreSkipped: [] + reported: reader.read().current }) const changed = replaceAgentSessionRecordOptions(record, { sessionId: record.sessionId, diff --git a/src/main/claude/claude-structured-acquisition-launch.ts b/src/main/claude/claude-structured-acquisition-launch.ts index 809a81d70263..a0efb5eddbaa 100644 --- a/src/main/claude/claude-structured-acquisition-launch.ts +++ b/src/main/claude/claude-structured-acquisition-launch.ts @@ -106,7 +106,6 @@ export async function resolveClaudeAcquisitionLaunch(args: { options: spawn.sdkOptions, savedOptions: { options: spawn.options, - skipped: spawn.skipped, fastModeAtStart: spawn.fastModeAtStart } } diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 7cc0a917b4aa..72d91fc93653 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 @@ -402,9 +402,7 @@ describe('Claude effort default at rest', () => { const reported = (await claudeOpened(adapter, SESSION)).reportedOptions expect(reported).not.toHaveProperty('effort') // The record the start persists names the listed row the catalog learned under. - const record = restingRecord( - nativeSessionOptionsFromReport({ reported: reported, restoreSkipped: [] }) - ) + const record = restingRecord(nativeSessionOptionsFromReport({ reported: reported })) expect(record.options).toEqual({ model: 'opus[1m]' }) expect((await readAtRest(store, record)).current.effort).toBe('medium') expect(record.options).toEqual({ model: 'opus[1m]' }) diff --git a/src/main/claude/claude-structured-options-context-window.test.ts b/src/main/claude/claude-structured-options-context-window.test.ts index 9416bf947a50..eab0aef168d9 100644 --- a/src/main/claude/claude-structured-options-context-window.test.ts +++ b/src/main/claude/claude-structured-options-context-window.test.ts @@ -36,7 +36,6 @@ describe('the context ring after a session option write', () => { const s = ringSession() adoptClaudeStructuredSpawnOptions(s.session, { options: new Map([['model', 'opus[1m]']]), - skipped: [], fastModeAtStart: false }) expect(s.respond('turn-a', 1_000)).toMatchObject({ windowTokens: 1_000_000, percentage: 10 }) diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 0250722cb2a5..6b93a2b99431 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -152,7 +152,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { }) // The chat's permission mode is the start's, never a saved option the CLI is handed. - it('self-heals a persisted effort the CLI would refuse, and launches the start mode', async () => { + it('omits an invalid persisted effort at launch and uses the explicit start mode', async () => { const claude = fakeClaude() const adapter = adapterFor(claude) const resolveLaunch = vi.spyOn(adapter['deps'], 'resolveLaunch') @@ -160,7 +160,6 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { const start = { identity: identityFor(), fence: 7, spawnToken: 'spawn-9', options } await adapter.acquire({ ...start, permissionMode: 'accept-edits' }) - expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['effort']) expect(claude.connections[0].launch.options).not.toHaveProperty('effort') expect(resolveLaunch.mock.calls[0]?.[0].permissionMode).toBe('accept-edits') }) diff --git a/src/main/codex/codex-structured-service-tier.test.ts b/src/main/codex/codex-structured-service-tier.test.ts index df7d8c4aae19..bb822d1cceb9 100644 --- a/src/main/codex/codex-structured-service-tier.test.ts +++ b/src/main/codex/codex-structured-service-tier.test.ts @@ -266,13 +266,12 @@ describe('Codex service tier across clients and rest', () => { expect( nativeSessionOptionsFromReport({ reported: { model: 'gpt-live', serviceTier: 'priority', fastMode: true }, - restoreSkipped: [], priorOptions: { fastMode: 'true', serviceTier: 'ultrafast' } }) ).toEqual({ model: 'gpt-live', serviceTier: 'priority' }) // An agent that reports Fast with no tier still keeps it. - expect( - nativeSessionOptionsFromReport({ reported: { fastMode: true }, restoreSkipped: [] }) - ).toEqual({ fastMode: 'true' }) + expect(nativeSessionOptionsFromReport({ reported: { fastMode: true } })).toEqual({ + fastMode: 'true' + }) }) }) From 331146cfa547285b12e94e3365fe498d2dd5ad2d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:45:46 -0700 Subject: [PATCH 051/127] Remove unused ACP and Pi restore bookkeeping --- src/main/acp/acp-structured-acquire.ts | 1 - src/main/acp/acp-structured-child.ts | 1 - src/main/acp/acp-structured-handshake.ts | 4 +--- src/main/acp/acp-structured-session-adapter.ts | 5 ----- src/main/acp/acp-structured-session.ts | 1 - src/main/pi/rpc-session-adapter.ts | 3 --- src/main/pi/rpc-session-startup.ts | 3 --- 7 files changed, 1 insertion(+), 17 deletions(-) diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 071458ba0a89..d5565f9fdfd7 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -235,7 +235,6 @@ export async function acquireAcpStructuredSession(input: { options, prompts, childStops: undefined, - restoreSkipped: [], closeRequested: false, journalClosed: null, ended: false, diff --git a/src/main/acp/acp-structured-child.ts b/src/main/acp/acp-structured-child.ts index 4602b1738f9b..78b3493e2d2f 100644 --- a/src/main/acp/acp-structured-child.ts +++ b/src/main/acp/acp-structured-child.ts @@ -21,7 +21,6 @@ export type AcpStructuredChild = Pick< options: AcpStructuredSession['options'] prompts: AcpStructuredSession['prompts'] childStops?: AcpStructuredSession['childStops'] - restoreSkipped: readonly string[] unbindReadingControl?: () => void open: (signal: AbortSignal) => Promise closing?: Promise diff --git a/src/main/acp/acp-structured-handshake.ts b/src/main/acp/acp-structured-handshake.ts index 66c34fe2cd63..71dcfd0adb32 100644 --- a/src/main/acp/acp-structured-handshake.ts +++ b/src/main/acp/acp-structured-handshake.ts @@ -102,7 +102,6 @@ export async function initializeAcpStructuredSession( assertStarting() options.adoptSession(started.response, started.kind === 'new' ? 'new' : 'loaded') liveLane.apply(liveLane.translator.contextModels(started.response.models, now())) - const restoreSkipped: string[] = [] const link: AgentSessionProviderHandleLink = { linkId: deps.mintLinkId?.() ?? `${spec.agent}-${acquire.fence}-${started.sessionId}`.slice(0, 128), @@ -145,8 +144,7 @@ export async function initializeAcpStructuredSession( agentName, now, settle: onSettled - }), - restoreSkipped + }) }) if (!isAcpReadySession(child)) { throw new Error('ACP session did not open') diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 3bb2d594dcb1..450d1fc1cbcc 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -261,11 +261,6 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte readOptions = async (input: { sessionId: string; fence: number }) => readAcpLiveOptions(this.live(input.sessionId)) - readOptionRestoreFailures = (sessionId: string): readonly string[] => { - const session = this.children.sessions.get(sessionId) - return session && isAcpReadySession(session) ? session.restoreSkipped : [] - } - readCommands = (sessionId: string) => { const session = this.children.sessions.get(sessionId) return session && isAcpReadySession(session) ? session.options.readCommands() : undefined diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts index c78b9b7581fd..b0cde0e1a2ae 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -29,7 +29,6 @@ export type AcpStructuredSession = { prompts: AcpStructuredPrompts options: AcpStructuredOptions turns: AcpStructuredTurns - restoreSkipped: readonly string[] /** Orca asked this child to stop; its exit is then a requested close. */ closeRequested: boolean /** Why nothing this child says reaches the journal any more; null while it does. */ diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index 7d8985007e6f..a70f4419a46d 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -215,9 +215,6 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { } readOptions: NonNullable = async (input) => this.session(input.sessionId, input.fence).readOptions() - readOptionRestoreFailures(id: string): readonly string[] { - return this.sessions.get(id)?.startup.skipped ?? [] - } readCommands = (id: string) => this.sessions.get(id)?.startup.commands holdsDispatch(id: string): boolean { return this.sessions.get(id)?.turns.holdsDispatch ?? false diff --git a/src/main/pi/rpc-session-startup.ts b/src/main/pi/rpc-session-startup.ts index 0dce12c54244..e4db9740f20a 100644 --- a/src/main/pi/rpc-session-startup.ts +++ b/src/main/pi/rpc-session-startup.ts @@ -26,7 +26,6 @@ import { STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } from '../native-chat/agen const START_REQUEST = { timeoutMs: STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } export class PiRpcSessionStartup { - skipped: readonly string[] = [] commands?: AgentSessionSlashCommand[] /** What the start listed; a signed-out Pi lists no model. */ options?: AgentSessionOptionsResult @@ -63,7 +62,6 @@ export class PiRpcSessionStartup { model: state.model ? piModelOptionId(state.model.provider, state.model.id) : '', ...(state.thinkingLevel ? { effort: state.thinkingLevel } : {}) } - const skipped: string[] = [] this.assertLive(signal) current.confirmed = [...(current.model ? ['model'] : []), ...(current.effort ? ['effort'] : [])] // Part of the start, as other agents' handshakes answer with theirs: a Pi that lists no model @@ -85,7 +83,6 @@ export class PiRpcSessionStartup { }) ).catalogListing } - this.skipped = skipped await this.readCommands(signal) this.assertLive(signal) return { 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 052/127] 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 053/127] 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 054/127] 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 055/127] 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 f1571b8119712652461fb2d775635a269d7924b0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:10:04 -0700 Subject: [PATCH 056/127] Remove retired Pi startup revision and match saved-pick behavior --- src/main/pi/rpc-session-startup.ts | 2 -- .../structured-agent-session-pi-startup.test.ts | 15 +++++---------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/main/pi/rpc-session-startup.ts b/src/main/pi/rpc-session-startup.ts index d7dd34baadf3..8a7c6a39b79c 100644 --- a/src/main/pi/rpc-session-startup.ts +++ b/src/main/pi/rpc-session-startup.ts @@ -52,7 +52,6 @@ export class PiRpcSessionStartup { launch: PiRpcResolvedLaunch, signal: AbortSignal ): Promise { - const optionRevision = this.input.optionRevision?.() ?? 0 const answer = await this.wait(this.connection.request('get_state', {}, START_REQUEST), signal) this.assertLive(signal) const state = piRpcStateSchema.parse(answer) @@ -121,7 +120,6 @@ export class PiRpcSessionStartup { link, reportedOptions: options?.current ?? current, restoreSkippedOptions: skipped, - optionRevision, ...(catalogListing ? { catalogListing } : {}) } } diff --git a/src/main/runtime/structured-agent-session-pi-startup.test.ts b/src/main/runtime/structured-agent-session-pi-startup.test.ts index 19403cfa5aa8..da1c11bb1c1e 100644 --- a/src/main/runtime/structured-agent-session-pi-startup.test.ts +++ b/src/main/runtime/structured-agent-session-pi-startup.test.ts @@ -103,15 +103,14 @@ it('attaches Pi at spawn and holds the opening send until the provider answers i await vi.waitFor(() => expect(script.prompts()).toEqual(['hello while Pi is starting'])) await vi.waitFor(() => expect(peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.options).toEqual({ - model: 'scripted/model-1', - effort: 'medium' + unknown: 'unavailable-saved-option' }) ) await stopStructuredAgentSessionRuntime() expect(script.closes()).toBe(1) }) -it('keeps a model picked during startup when the optional state read predates applying the pick', async () => { +it('saves a startup model pick without writing it to the starting Pi child', async () => { const { script, host, caller, fence } = await setup() const fields = { key: 'model', value: 'scripted/model-2' } const picked = await host.setOption(caller, { @@ -135,16 +134,12 @@ it('keeps a model picked during startup when the optional state read predates ap }) expect(sent.ok).toBe(true) script.releaseHandshake() - // The pick reaches Pi before the message held for its start. + // A startup pick is saved for a later start at this piece. await vi.waitFor(() => expect(script.prompts()).toEqual(['first message'])) const wire = script.wire() - expect(wire.indexOf('set_model')).toBeGreaterThan(-1) - expect(wire.indexOf('set_model')).toBeLessThan(wire.indexOf('prompt:first message')) - const { runtimeState, sessions, serialize } = host.collaboratorsForTests() + expect(wire).not.toContain('set_model') + const { sessions, serialize } = host.collaboratorsForTests() await vi.waitFor(() => expect(sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe('ready')) - await vi.waitFor(() => - expect(runtimeState.optionRevisions.current(HOST_TEST_SESSION)).toBeGreaterThan(1) - ) await serialize(HOST_TEST_SESSION, async () => {}) expect(peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.options?.model).toBe( fields.value From ce3deb5730588c780f6175608a6fb185b805228a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:16:22 -0700 Subject: [PATCH 057/127] Check running choices and retained rewind messages after delivery integration --- ...red-agent-session-claude-option-queue.test.ts | 11 ++++++----- ...tured-agent-session-option-settlement.test.ts | 4 ++-- ...tructured-agent-session-provider-open.test.ts | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 7 deletions(-) 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 4d4ab410b804..47b7b3901016 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 @@ -182,13 +182,14 @@ it('applies model and effort picks on a running Claude chat to the next message' ).toMatchObject({ ok: true }) } const body = hostTestMessage('with the new choices') - dispatch.mockImplementationOnce(async () => { - expect(setOption).not.toHaveBeenCalled() - 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([]) + expect(order).toEqual(['dispatch']) + expect(setOption).not.toHaveBeenCalled() + expect(dispatch.mock.calls[0]?.[0].executionOptions).toEqual({ + model: PICKED_MODEL, + effort: 'high' + }) }) 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 29102750b379..df310b364a86 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 @@ -22,7 +22,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' const CALLER = { callerKey: 'client-1' } const DEFAULT_MODEL = 'gpt-default' @@ -136,7 +136,7 @@ beforeEach(async () => { store = await openTestAgentSessionRecordStore(root) router = adapter() host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter: router, 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 e3e3677de6a7..af111436b03c 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 @@ -309,6 +309,18 @@ it.each([false, true])( expectedEpoch }) await host.collaboratorsForTests().serialize(SESSION, async () => {}) + for (const [key, value] of [ + ['model', 'gpt-after-rewind'], + ['effort', 'high'] + ]) { + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', { key, value }), + key, + value + }) + ).toMatchObject({ ok: true }) + } const later = send('later message') let accepted = false void later.then(() => { @@ -331,6 +343,10 @@ it.each([false, true])( expect(await later).toMatchObject({ ok: true }) await vi.waitFor(() => expect(order).toHaveLength(2)) expect(order).toEqual(['rewind', 'send']) + expect(dispatch.mock.calls.at(-1)?.[0].executionOptions).toEqual({ + model: 'gpt-after-rewind', + effort: 'high' + }) } ) 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 058/127] 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 059/127] 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 060/127] 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 0b4f30e09dc1168fd507dce4314eaa7b06f82963 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:18:14 -0700 Subject: [PATCH 061/127] Apply Pi startup picks after readiness and remove unused open facts [skip ci] --- src/main/pi/rpc-session-adapter.test.ts | 2 +- src/main/pi/rpc-session-startup.ts | 1 - .../structured-agent-session-pi-startup.test.ts | 13 +++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 7d08cec2014d..d4d2e4c4bf11 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -171,7 +171,7 @@ describe('Pi RPC session ownership and delivery', () => { origin: 'created', mintedAtFence: 7 }) - expect(h.started.restoreSkippedOptions).toEqual(['unknown', 'effort']) + expect(h.adapter.readOptionRestoreFailures(sessionId)).toEqual(['unknown', 'effort']) expect(h.connection.requests).toContain('get_commands') }) diff --git a/src/main/pi/rpc-session-startup.ts b/src/main/pi/rpc-session-startup.ts index 8a7c6a39b79c..b246ea9b9aab 100644 --- a/src/main/pi/rpc-session-startup.ts +++ b/src/main/pi/rpc-session-startup.ts @@ -119,7 +119,6 @@ export class PiRpcSessionStartup { return { link, reportedOptions: options?.current ?? current, - restoreSkippedOptions: skipped, ...(catalogListing ? { catalogListing } : {}) } } diff --git a/src/main/runtime/structured-agent-session-pi-startup.test.ts b/src/main/runtime/structured-agent-session-pi-startup.test.ts index da1c11bb1c1e..61dbba5ff0be 100644 --- a/src/main/runtime/structured-agent-session-pi-startup.test.ts +++ b/src/main/runtime/structured-agent-session-pi-startup.test.ts @@ -110,10 +110,10 @@ it('attaches Pi at spawn and holds the opening send until the provider answers i expect(script.closes()).toBe(1) }) -it('saves a startup model pick without writing it to the starting Pi child', async () => { +it('writes a startup model pick once the Pi child is ready', async () => { const { script, host, caller, fence } = await setup() const fields = { key: 'model', value: 'scripted/model-2' } - const picked = await host.setOption(caller, { + const picked = host.setOption(caller, { envelope: { sessionId: HOST_TEST_SESSION, clientOperationId: operationId(), @@ -126,18 +126,19 @@ it('saves a startup model pick without writing it to the starting Pi child', asy }, ...fields }) - expect(picked.ok).toBe(true) + expect(script.wire()).not.toContain('set_model') + script.releaseHandshake() + expect((await picked).ok).toBe(true) + expect(script.prompts()).toEqual([]) const body = hostTestMessage('first message') const sent = await host.send(caller, { envelope: envelope('agentSession.send', { body }), body }) expect(sent.ok).toBe(true) - script.releaseHandshake() - // A startup pick is saved for a later start at this piece. await vi.waitFor(() => expect(script.prompts()).toEqual(['first message'])) const wire = script.wire() - expect(wire).not.toContain('set_model') + expect(wire).toContain('set_model') const { sessions, serialize } = host.collaboratorsForTests() await vi.waitFor(() => expect(sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe('ready')) await serialize(HOST_TEST_SESSION, async () => {}) From 03bc33464b7e5bab9702c9e94b24307e6a350b3a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:27:18 -0700 Subject: [PATCH 062/127] Cover goal changes during the held Codex message wait [skip ci] --- ...ured-agent-session-codex-held-goal.test.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-codex-held-goal.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-held-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-held-goal.test.ts new file mode 100644 index 000000000000..43b7dca105de --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-held-goal.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) +}) + +afterEach(() => rig.dispose()) + +async function publishRunningTurn(first: string): Promise { + rig.providerEvents().appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-a', ordinal: 99 }, + { + kind: 'turn', + turnId: 'turn-a', + state: 'running', + userItemId: agentJournalSubmissionKey(first) + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rig.host.flushStreamedEvents(SESSION) +} + +it.each([ + { label: 'pause', change: { kind: 'status', status: 'paused' } }, + { label: 'edit', change: { kind: 'set', objective: 'Updated objective' } }, + { label: 'clear', change: { kind: 'clear' } } +] as const)( + 'runs a goal $label while Codex holds an earlier send for different choices', + async ({ change }) => { + const changeGoal = vi.fn(async () => ({ ok: true as const })) + rig.host.deps.adapter.changeThreadGoal = changeGoal + const fields = { key: 'model', value: 'model-a' } + expect( + await rig.host.setOption(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.setOption', hostTestOperationId()), + ...fields + }) + ).toMatchObject({ ok: true }) + const first = await rig.workingSend() + await rig.settleAccepted(first, 'a') + await publishRunningTurn(first) + expect( + rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.newestTurn() + ).toMatchObject({ state: 'running', userItemId: agentJournalSubmissionKey(first) }) + const held = rig.send('Use a different model', undefined, { + executionOptions: { model: 'model-b' } + }) + expect(await held.result).toMatchObject({ ok: true }) + await rig.host.collaboratorsForTests().serialize(SESSION, async () => {}) + expect(rig.dispatch).toHaveBeenCalledOnce() + const goal = rig.host.changeThreadGoal(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ change }, 'agentSession.threadGoal', hostTestOperationId()), + change + }) + await eventually(() => expect(changeGoal).toHaveBeenCalledOnce()) + expect(await goal).toMatchObject({ ok: true }) + expect(changeGoal).toHaveBeenCalledWith(expect.objectContaining({ change })) + expect(rig.dispatch).toHaveBeenCalledOnce() + expect(await rig.submission(held.id)).toMatchObject({ dispatchState: 'pending' }) + expect((await rig.submission(held.id))?.handedOverAt).toBeUndefined() + } +) + +it('releases a startup goal when a second message parks while the first turn opens', async () => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ starting: true, restartable: true }) + const changeGoal = vi.fn(async () => ({ ok: true as const })) + rig.host.deps.adapter.changeThreadGoal = changeGoal + const first = rig.send('First model', undefined, { executionOptions: { model: 'model-a' } }) + const second = rig.send('Second model', undefined, { executionOptions: { model: 'model-b' } }) + await first.result + await second.result + const change = { kind: 'set' as const, objective: 'A startup objective' } + const goal = rig.host.changeThreadGoal(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ change }, 'agentSession.threadGoal', hostTestOperationId()), + change + }) + await rig.host.collaboratorsForTests().serialize(SESSION, async () => {}) + await rig.proveStart() + await eventually(() => expect(changeGoal).toHaveBeenCalledOnce()) + expect(await goal).toMatchObject({ ok: true }) + expect(rig.dispatch).toHaveBeenCalledOnce() + await rig.settleAccepted(first.id, 'a') + await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) +}) 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 063/127] 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 064/127] 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 065/127] 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 066/127] 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 59267e1c1ce77705cb6ca6bf45806211688496fd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:50:04 -0700 Subject: [PATCH 067/127] Check the saved picker selection in per-message choice fixtures --- ...uctured-startup-unanswered-control-request.test.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 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 2cba0bdb2c0f..6466c0507b58 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 @@ -171,16 +171,11 @@ describe('a Claude start whose CLI answers initialize but not a control request' 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'] }) + expect(await picker(host)).toEqual({ model: 'sonnet', effort: undefined, confirmed: undefined }) - // A turn shows the child running the CLI's own model: the picker follows it, the record - // keeps what the user chose. + // A live report describes the turn; the picker keeps the saved next-message choice. turnReportsModel('claude-sonnet-5') - expect(await picker(host)).toEqual({ - model: 'claude-sonnet-5', - effort: 'high', - confirmed: ['model', 'effort'] - }) + expect(await picker(host)).toEqual({ model: 'sonnet', effort: undefined, confirmed: undefined }) expect(record(host)?.options).toEqual({ model: 'sonnet' }) behavior.optionWritesHang = false From bbe11a97321055f069ae1c692d43ee16fa6993e8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:28:32 -0700 Subject: [PATCH 068/127] Exercise current choices and eager client capabilities in runtime fixtures --- ...-session-codex-turn-end-settlement.test.ts | 27 ++++--- ...ructured-agent-session-integration.test.ts | 79 +++++++++++-------- 2 files changed, 63 insertions(+), 43 deletions(-) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index f41061392fd6..65f26c0b0a2e 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -283,7 +283,10 @@ beforeEach(async () => { resolveEnvironment: async () => ({ PATH: process.env.PATH }), ...scriptedCodexTransport(openConnection) }) - const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + const attachParams = hostTestAttachParams(null, { + providerHandle: undefined, + options: { model: MODEL.model } + }) attachParams.envelope.clientOperationId = operationId() const attached = await host.attach(CALLER, attachParams) if (!attached.ok) { @@ -363,10 +366,11 @@ describe('the turn a withdrawn Codex send was answered into', () => { } } - it("is that turn's record, started by the send that opened it and steered by a later one", async () => { + it('keeps the turn identity of a steering send after the opening message is identified', async () => { const opening = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) turns.start() + turns.echo(opening) const steered = await send('and check the tests') await vi.waitFor(() => expect(steers).toBe(1)) @@ -377,9 +381,7 @@ describe('the turn a withdrawn Codex send was answered into', () => { const { turnRecords } = await answeredInto(opening) expect(turnRecords).toHaveLength(1) - const started = { turnItemId: turnRecords[0], via: 'start' } const steeredIn = { turnItemId: turnRecords[0], via: 'steer' } - expect(await answeredInto(opening)).toEqual({ turnRecords, named: started, onPage: started }) expect(await answeredInto(steered)).toEqual({ turnRecords, named: steeredIn, @@ -558,12 +560,7 @@ describe("a queued card's Send-now while a Codex turn runs", () => { }) describe('a second send made after Codex answered the first, before it opened that turn', () => { - it('joins that turn once it opens, on a Codex that names a steered start falsely, so a Stop withdraws both', async () => { - turns = codexTurnLifecycleFake( - THREAD, - () => (method, params) => handlers?.onNotification?.(method, params), - { legacyStartAnswers: true } - ) + it('waits for the opener before steering the later send, which Stop then withdraws', async () => { const opening = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) const followUp = await send('and check the tests') @@ -573,6 +570,14 @@ describe('a second send made after Codex answered the first, before it opened th await vi.waitFor(() => expect(loop.isRunning(SESSION)).toBe(false)) expect(openWaits.turnIds).toEqual([]) turns.start() + await host.flushStreamedEvents(SESSION) + expect(host.collaboratorsForTests().sessions.get(SESSION)?.journal.newestTurn()?.state).toBe( + 'running' + ) + await vi.waitFor(() => expect(loop.isRunning(SESSION)).toBe(false)) + expect(steers).toBe(0) + expect(verdictOf((await settled()).submissions, followUp)).toBe('pending') + turns.echo(opening) await vi.waitFor(() => expect(steers).toBe(1)) expect(answers).toBe(1) @@ -582,7 +587,7 @@ describe('a second send made after Codex answered the first, before it opened th expect(verdictOf((await settled()).submissions, followUp)).not.toBe('pending') ) const after = await settled() - expect(verdictOf(after.submissions, opening)).toBe('withdrawn') + expect(verdictOf(after.submissions, opening)).toBe('accepted') expect(verdictOf(after.submissions, followUp)).toBe('withdrawn') expect(after.owesWork).toBe(false) }) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 1696472ef8a9..19d3997834b3 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -17,10 +17,8 @@ import type { openCodexAppServerConnection } from '../codex/codex-app-server-connection' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from '../../shared/protocol-version' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../ipc/desktop-renderer-runtime-capabilities' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../shared/agent-session-create-capabilities' import type { AgentJournalRenderItem, AgentJournalSubmission @@ -61,16 +59,15 @@ const JOURNAL_IDENTITY = { agent: 'codex' as const, providerHandle: codexProviderHandle(THREAD) } -// The capability set the desktop renderer advertises. Without the pending-send +// An eager-start client with current options capabilities. Without the pending-send // one the host holds the reply until the send settles, which is a shim for // clients too old to render a pending bubble — not what this suite models. const CLIENT = { clientId: 'device-a', clientKind: 'runtime' as const, - clientCapabilities: [ - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ] + clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES.filter( + (capability) => capability !== AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY + ) } // ─── the fake `codex app-server` ──────────────────────────────────────────── @@ -108,11 +105,19 @@ function fakeCodex(): CodexScript { request: async (method, params) => { connection.calls.push({ method, params }) if (method === 'thread/start') { - return { thread: { id: THREAD, path: '/rollouts/integration.jsonl' } } + return { + thread: { id: THREAD, path: '/rollouts/integration.jsonl' }, + model: 'gpt-live', + reasoningEffort: 'medium' + } } if (method === 'thread/resume') { connection.resumedThreadId = (params as { threadId: string }).threadId - return { thread: { id: connection.resumedThreadId } } + return { + thread: { id: connection.resumedThreadId }, + model: 'gpt-live', + reasoningEffort: 'medium' + } } if (method === 'turn/start') { return { turn: { id: TURN } } @@ -336,7 +341,7 @@ beforeEach(async () => { providerHandle: _providerHandle, ...resolved } = attachParams(null) - return resolved + return { ...resolved, options: { model: 'gpt-live' } } }, publishStructuredAgentSessionTab: () => {}, ensureStructuredAgentSessionHost: () => @@ -442,13 +447,15 @@ describe('a structured codex session over agentSession.*', () => { createIntentParams() ) expect(created.page.items.map(textOf)).toContain('legacy question') - expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ - ok: true, - result: { - models: [{ id: 'gpt-live', defaultEffort: 'medium' }], - current: { model: 'gpt-live' } - } - }) + await vi.waitFor(async () => + expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ + ok: true, + result: { + models: [{ id: 'gpt-live', defaultEffort: 'medium' }], + current: { model: 'gpt-live' } + } + }) + ) }) it('dispatches and streams a plain first send from a fresh session', async () => { @@ -534,7 +541,11 @@ describe('a structured codex session over agentSession.*', () => { expect(stream[0]).toMatchObject({ type: 'snapshot', sessionId: SESSION }) // ── options ───────────────────────────────────────────────────────────── - const options = await call('agentSession.options', { sessionId: SESSION }) + const options = await vi.waitFor(async () => { + const result = await call('agentSession.options', { sessionId: SESSION }) + expect(result).toMatchObject({ ok: true, result: { current: { model: 'gpt-live' } } }) + return result + }) expect(options).toMatchObject({ ok: true, result: { @@ -552,10 +563,12 @@ describe('a structured codex session over agentSession.*', () => { key: 'effort', value: 'high' }) - expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ - ok: true, - result: { current: { model: 'gpt-live', effort: 'high' } } - }) + await vi.waitFor(async () => + expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ + ok: true, + result: { current: { model: 'gpt-live', effort: 'high' } } + }) + ) expect(itemsOf(stream)).toEqual([]) // ── send ──────────────────────────────────────────────────────────────── @@ -697,13 +710,15 @@ describe('a structured codex session over agentSession.*', () => { expect(resumed.fence).toBe(fence + 1) expect(reaped.closed).toBe(true) expect(codex.live().resumedThreadId).toBe(THREAD) - expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ - ok: true, - result: { - models: [{ id: 'gpt-live', defaultEffort: 'medium' }], - current: { model: 'gpt-live', effort: 'high' } - } - }) + await vi.waitFor(async () => + expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ + ok: true, + result: { + models: [{ id: 'gpt-live', defaultEffort: 'medium' }], + current: { model: 'gpt-live', effort: 'high' } + } + }) + ) // The journal belongs to the session, not to the process that just died. expect(resumed.page.items.map(textOf)).toContain('Two files.') @@ -727,7 +742,7 @@ describe('a structured codex session over agentSession.*', () => { // The turn's lifecycle row outlives the turn: it is revised, never tombstoned. expect([...older.page.items, ...tail.page.items].map((item) => item.body?.kind)).toEqual([ 'message', - 'status', + 'turn', 'message', 'tool-call', 'approval', From ce99d7a8ddcca5c9fb10abde62adc10ba4266c28 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:52:37 -0700 Subject: [PATCH 069/127] Reuse runtime fixture reads and type the fake connection directly --- ...-session-codex-turn-end-settlement.test.ts | 50 +++++--------- ...ructured-agent-session-integration.test.ts | 66 +++++++------------ 2 files changed, 39 insertions(+), 77 deletions(-) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 65f26c0b0a2e..0b1bd318c2ed 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -302,6 +302,14 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) +async function runningTurn(): Promise { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + return opening +} + describe('a Codex send its turn ended without taking it', () => { it('is withdrawn by a Stop before any echo, and nothing reads as working after', async () => { const sent = await send('look around') @@ -327,10 +335,7 @@ describe('a Codex send its turn ended without taking it', () => { }) it('withdraws a follow-up Codex steered into the turn a Stop ends, with no working latch', async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + const opening = await runningTurn() const followUp = await send('and check the tests') // Steered into the running turn by name, which fires no second turn/started. await vi.waitFor(() => expect(steers).toBe(1)) @@ -367,10 +372,7 @@ describe('the turn a withdrawn Codex send was answered into', () => { } it('keeps the turn identity of a steering send after the opening message is identified', async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + const opening = await runningTurn() const steered = await send('and check the tests') await vi.waitFor(() => expect(steers).toBe(1)) @@ -390,10 +392,7 @@ describe('the turn a withdrawn Codex send was answered into', () => { }) it('is not named on a send the turn took', async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + const opening = await runningTurn() await stop('turn-1') await vi.waitFor(async () => @@ -444,10 +443,7 @@ describe('a queued card sent now into the turn a Stop ends', () => { } it('comes back as a paused waiting card, and nothing sends it again', async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + await runningTurn() const { messageId: cardId } = await queueThenSendNow('and check the tests') // Steered into the running turn, with no echo yet. await vi.waitFor(() => expect(steers).toBe(1)) @@ -491,10 +487,7 @@ describe('a Codex before 0.148, which names a steered start falsely', () => { () => (method, params) => handlers?.onNotification?.(method, params), { legacyStartAnswers: true } ) - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + await runningTurn() const followUp = await send('and check the tests') await vi.waitFor(() => expect(steers + answers).toBe(2)) @@ -512,13 +505,6 @@ describe('a Codex before 0.148, which names a steered start falsely', () => { }) describe("a queued card's Send-now while a Codex turn runs", () => { - async function runningTurn(): Promise { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) - } - it('goes into that turn as turn/steer, and the turn settles it when Codex echoes it', async () => { await runningTurn() @@ -869,10 +855,7 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { // Handed over once the stopped turn read ended, it started its own turn, however late that // turn's own end then arrives; a second Stop finds its turn never opened. it('withdraws a send made after a Stop that started its own turn, which never opened', async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + await runningTurn() interruptEndHeld = true await stop() interruptEndHeld = false @@ -889,10 +872,7 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => { // Codex drains a steer into the running turn, so it may hold one it never echoed. it('leaves a send steered into the open turn in doubt when the child end follows', async () => { - const opening = await send('look around') - await vi.waitFor(() => expect(answers).toBe(1)) - turns.start() - turns.echo(opening) + await runningTurn() const steered = await send('and check the tests') await vi.waitFor(() => expect(steers).toBe(1)) interruptsFail = true diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 19d3997834b3..680e51150ba4 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -93,7 +93,7 @@ type FakeConnection = Omit & { function fakeCodex(): CodexScript { const connections: FakeConnection[] = [] - const openConnection = (async (launch, handlers = {}) => { + const openConnection: typeof openCodexAppServerConnection = async (launch, handlers = {}) => { const connection: FakeConnection = { launch, handlers, @@ -105,19 +105,11 @@ function fakeCodex(): CodexScript { request: async (method, params) => { connection.calls.push({ method, params }) if (method === 'thread/start') { - return { - thread: { id: THREAD, path: '/rollouts/integration.jsonl' }, - model: 'gpt-live', - reasoningEffort: 'medium' - } + return { thread: { id: THREAD, path: '/rollouts/integration.jsonl' } } } if (method === 'thread/resume') { connection.resumedThreadId = (params as { threadId: string }).threadId - return { - thread: { id: connection.resumedThreadId }, - model: 'gpt-live', - reasoningEffort: 'medium' - } + return { thread: { id: connection.resumedThreadId } } } if (method === 'turn/start') { return { turn: { id: TURN } } @@ -152,7 +144,7 @@ function fakeCodex(): CodexScript { } connections.push(connection) return connection - }) as typeof openCodexAppServerConnection + } const live = (): FakeConnection => { const connection = connections.at(-1) if (!connection) { @@ -323,6 +315,20 @@ async function historyPage( return (response as { result: AgentSessionHistoryResult }).result } +async function readKnownOptions(): Promise { + return vi.waitFor(async () => { + const result = await call('agentSession.options', { sessionId: SESSION }) + expect(result).toMatchObject({ + ok: true, + result: { + models: [{ id: 'gpt-live', defaultEffort: 'medium' }], + current: { model: 'gpt-live' } + } + }) + return result + }) +} + beforeEach(async () => { operations = 0 root = await mkdtemp(join(tmpdir(), 'orca-structured-integration-')) @@ -447,15 +453,7 @@ describe('a structured codex session over agentSession.*', () => { createIntentParams() ) expect(created.page.items.map(textOf)).toContain('legacy question') - await vi.waitFor(async () => - expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ - ok: true, - result: { - models: [{ id: 'gpt-live', defaultEffort: 'medium' }], - current: { model: 'gpt-live' } - } - }) - ) + await readKnownOptions() }) it('dispatches and streams a plain first send from a fresh session', async () => { @@ -541,18 +539,7 @@ describe('a structured codex session over agentSession.*', () => { expect(stream[0]).toMatchObject({ type: 'snapshot', sessionId: SESSION }) // ── options ───────────────────────────────────────────────────────────── - const options = await vi.waitFor(async () => { - const result = await call('agentSession.options', { sessionId: SESSION }) - expect(result).toMatchObject({ ok: true, result: { current: { model: 'gpt-live' } } }) - return result - }) - expect(options).toMatchObject({ - ok: true, - result: { - models: [{ id: 'gpt-live', defaultEffort: 'medium' }], - current: { model: 'gpt-live' } - } - }) + await readKnownOptions() await ok('agentSession.setOption', { envelope: envelope('agentSession.setOption', { key: 'model', value: 'gpt-live' }, fence), key: 'model', @@ -710,15 +697,10 @@ describe('a structured codex session over agentSession.*', () => { expect(resumed.fence).toBe(fence + 1) expect(reaped.closed).toBe(true) expect(codex.live().resumedThreadId).toBe(THREAD) - await vi.waitFor(async () => - expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ - ok: true, - result: { - models: [{ id: 'gpt-live', defaultEffort: 'medium' }], - current: { model: 'gpt-live', effort: 'high' } - } - }) - ) + expect(await readKnownOptions()).toMatchObject({ + ok: true, + result: { current: { effort: 'high' } } + }) // The journal belongs to the session, not to the process that just died. expect(resumed.page.items.map(textOf)).toContain('Two files.') 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 070/127] 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 7364de75985aa256c5a81ef97eec8a5494b1e5b3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:48:18 -0700 Subject: [PATCH 071/127] Honor queued send authorization in Claude runtime fixtures --- .../runtime/claude-structured-fake-connection-test-fixture.ts | 3 ++- src/main/runtime/claude-structured-session-integration.test.ts | 3 ++- .../runtime/structured-claude-scripted-runtime-test-support.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) 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 528d8a8b7c7d..0d20297fa9ee 100644 --- a/src/main/runtime/claude-structured-fake-connection-test-fixture.ts +++ b/src/main/runtime/claude-structured-fake-connection-test-fixture.ts @@ -85,7 +85,8 @@ export function fakeClaude(providerSession: string) { stopTask: async (taskId) => { connection.calls.push({ subtype: 'stop_task', params: { taskId } }) }, - send: async (message) => { + send: async (message, beforeDispatch) => { + await beforeDispatch?.() connection.sent.push(message) if (message.type === 'user') { handlers.onMessage?.({ ...message, uuid: 'user-1' }) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 2e4009ed7e42..7fbc53248df7 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -534,7 +534,8 @@ describe('a structured Claude session over agentSession.*', () => { return false } // Claude takes the message but crashes before echoing it. - connection.send = async (message) => { + connection.send = async (message, beforeDispatch) => { + await beforeDispatch?.() connection.sent.push(message) } const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'in flight' }] } diff --git a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts index b776140c68df..6e9a2f147c93 100644 --- a/src/main/runtime/structured-claude-scripted-runtime-test-support.ts +++ b/src/main/runtime/structured-claude-scripted-runtime-test-support.ts @@ -188,7 +188,8 @@ export function createScriptedClaudeRuntime(sessionIds: readonly string[]) { interrupt: async () => undefined, cancelAsyncMessage: async () => false, stopTask: async () => {}, - send: async () => { + send: async (_message, beforeDispatch) => { + await beforeDispatch?.() child.calls.push('send') }, close: async () => { 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 072/127] 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 073/127] 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 35ee605c1dd74b9e06ad498af782e7c9c4a2ade1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:04:33 -0700 Subject: [PATCH 074/127] Keep repeated permission picks usable and align Codex fixtures --- ...nt-session-codex-opening-send-hold.test.ts | 20 ++++++++++++------- .../structured-child-records-switch.test.ts | 13 +++++++++++- .../use-structured-agent-session-options.ts | 3 --- .../structured-agent-session-options.ts | 3 ++- 4 files changed, 27 insertions(+), 12 deletions(-) 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 464c7bb2e84f..df6cbd279f69 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 @@ -241,7 +241,10 @@ beforeEach(async () => { resolveEnvironment: async () => ({ PATH: process.env.PATH }), ...scriptedCodexTransport(openConnection) }) - const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + const attachParams = hostTestAttachParams(null, { + providerHandle: undefined, + options: { model: MODEL.model } + }) attachParams.envelope.clientOperationId = operationId() const attached = await host.attach(CALLER, attachParams) if (!attached.ok) { @@ -350,9 +353,10 @@ describe("a message queued behind a send whose turn hasn't opened", () => { expect(openWaits.turnIds).toEqual([]) turns.start() + expect((await submission(second))?.handedOverAt).toBeUndefined() + turns.echo(first) await vi.waitFor(async () => expect((await submission(second))?.handedOverAt).toBeDefined()) await vi.waitFor(() => expect(steers).toBe(1)) - turns.echo(first) says('working on the first', 'reply-1') says('more on both', 'reply-2') @@ -386,8 +390,8 @@ describe("a message queued behind a send whose turn hasn't opened", () => { expect((await submission(b))?.handedOverAt).toBeUndefined() turns.start() - await vi.waitFor(() => expect(steers).toBe(2)) turns.echo(zero) + await vi.waitFor(() => expect(steers).toBe(2)) says('working on zero', 'reply-0') expect(await rowScope(a)).toMatchObject({ kind: 'turn' }) @@ -497,17 +501,19 @@ describe('a send whose turn Codex answered and never opened', () => { expect(verdictOf((await settled()).submissions, first)).toBe('pending') expect((await submission(second))?.handedOverAt).toBeUndefined() turns.start() - await vi.waitFor(() => expect(steers).toBe(1)) turns.echo(first) + await vi.waitFor(() => expect(steers).toBe(1)) await vi.waitFor(async () => expect(verdictOf((await settled()).submissions, first)).toBe('accepted') ) }) - // Steered in the moment the turn opens, it can be echoed before the send that opened the turn. - it('keeps the turn, and its live status, on the send that opened it when the steer echoes first', async () => { + // The opener's echo identifies the choices a matching follow-up may steer into. + it('keeps the turn and its live status on the opener when a later steer echoes', async () => { const { first, second } = await heldBehindAnAnsweredSend() turns.start() + expect((await submission(second))?.handedOverAt).toBeUndefined() + turns.echo(first) await vi.waitFor(() => expect(steers).toBe(1)) turns.echo(second) @@ -515,7 +521,7 @@ describe('a send whose turn Codex answered and never opened', () => { expect(verdictOf((await settled()).submissions, second)).toBe('accepted') ) - expect(verdictOf((await settled()).submissions, first)).toBe('pending') + expect(verdictOf((await settled()).submissions, first)).toBe('accepted') const rows = await drawn() expect(rows.map((row) => row.text)).toEqual(['look around', 'and check the tests']) expect(new Set(rows.map((row) => row.turn))).toEqual( diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index 949afdb89dc6..8fe711907688 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -44,6 +44,7 @@ const THREAD = 'thread-switch' const REVIEWER = 'thread-reviewer' const ROUTES: Record = { 'thread/start': { thread: { id: THREAD } }, + 'turn/start': { turn: { id: 'p2', status: 'inProgress' } }, 'model/list': { data: [ { @@ -132,7 +133,10 @@ describe('the chat strip and the session list read the same host child records', ...scriptedCodexTransport(openConnection), statusSink }) - const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + const attachParams = hostTestAttachParams(null, { + providerHandle: undefined, + options: { model: 'gpt-test' } + }) attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` const attached = await host.attach({ callerKey: 'switch-test' }, attachParams) expect(attached).toMatchObject({ ok: true }) @@ -317,6 +321,13 @@ describe('the chat strip and the session list read the same host child records', body } ) + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientOperationId + )?.handedOverAt + ).toBeDefined() + ) await notify('turn/started', { threadId: THREAD, turn: { id: 'p2', status: 'inProgress' } }) await notify('item/started', { threadId: THREAD, diff --git a/src/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 1f089317b151..c467f7ed1f22 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 @@ -256,9 +256,6 @@ export function useStructuredAgentSessionOptions(args: { void applied?.then(settleLaunchOptionPick) return applied === null ? null : { [id]: encoded } } - if (optionStateRef.current.pendingPick !== null) { - return null - } return sendStructuredOption(id, encoded) }, [ diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index e1c739c7bb79..7b13519b3aa5 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -219,7 +219,8 @@ export function canSetStructuredAgentSessionOption( const descriptor = structuredAgentSessionOptionSnapshot(state).find((entry) => entry.id === id) return Boolean( state.catalog && - state.pendingPick === null && + (state.pendingPick === null || + (id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID && state.pendingPick.id === id)) && ((typeof value === 'string' && descriptor?.kind.type === 'select' && descriptor.kind.choices.some((choice) => choice.value === value)) || From 466dc12cf124f0e477095dd0096b4fb1b0afa389 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:26:33 -0700 Subject: [PATCH 075/127] Validate pending picks against the live option state --- .../native-chat/use-structured-agent-session-options.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 c467f7ed1f22..609c955523f5 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 @@ -246,7 +246,11 @@ export function useStructuredAgentSessionOptions(args: { if ( !optionCatalog || encoded === null || - !canSetStructuredAgentSessionOption(optionView, id, value) + !canSetStructuredAgentSessionOption( + { ...optionView, pendingPick: optionStateRef.current.pendingPick }, + id, + value + ) ) { return null } 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 076/127] 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 077/127] 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 078/127] 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 079/127] 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 080/127] 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 081/127] 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 b85ccadc250052a4223f867d09f3357fb3299b4f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:11:50 -0700 Subject: [PATCH 082/127] Keep startup recovery flows and remove duplicate Pi handshake cases --- src/main/pi/rpc-session-adapter.test.ts | 74 --------------- src/main/pi/rpc-session-startup.test.ts | 67 -------------- ...provider-operations-while-starting.test.ts | 89 +------------------ 3 files changed, 1 insertion(+), 229 deletions(-) diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index d4d2e4c4bf11..004a21b5e6be 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -5,7 +5,6 @@ import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/age import { JsonlRpcResponseError } from '../jsonl-rpc/peer' import { closePiRpcAdapterFixtures, - FakeConnection, file, sessionId, setup @@ -46,51 +45,6 @@ describe('Pi RPC session ownership and delivery', () => { expect(h.connections).toHaveLength(2) }) - it('closes a published child during a stalled handshake without reporting started', async () => { - const h = await setup() - await h.adapter.closeSession(sessionId) - const opened = Promise.withResolvers() - const reply = Promise.withResolvers() - h.openConnection.mockImplementationOnce((_launch, handlers) => { - const connection = new FakeConnection(handlers) - connection.requestOverride = (command) => - command === 'get_state' ? reply.promise : undefined - connection.abortRequest = () => reply.reject(new Error('closed while starting')) - h.connections.push(connection) - opened.resolve(connection) - return connection - }) - const acquired = h.adapter.acquire({ ...h.input, fence: 8 }) - const child = await opened.promise - await Promise.resolve() - const owned = await acquired - expect(owned).toMatchObject({ process: { pid: 4123 } }) - const opening = h.adapter - .open({ - sessionId, - fence: 8, - acquisitionGeneration: owned.acquisitionGeneration ?? null, - signal: new AbortController().signal - }) - .catch((error: unknown) => error) - h.lifecycle.mockClear() - expect(h.adapter.readCommands(sessionId)).toBeUndefined() - expect(h.adapter.holdsDispatch(sessionId)).toBe(false) - await h.adapter.closeSession(sessionId) - await h.adapter.drainObservedExits() - expect(h.lifecycle).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'ended', - cause: 'requested-close' - }) - ) - expect(h.lifecycle).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'started', fence: 8 }) - ) - expect(child.rootVerdict).toBe('exited') - expect(await opening).toBeInstanceOf(Error) - }) - it('retains final events when exit publication times out under journal backpressure', async () => { let blocked = true const h = await setup({}, (sink) => ({ @@ -428,31 +382,3 @@ describe('Pi RPC session ownership and delivery', () => { expect(await other.adapter.closeSession(sessionId)).toBe(false) }) }) - -describe('Pi RPC start without a model', () => { - it('refuses a successfully empty listing and leaves cleanup to the host', async () => { - const h = await setup() - await h.adapter.closeSession(sessionId) - h.openConnection.mockImplementationOnce((_launch, handlers) => { - const connection = new FakeConnection(handlers) - // What a signed-out Pi lists (captured from Pi 1.0.4 in __fixtures__/signed-out.jsonl). - connection.requestOverride = (command) => - command === 'get_available_models' ? Promise.resolve({ models: [] }) : undefined - h.connections.push(connection) - return connection - }) - const acquired = await h.adapter.acquire({ ...h.input, fence: 8 }) - await expect( - h.adapter.open({ - sessionId, - fence: 8, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, - signal: new AbortController().signal - }) - ).rejects.toMatchObject({ reason: 'notSignedIn' }) - const child = h.connections.at(-1)! - expect(child.closed).toBe(false) - await h.adapter.closeSession(sessionId) - expect(child.closed).toBe(true) - }) -}) diff --git a/src/main/pi/rpc-session-startup.test.ts b/src/main/pi/rpc-session-startup.test.ts index cf54e06637a1..e381e875d18b 100644 --- a/src/main/pi/rpc-session-startup.test.ts +++ b/src/main/pi/rpc-session-startup.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it } from 'vitest' -import { JsonlRpcResponseError } from '../jsonl-rpc/peer' import { closePiRpcAdapterFixtures, restart, @@ -45,72 +44,6 @@ describe('Pi host awaited open', () => { expect(h.lifecycle).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) }) - it('rejects a known empty listing once, then opens a fresh child after sign-in', async () => { - const h = await setup() - const { acquired, child } = await restart(h, (connection) => { - connection.requestOverride = (command) => - command === 'get_available_models' ? Promise.resolve({ models: [] }) : undefined - }) - await expect( - h.adapter.open({ - sessionId, - fence: 8, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, - signal: new AbortController().signal - }) - ).rejects.toMatchObject({ reason: 'notSignedIn' }) - expect(child.closed).toBe(false) - expect(child.sent).toEqual([]) - await h.adapter.closeSession(sessionId) - const again = await h.adapter.acquire({ ...h.input, fence: 9 }) - await expect( - h.adapter.open({ - sessionId, - fence: 9, - acquisitionGeneration: again.acquisitionGeneration ?? null, - signal: new AbortController().signal - }) - ).resolves.toMatchObject({ reportedOptions: { model: 'anthropic/model-1' } }) - expect(h.connections).toHaveLength(3) - }) - - it('treats an unknown provider as signed out even when listing succeeds', async () => { - const h = await setup() - const { acquired, child } = await restart(h, (connection) => { - connection.requestOverride = (command) => - command === 'get_state' - ? Promise.resolve({ ...state, model: { provider: 'unknown', id: 'unknown' } }) - : undefined - }) - await expect( - h.adapter.open({ - sessionId, - fence: 8, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, - signal: new AbortController().signal - }) - ).rejects.toMatchObject({ reason: 'notSignedIn' }) - expect(child.requests).toEqual(['get_state']) - }) - - it('keeps a failed optional listing distinct from a known empty list', async () => { - const h = await setup() - const { acquired } = await restart(h, (connection) => { - connection.requestOverride = (command) => - command === 'get_available_models' - ? Promise.reject(new JsonlRpcResponseError(command, 'listing unavailable')) - : undefined - }) - await expect( - h.adapter.open({ - sessionId, - fence: 8, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, - signal: new AbortController().signal - }) - ).resolves.toMatchObject({ reportedOptions: { model: 'anthropic/model-1' } }) - }) - it('lets the host close a child held in the state request without a late open', async () => { const h = await setup() const stateReply = Promise.withResolvers() diff --git a/src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts b/src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts index 66c7fd735e72..e6cd3762a512 100644 --- a/src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts +++ b/src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts @@ -1,6 +1,4 @@ -// An operation only the agent's protocol session can perform (a rewind, a goal, the recovery of a -// rewind left in doubt) never reaches a child still proving its start, for any agent. Driven through -// the real Codex and ACP adapters with only the provider child scripted. +// Protocol operations wait for the real Codex adapter to open; only its child is scripted. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -9,9 +7,6 @@ import { afterEach, expect, it, vi } from 'vitest' import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../shared/agent-session-wire' -import { ACP_LAUNCH_SPECS } from '../acp/acp-launch-specs' -import { acpScriptedChild } from '../acp/acp-scripted-child.test-fixture' -import { acpStructuredAgentDefinition } from '../acp/acp-structured-agent-definitions' import { CODEX_STRUCTURED_AGENT } from '../codex/codex-structured-agent-definition' import type { StructuredAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' import { @@ -142,46 +137,6 @@ async function settlesSoon(pending: Promise): Promise { return settled } -it('recovers a Codex rewind left in doubt once the restarted thread is open, then delivers the send that started it', async () => { - const child = codexScriptedChild({ - 'thread/read': () => ({ thread: { id: CODEX_SCRIPTED_THREAD, status: { type: 'idle' } } }), - // The rewound turn is gone and nothing earlier is retained: the provider applied the rewind. - 'thread/turns/list': () => ({ data: [], nextCursor: null }), - 'thread/items/list': () => ({ data: [], nextCursor: null }) - }) - const { host, itemId, providerItemId } = await codexChatAtRestWithATurn(child) - const { store } = host.deps - const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch - const request = envelope(host, 'agentSession.rewind', { itemId, expectedEpoch }) - await store.admitMutationOperation({ - callerKey: CALLER.callerKey, - envelope: request, - hostFingerprint: request.payloadFingerprint, - now: Date.now() - }) - await store.transitionHandoff(SESSION, (record) => ({ - ...record, - rewind: { - operationId: request.clientOperationId, - callerKey: CALLER.callerKey, - itemId, - providerItemId, - expectedEpoch, - phase: 'prepared', - retained: [] - } - })) - - const sent = send(host, 'after the rewind') - await eventually(() => expect(child.spawns()).toBe(2)) - expect(await settlesSoon(sent)).toBe(false) - child.releaseHandshake() - - expect(await sent).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.rewind?.phase).toBe('completed') - await eventually(() => expect(child.prompts()).toEqual(['first turn', 'after the rewind'])) -}) - it('settles a rewind that goes in doubt while a send waits in the queue, by waiting out the start the send needs', async () => { const child = codexScriptedChild({ 'thread/read': () => ({ thread: { id: CODEX_SCRIPTED_THREAD, status: { type: 'idle' } } }), @@ -257,27 +212,6 @@ it('answers a rewind asked of a resting Codex chat from the thread it opens, lea await eventually(() => expect(child.prompts()).toEqual(['first turn', 'still sends'])) }) -it('sets a goal asked of a starting Codex chat on the thread once it is open', async () => { - const child = codexScriptedChild() - const host = await openChat(child) - const change = { kind: 'set' as const, objective: 'Ship the release' } - - const goal = host.changeThreadGoal(CALLER, { - envelope: envelope(host, 'agentSession.threadGoal', { change }), - change - }) - expect(await settlesSoon(goal)).toBe(false) - child.releaseHandshake() - - expect(await goal).toMatchObject({ ok: true }) - expect(child.requests().filter(({ method }) => method.startsWith('thread/goal'))).toEqual([ - { - method: 'thread/goal/set', - params: { threadId: CODEX_SCRIPTED_THREAD, objective: 'Ship the release', status: 'active' } - } - ]) -}) - it('keeps a goal waiting on a starting Codex chat through a re-attach, then sets it once the thread is open', async () => { const child = codexScriptedChild() const host = await openChat(child) @@ -300,24 +234,3 @@ it('keeps a goal waiting on a starting Codex chat through a re-attach, then sets expect(await goal).toMatchObject({ ok: true }) expect(child.requests().some(({ method }) => method === 'thread/goal/set')).toBe(true) }) - -it('answers a goal asked of an ACP agent, which has none, without waiting out its start', async () => { - const spec = ACP_LAUNCH_SPECS[0]! - const child = acpScriptedChild(spec.agent)() - const host = await openChat(child, acpStructuredAgentDefinition(spec)) - const change = { kind: 'set' as const, objective: 'Ship the release' } - - const goal = host.changeThreadGoal(CALLER, { - envelope: envelope(host, 'agentSession.threadGoal', { change }), - change - }) - expect(await settlesSoon(goal)).toBe(true) - expect(await goal).toMatchObject({ - ok: false, - refusal: { details: { reason: 'goalsUnsupported' } } - }) - child.releaseHandshake() - - expect(await send(host, 'after the goal')).toMatchObject({ ok: true }) - await eventually(() => expect(child.prompts()).toEqual(['after the goal'])) -}) From b9e16a8d9efc56a52739fdbc79d87b81fc964e2a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:18:09 -0700 Subject: [PATCH 083/127] Cover both Pi startup choices in the delivery flow --- src/main/pi/rpc-session-startup.test.ts | 34 ----------- ...tructured-agent-session-pi-startup.test.ts | 56 +++++++++---------- 2 files changed, 25 insertions(+), 65 deletions(-) diff --git a/src/main/pi/rpc-session-startup.test.ts b/src/main/pi/rpc-session-startup.test.ts index e381e875d18b..e39c84ec3457 100644 --- a/src/main/pi/rpc-session-startup.test.ts +++ b/src/main/pi/rpc-session-startup.test.ts @@ -10,40 +10,6 @@ import { afterEach(closePiRpcAdapterFixtures) describe('Pi host awaited open', () => { - it('owns the process before RPC and restores model before effort during open', async () => { - const h = await setup() - const stateReply = Promise.withResolvers() - const { acquired, child } = await restart( - h, - (connection) => { - connection.requestOverride = (command) => - command === 'get_state' && connection.requests.length === 1 - ? stateReply.promise - : command === 'set_thinking_level' - ? Promise.resolve({}) - : undefined - }, - { options: { model: 'anthropic/model-2', effort: 'high' } } - ) - expect(child.requests).toEqual([]) - expect(acquired.process.pid).toBe(4123) - const opening = h.adapter.open({ - sessionId, - fence: 8, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, - signal: new AbortController().signal - }) - expect(child.requests).toEqual(['get_state']) - stateReply.resolve(state) - await expect(opening).resolves.toMatchObject({ - reportedOptions: { model: 'anthropic/model-1' } - }) - expect(child.requests.indexOf('set_model')).toBeLessThan( - child.requests.indexOf('set_thinking_level') - ) - expect(h.lifecycle).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) - }) - it('lets the host close a child held in the state request without a late open', async () => { const h = await setup() const stateReply = Promise.withResolvers() diff --git a/src/main/runtime/structured-agent-session-pi-startup.test.ts b/src/main/runtime/structured-agent-session-pi-startup.test.ts index 61dbba5ff0be..ca1250b3041a 100644 --- a/src/main/runtime/structured-agent-session-pi-startup.test.ts +++ b/src/main/runtime/structured-agent-session-pi-startup.test.ts @@ -110,41 +110,35 @@ it('attaches Pi at spawn and holds the opening send until the provider answers i expect(script.closes()).toBe(1) }) -it('writes a startup model pick once the Pi child is ready', async () => { - const { script, host, caller, fence } = await setup() - const fields = { key: 'model', value: 'scripted/model-2' } - const picked = host.setOption(caller, { - envelope: { - sessionId: HOST_TEST_SESSION, - clientOperationId: operationId(), - expectedRuntimeFence: fence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.setOption', - sessionId: HOST_TEST_SESSION, - fields - }) - }, - ...fields - }) +it('writes startup model and effort picks before a message accepted after them', async () => { + const { script, host, caller } = await setup() + const picked = ( + [ + ['model', 'scripted/model-2'], + ['effort', 'low'] + ] as const + ).map(([key, value]) => + host.setOption(caller, { + envelope: envelope('agentSession.setOption', { key, value }), + key, + value + }) + ) + const body = hostTestMessage('first message') + const sent = host.send(caller, { envelope: envelope('agentSession.send', { body }), body }) expect(script.wire()).not.toContain('set_model') script.releaseHandshake() - expect((await picked).ok).toBe(true) - expect(script.prompts()).toEqual([]) - const body = hostTestMessage('first message') - const sent = await host.send(caller, { - envelope: envelope('agentSession.send', { body }), - body - }) - expect(sent.ok).toBe(true) + expect((await Promise.all(picked)).every((result) => result.ok)).toBe(true) + expect((await sent).ok).toBe(true) await vi.waitFor(() => expect(script.prompts()).toEqual(['first message'])) const wire = script.wire() - expect(wire).toContain('set_model') - const { sessions, serialize } = host.collaboratorsForTests() - await vi.waitFor(() => expect(sessions.get(HOST_TEST_SESSION)?.child?.phase).toBe('ready')) - await serialize(HOST_TEST_SESSION, async () => {}) - expect(peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.options?.model).toBe( - fields.value - ) + expect(wire.indexOf('set_model')).toBeGreaterThanOrEqual(0) + expect(wire.indexOf('set_thinking_level')).toBeGreaterThan(wire.indexOf('set_model')) + expect(wire.indexOf('prompt')).toBeGreaterThan(wire.indexOf('set_thinking_level')) + expect(peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.options).toMatchObject({ + model: 'scripted/model-2', + effort: 'low' + }) }) it('ends Pi on Stop and resumes its real session file when the next send starts a child', async () => { From a826ea9311dfd18ef8e197e1634eac0e53718b02 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:21:12 -0700 Subject: [PATCH 084/127] Match the Pi prompt entry recorded by the scripted wire --- src/main/runtime/structured-agent-session-pi-startup.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/runtime/structured-agent-session-pi-startup.test.ts b/src/main/runtime/structured-agent-session-pi-startup.test.ts index ca1250b3041a..ce452f7367a0 100644 --- a/src/main/runtime/structured-agent-session-pi-startup.test.ts +++ b/src/main/runtime/structured-agent-session-pi-startup.test.ts @@ -134,7 +134,7 @@ it('writes startup model and effort picks before a message accepted after them', const wire = script.wire() expect(wire.indexOf('set_model')).toBeGreaterThanOrEqual(0) expect(wire.indexOf('set_thinking_level')).toBeGreaterThan(wire.indexOf('set_model')) - expect(wire.indexOf('prompt')).toBeGreaterThan(wire.indexOf('set_thinking_level')) + expect(wire.indexOf('prompt:first message')).toBeGreaterThan(wire.indexOf('set_thinking_level')) expect(peekOpenedAgentSessionRecordStore()?.getRecord(HOST_TEST_SESSION)?.options).toMatchObject({ model: 'scripted/model-2', effort: 'low' 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 085/127] 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 af5edab90ea7ea2eaaa8b06d5b2b8c148ac5b5be Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:57:46 -0700 Subject: [PATCH 086/127] Keep message choice flows and remove overlapping selector tests --- .../acp-structured-session-adapter.test.ts | 87 ------ .../claude-structured-message-options.test.ts | 26 -- .../codex-structured-message-options.test.ts | 239 ----------------- ...ured-agent-session-message-choices.test.ts | 15 -- ...agent-session-options-cold-catalog.test.ts | 137 ---------- src/main/pi/rpc-session-adapter.test.ts | 113 -------- ...gent-session-options.cold-catalog.test.tsx | 109 -------- ...session-options.current-selection.test.tsx | 173 ------------ ...session-options.no-catalog-effort.test.tsx | 104 -------- ...t-session-options.pending-restart.test.tsx | 98 ------- ...nt-session-options.scoped-default.test.tsx | 148 ----------- ...se-structured-agent-session-sends.test.tsx | 64 +---- ...-session-model-catalog-application.test.ts | 248 ------------------ 13 files changed, 1 insertion(+), 1560 deletions(-) delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-options-cold-catalog.test.ts delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.cold-catalog.test.tsx delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.no-catalog-effort.test.tsx delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.pending-restart.test.tsx delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.scoped-default.test.tsx delete mode 100644 src/shared/structured-agent-session-model-catalog-application.test.ts diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index a6070fa3356f..b470ab562c36 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -180,93 +180,6 @@ describe('ACP structured session adapter: turns', () => { }) }) - it('restores the opened model and effort when the next message omits both choices', async () => { - const rig = await openAcpAdapterRig() - await rig.acquire() - await rig.adapter.dispatch({ - sessionId: SESSION, - fence: 1, - clientMessageId: 'override', - body: hello, - executionOptions: { model: 'grok-4.6', effort: 'low' } - }) - await rig.adapter.dispatch({ - sessionId: SESSION, - fence: 1, - clientMessageId: 'default', - body: hello, - executionOptions: {} - }) - expect(rig.sent('session/set_config_option')).toMatchObject([ - { params: { configId: 'model', value: 'grok-4.6' } }, - { params: { configId: 'reasoning_effort', value: 'low' } }, - { params: { configId: 'model', value: 'grok-4.7' } }, - { params: { configId: 'reasoning_effort', value: 'high' } } - ]) - }) - - it('keeps legacy sends on the current selection when execution options are absent', async () => { - const rig = await openAcpAdapterRig() - await rig.acquire() - await rig.adapter.dispatch({ - sessionId: SESSION, - fence: 1, - clientMessageId: 'override', - body: hello, - executionOptions: { model: 'grok-4.6', effort: 'low' } - }) - await rig.adapter.dispatch({ - sessionId: SESSION, - fence: 1, - clientMessageId: 'legacy', - body: hello - }) - expect(rig.sent('session/set_config_option')).toHaveLength(2) - expect(rig.sent('session/prompt')).toHaveLength(1) - }) - - it('refuses an omitted effort whose new model default cannot be confirmed', async () => { - const rig = await openAcpAdapterRig() - await rig.acquire() - await rig.adapter.dispatch({ - sessionId: SESSION, - fence: 1, - clientMessageId: 'override-effort', - body: hello, - executionOptions: { model: 'grok-4.7', effort: 'low' } - }) - await expect( - rig.adapter.dispatch({ - sessionId: SESSION, - fence: 1, - clientMessageId: 'unknown-default', - body: hello, - executionOptions: { model: 'grok-4.6' } - }) - ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) - expect(rig.sent('session/prompt')).toHaveLength(1) - }) - - it('does not send a prompt when the agent refuses a message’s model', async () => { - const rig = await openAcpAdapterRig({ - script: (agent) => - agent.on('session/set_config_option', (frame) => - agent.fail(frame, -32602, 'Model unavailable') - ) - }) - await rig.acquire() - await expect( - rig.adapter.dispatch({ - sessionId: SESSION, - fence: 1, - clientMessageId: 'refused-model', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - executionOptions: { model: 'grok-4.6' } - }) - ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) - expect(rig.sent('session/prompt')).toEqual([]) - }) - it("sends Grok's prompt identity only to an agent whose dialect echoes it", async () => { const rig = await openAcpAdapterRig({ deps: { spec: { ...GROK, dialect: GENERIC_ACP_DIALECT } } diff --git a/src/main/claude/claude-structured-message-options.test.ts b/src/main/claude/claude-structured-message-options.test.ts index 7366be3bb1cd..2cc53747affb 100644 --- a/src/main/claude/claude-structured-message-options.test.ts +++ b/src/main/claude/claude-structured-message-options.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import { ClaudeControlRequestError } from './claude-stream-json-connection' import { acquired, claudeFrame, @@ -147,29 +146,4 @@ describe('Claude accepted message choices', () => { ]) expect(connection.sent).toHaveLength(2) }) - - it('rejects a refused model before any prompt is handed to Claude', async () => { - const claude = fakeClaude({ - routes: { - list_models: () => MODELS, - set_model: () => { - throw new ClaudeControlRequestError('set_model', 'model unavailable') - } - } - }) - const adapter = await acquired(claude) - await expect( - adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'refused', - body: USER_MESSAGE, - executionOptions: { model: 'opus', effort: 'low' }, - fence: 7 - }) - ).resolves.toMatchObject({ - state: 'rejected', - rejection: { kind: 'providerRejected' } - }) - expect(claude.connections[0]!.sent).toHaveLength(0) - }) }) diff --git a/src/main/codex/codex-structured-message-options.test.ts b/src/main/codex/codex-structured-message-options.test.ts index 3f35777c4848..6bef482395ea 100644 --- a/src/main/codex/codex-structured-message-options.test.ts +++ b/src/main/codex/codex-structured-message-options.test.ts @@ -1,13 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { agentSessionExecutionOptionsFromSnapshot } from '../../shared/agent-session-execution-option-snapshot' -import { - createStructuredAgentSessionOptionState, - structuredAgentSessionOptionSnapshot -} from '../../shared/structured-agent-session-options' -import { structuredAgentSessionSeedCatalog } from '../../shared/structured-agent-session-seed-catalog' import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' -import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' import { optionSession } from './codex-structured-option-session.test-fixture' import { resolvedCodexMessageOptions } from './codex-structured-message-options' import { startCodexTurn } from './codex-structured-turn-start' @@ -145,103 +137,6 @@ describe('Codex captured message choices', () => { }) }) - it('resets to the configured model after opening the thread with an explicit model', async () => { - const store = new AgentModelCatalogStore() - const access = agentModelCatalogSessionAccess(store, CODEX_STRUCTURED_AGENT, '/test-account') - if (!access) { - throw new Error('expected Codex catalog access') - } - store.recordSuccess( - access.fingerprint, - 'codex', - { - origin: 'probe', - models: [ - { - id: 'model-a', - label: 'Model A', - isDefault: false, - defaultEffort: 'high', - efforts: [{ value: 'high', label: 'High' }] - }, - { - id: 'model-b', - label: 'Model B', - isDefault: true, - defaultEffort: 'medium', - efforts: [{ value: 'medium', label: 'Medium' }] - } - ] - }, - 'discovery' - ) - const codex = fakeCodex({ - 'thread/start': () => ({ - thread: { id: 'thread-abc' }, - model: 'model-a', - reasoningEffort: 'high' - }), - 'model/list': () => ({ - data: [ - { - model: 'model-a', - supportedReasoningEfforts: [{ reasoningEffort: 'high' }], - defaultReasoningEffort: 'high' - }, - { - model: 'model-b', - supportedReasoningEfforts: [{ reasoningEffort: 'medium' }], - defaultReasoningEffort: 'medium' - } - ], - nextCursor: null - }), - 'config/read': () => ({ - config: { model: 'model-b', model_reasoning_effort: 'medium' } - }), - 'turn/start': () => ({ turn: { id: 'opened' } }) - }) - const adapter = adapterFor(codex, { model: 'model-a', codexHome: '/test-account' }, [], { - modelCatalog: store - }) - await acquireReadyCodexForTest(adapter, { - identity: identityFor('session-1'), - fence: 7, - spawnToken: 'spawn-1', - options: { model: 'model-a', effort: 'high' } - }) - expect( - codex.connections[0]?.calls.find((call) => call.method === 'thread/start')?.params - ).toMatchObject({ - model: 'model-a' - }) - - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'a', - body: USER_MESSAGE, - executionOptions: { model: 'model-a', effort: 'high' }, - fence: 7 - }) - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'b', - body: USER_MESSAGE, - executionOptions: {}, - fence: 7 - }) - expect( - codex.connections[0]?.calls - .filter((call) => call.method === 'turn/start') - .map((call) => call.params) - ).toMatchObject([ - { model: 'model-a', effort: 'high' }, - { model: 'model-b', effort: 'medium', personality: 'none', serviceTier: 'default' } - ]) - expect(codex.connections[0]?.calls.map((call) => call.method)).not.toContain('model/list') - expect(codex.connections[0]?.calls.map((call) => call.method)).not.toContain('config/read') - }) - it('opens from the first captured model even when launch resolution saw a newer saved pick', async () => { const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'opened' } }) }) const adapter = adapterFor(codex, { model: 'model-b' }) @@ -266,34 +161,6 @@ describe('Codex captured message choices', () => { ).toMatchObject({ model: 'model-a', effort: 'high' }) }) - it('uses native defaults through a held catalog read and allows the child to close', async () => { - const codex = fakeCodex({ - 'model/list': () => new Promise(() => {}), - 'turn/start': () => ({ turn: { id: 'opened' } }) - }) - const adapter = adapterFor(codex, { model: 'newer-saved-model' }) - await acquireReadyCodexForTest(adapter, { - identity: identityFor('session-1'), - fence: 7, - spawnToken: 'spawn-1', - options: {} - }) - await adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'default', - body: USER_MESSAGE, - executionOptions: {}, - fence: 7 - }) - expect( - codex.connections[0]?.calls.find((call) => call.method === 'thread/start')?.params - ).not.toHaveProperty('model') - expect( - codex.connections[0]?.calls.find((call) => call.method === 'turn/start')?.params - ).not.toHaveProperty('model') - expect(await adapter.closeSession('session-1')).toBe(true) - }) - it('preserves the old host path when no message snapshot was supplied', async () => { const request = vi.fn(async (_method: string, _params?: Record) => ({ turn: { id: 'legacy' } @@ -310,90 +177,6 @@ describe('Codex captured message choices', () => { expect(request.mock.calls[0]?.[1]).toMatchObject({ model: 'saved-next', effort: 'high' }) }) - it('uses a saved Codex choice when the picker has not loaded', async () => { - const request = vi.fn(async (_method: string, _params?: Record) => ({ - turn: { id: 'saved-turn' } - })) - const session = optionSession(request) - session.options.set('model', 'gpt-saved-unlisted') - session.options.set('effort', 'high') - session.reportedOptions = { model: 'gpt-saved-unlisted', effort: 'high' } - delete session.catalogAccess - delete session.nativeDefaults - const seed = structuredAgentSessionSeedCatalog('codex') - const snapshot = structuredAgentSessionOptionSnapshot( - createStructuredAgentSessionOptionState('codex', seed) - ) - const captured = agentSessionExecutionOptionsFromSnapshot(snapshot) - expect(captured).toBeUndefined() - const turnOptions = await resolvedCodexMessageOptions(session, captured) - await startCodexTurn(session, { - clientMessageId: 'saved', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }, - turnOptions - }) - expect(request.mock.calls[0]?.[1]).toMatchObject({ - model: 'gpt-saved-unlisted', - effort: 'high' - }) - }) - - it('sends a fresh default while model discovery is held', async () => { - const request = vi.fn(async (method: string, _params?: Record) => { - if (method === 'model/list') { - return new Promise(() => {}) - } - return { turn: { id: 'default-turn' } } - }) - const session = optionSession(request) - session.reportedOptions = { model: 'model-a', effort: 'high' } - session.nativeDefaults = { model: 'model-a', effort: 'high' } - const turnOptions = await resolvedCodexMessageOptions(session, {}) - await startCodexTurn(session, { - clientMessageId: 'default', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }, - turnOptions - }) - expect(request.mock.calls.map(([method]) => method)).toEqual(['turn/start']) - expect(request.mock.calls[0]?.[1]).toMatchObject({ - personality: 'none', - serviceTier: 'default' - }) - expect(request.mock.calls[0]?.[1]).not.toHaveProperty('model') - expect(request.mock.calls[0]?.[1]).not.toHaveProperty('effort') - }) - - it('restores opened native defaults after an explicit override without a catalog', async () => { - const request = vi.fn(async (_method: string, _params?: Record) => ({ - turn: { id: 'opened' } - })) - const session = optionSession(request) - session.reportedOptions = { model: 'native-model', effort: 'medium' } - session.nativeDefaults = { model: 'native-model', effort: 'medium' } - const body = { - kind: 'message' as const, - role: 'user' as const, - blocks: [{ type: 'text' as const, text: 'go' }] - } - await startCodexTurn(session, { - clientMessageId: 'override', - body, - turnOptions: await resolvedCodexMessageOptions(session, { - model: 'other-model', - effort: 'high' - }) - }) - await startCodexTurn(session, { - clientMessageId: 'reset', - body, - turnOptions: await resolvedCodexMessageOptions(session, {}) - }) - expect(request.mock.calls.map(([, params]) => params)).toMatchObject([ - { model: 'other-model', effort: 'high' }, - { model: 'native-model', effort: 'medium', personality: 'none', serviceTier: 'default' } - ]) - }) - it('refuses a default reset with no trustworthy default before prompt input', async () => { const request = vi.fn(async () => ({ turn: { id: 'unexpected' } })) const session = optionSession(request) @@ -436,26 +219,4 @@ describe('Codex captured message choices', () => { ).rejects.toThrow('connection lost after write') expect(Object.fromEntries(unknown.options)).toEqual({ model: 'attempted' }) }) - - it('refuses an unavailable message option before a protocol prompt', async () => { - const request = vi.fn(async (_method: string, _params?: Record) => ({ - turn: { id: 'turn-1' } - })) - const session = optionSession(request) - seedMessageDefaults(session) - await expect(resolvedCodexMessageOptions(session, { fastMode: 'true' })).rejects.toThrow( - 'does not support Fast mode' - ) - expect(request).not.toHaveBeenCalled() - }) - - it('refuses an unlisted captured model before prompt input', async () => { - const request = vi.fn(async () => ({ turn: { id: 'unexpected' } })) - const session = optionSession(request) - seedMessageDefaults(session) - await expect( - resolvedCodexMessageOptions(session, { model: 'unlisted', effort: 'high' }) - ).rejects.toThrow('does not offer model unlisted') - expect(request).not.toHaveBeenCalled() - }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts index 5669ea0ae03f..f6df02978f8f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts @@ -49,21 +49,6 @@ it('keeps the first accepted choice through opening and saves a later pick for t expect(rig.store.getRecord(SESSION)?.options?.model).toBe('model-b') }) -it('queues a different choice during a running Codex turn', async () => { - await pickModel('model-a') - const first = await rig.workingSend() - await pickModel('model-b') - const second = rig.send('second') - expect(await second.result).toMatchObject({ ok: true }) - expect(rig.dispatch).toHaveBeenCalledTimes(1) - await rig.settleAccepted(first, 'a') - await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) - expect(rig.dispatch.mock.calls.map(([input]) => input.executionOptions?.model)).toEqual([ - 'model-a', - 'model-b' - ]) -}) - it('rejects a refused choice before prompt input and leaves the next pick saved', async () => { await pickModel('model-b') rig.dispatch.mockRejectedValueOnce( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-cold-catalog.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-cold-catalog.test.ts deleted file mode 100644 index a295677b8038..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-cold-catalog.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { structuredAgentSessionSeedCatalog } from '../../../shared/structured-agent-session-seed-catalog' -import { - applyStructuredAgentSessionModelCatalog, - applyStructuredAgentSessionOptions, - createStructuredAgentSessionOptionState, - structuredAgentSessionOptionSnapshot -} from '../../../shared/structured-agent-session-options' -import { acpLaunchSpecFor } from '../../acp/acp-launch-specs' -import { acpStructuredAgentDefinition } from '../../acp/acp-structured-agent-definitions' -import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' -import { PI_RPC_AGENT } from '../../pi/rpc-agent-definition' -import { createAgentModelCatalogService } from '../agent-model-catalog/agent-model-catalog-service' -import { - AgentModelCatalogStore, - type AgentModelCatalogSuccess -} from '../agent-model-catalog/agent-model-catalog-store' -import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -import { readStructuredAgentSessionOptions } from './structured-agent-session-options-read' - -const DEFINITIONS = [ - CODEX_STRUCTURED_AGENT, - acpStructuredAgentDefinition(acpLaunchSpecFor('grok')!), - PI_RPC_AGENT, - CLAUDE_STRUCTURED_AGENT -] -const MODELS = [ - { - id: 'configured-model', - label: 'Configured model', - isDefault: true, - defaultEffort: 'high', - efforts: [ - { value: 'low', label: 'Low' }, - { value: 'high', label: 'High' } - ] - } -] - -function coldChat(definition: (typeof DEFINITIONS)[number], starting: boolean) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are all the record fields the options read and catalog fingerprint access. - const record = { - provider: definition.agent, - accountHome: { variable: 'TEST_AGENT_HOME', path: '/test/account' }, - location: { executionHostId: 'local', wslDistro: null }, - options: {} - } as unknown as AgentSessionRecord - const listing = Promise.withResolvers() - const probe = vi.fn(() => listing.promise) - const modelCatalog = createAgentModelCatalogService({ - store: new AgentModelCatalogStore(), - getRecord: () => record, - drivesRecord: () => true, - resolveAccountHome: async () => record.accountHome, - recordWorkspacePath: async () => '/test/folder', - workspaceMayOverrideDefaultModel: async () => false, - listingNamesConfiguredModel: new Set([definition.agent]), - probes: { [definition.agent]: probe } - }) - const resting = { - child: starting ? { phase: 'starting' } : null, - params: { provider: definition.agent }, - journal: { context: { floor: () => null } } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resting path accesses only these context members; no provider process runs. - const context = { - deps: { - adapter: {}, - agents: { definition: () => definition, capabilities: () => null }, - store: { getRecord: () => record }, - modelCatalog - }, - serialize: (_sessionId: string, task: () => Promise) => task(), - openConversation: async () => resting, - conversation: async () => resting - } as unknown as StructuredAgentSessionMutationContext - const read = (sessionId = 'first-chat') => - readStructuredAgentSessionOptions(context, sessionId, { readsWithoutModel: true }) - return { listing, probe, modelCatalog, read } -} - -describe.each(DEFINITIONS)('$agent first options read before its catalog loads', (definition) => { - it.each([false, true])('fills the picker in place with starting=%s', async (starting) => { - const chat = coldChat(definition, starting) - const agent = definition.agent - const seed = structuredAgentSessionSeedCatalog(agent) - let state = createStructuredAgentSessionOptionState(agent, seed) - const pending = await chat.modelCatalog.read({ agent, sessionId: 'first-chat' }) - expect(pending).toMatchObject({ origin: 'unknown', listingInProgress: true }) - const waiting = chat.modelCatalog.read({ agent, sessionId: 'first-chat', waitForListing: true }) - const options = await chat.read() - expect(options.current.model).toBeUndefined() - state = applyStructuredAgentSessionOptions(state, seed, options) - const beforeListing = structuredAgentSessionOptionSnapshot(state) - chat.listing.resolve({ origin: 'probe', models: MODELS }) - const loaded = await waiting - state = applyStructuredAgentSessionModelCatalog(state, seed, loaded, { newLaunch: true }) - expect(structuredAgentSessionOptionSnapshot(state)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: 'model', - kind: expect.objectContaining({ currentValue: 'configured-model' }) - }), - expect.objectContaining({ - id: 'effort', - kind: expect.objectContaining({ currentValue: 'high' }) - }) - ]) - ) - expect(beforeListing).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: 'model', settable: false, valueSource: 'unknown' }) - ]) - ) - expect(chat.probe).toHaveBeenCalledOnce() - - const secondOptions = await chat.read('second-chat') - const second = applyStructuredAgentSessionOptions( - createStructuredAgentSessionOptionState(agent, seed), - seed, - secondOptions - ) - expect(secondOptions.current).toMatchObject({ model: 'configured-model', effort: 'high' }) - expect(structuredAgentSessionOptionSnapshot(second)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: 'model', - kind: expect.objectContaining({ currentValue: 'configured-model' }) - }) - ]) - ) - expect(chat.probe).toHaveBeenCalledOnce() - chat.modelCatalog.stop() - }) -}) diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 44f4b1fb7764..75f7966f139d 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -168,119 +168,6 @@ describe('Pi RPC session ownership and delivery', () => { expect(h.connection.sent).toMatchObject([{ type: 'prompt', message: 'chosen' }]) }) - it('restores the opened model when a later snapshot omits its override', async () => { - const h = await setup() - await h.adapter.dispatch({ - sessionId, - fence: 7, - clientMessageId: 'override', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'first' }] }, - executionOptions: { model: 'anthropic/model-2' } - }) - await h.adapter.dispatch({ - sessionId, - fence: 7, - clientMessageId: 'default', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'second' }] }, - executionOptions: {} - }) - expect(h.connection.requests.filter((command) => command === 'set_model')).toEqual([ - 'set_model', - 'set_model' - ]) - expect(h.connection.sent).toMatchObject([ - { type: 'prompt', message: 'first' }, - { type: 'prompt', message: 'second' } - ]) - }) - - it('keeps legacy sends on the current selection when execution options are absent', async () => { - const h = await setup() - await h.adapter.dispatch({ - sessionId, - fence: 7, - clientMessageId: 'override', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'first' }] }, - executionOptions: { model: 'anthropic/model-2' } - }) - await h.adapter.dispatch({ - sessionId, - fence: 7, - clientMessageId: 'legacy', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'second' }] } - }) - expect(h.connection.requests.filter((command) => command === 'set_model')).toEqual([ - 'set_model' - ]) - expect(h.connection.sent).toHaveLength(2) - }) - - it('refuses an omitted effort whose new model default cannot be confirmed', async () => { - const h = await setup() - h.connection.requestOverride = (command) => - command === 'set_thinking_level' ? Promise.resolve({}) : undefined - await h.adapter.dispatch({ - sessionId, - fence: 7, - clientMessageId: 'override-effort', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'first' }] }, - executionOptions: { model: 'anthropic/model-1', effort: 'low' } - }) - await expect( - h.adapter.dispatch({ - sessionId, - fence: 7, - clientMessageId: 'unknown-default', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'second' }] }, - executionOptions: { model: 'anthropic/model-2' } - }) - ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) - expect(h.connection.sent).toHaveLength(1) - }) - - it('applies the displayed default model and effort after different message choices', async () => { - const h = await setup() - h.connection.requestOverride = (command) => - command === 'set_thinking_level' ? Promise.resolve({}) : undefined - for (const [clientMessageId, model, effort] of [ - ['override', 'anthropic/model-2', 'low'], - ['default', 'anthropic/model-1', 'high'] - ]) { - await h.adapter.dispatch({ - sessionId, - fence: 7, - clientMessageId, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, - executionOptions: { model, effort } - }) - } - expect(h.connection.requests.slice(-4)).toEqual([ - 'set_model', - 'set_thinking_level', - 'set_model', - 'set_thinking_level' - ]) - expect(h.connection.sent).toHaveLength(2) - }) - - it('leaves the prompt unsent when Pi refuses a sent model', async () => { - const h = await setup() - h.connection.requestOverride = (command) => - command === 'set_model' - ? Promise.reject(new JsonlRpcResponseError(command, 'Model unavailable')) - : undefined - await expect( - h.adapter.dispatch({ - sessionId, - fence: 7, - clientMessageId: 'refused-choice', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'chosen' }] }, - executionOptions: { model: 'anthropic/model-2' } - }) - ).rejects.toMatchObject({ name: 'AgentSessionOptionRejectedError' }) - expect(h.connection.sent).toEqual([]) - }) - it('rejects stale fences and a Stop for a different turn before sending abort', async () => { const h = await setup() await expect( diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.cold-catalog.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.cold-catalog.test.tsx deleted file mode 100644 index 168b574fb32f..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.cold-catalog.test.tsx +++ /dev/null @@ -1,109 +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 -})) - -import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' -import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' -import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' - -const LOCAL = { kind: 'local' } as const -const EMPTY_SELECTION = {} -const LAUNCH = { kind: 'new' as const, heldOptions: {}, worktree: 'folder:one' } -const MODELS = [ - { - id: 'configured-model', - label: 'Configured model', - isDefault: true, - defaultEffort: 'high', - efforts: [ - { value: 'low', label: 'Low' }, - { value: 'high', label: 'High' } - ] - } -] -const LOADED: AgentSessionModelCatalogResult = { - origin: 'probe', - models: MODELS, - fetchedAt: 1, - listingNamesConfiguredModel: true -} - -function renderChat(agent: string, selectionFromStatus: boolean, sessionId = 'first-chat') { - const frames: string[][] = [] - const view = renderHook(() => { - const options = useStructuredAgentSessionOptions({ - agent, - sessionId, - target: LOCAL, - transportEnabled: true, - isVisible: true, - providerVisible: true, - ...(selectionFromStatus ? { optionSelection: EMPTY_SELECTION } : {}), - fence: 1, - turnId: null, - unloadedTurnRevisions: undefined, - mutate: async () => null, - launch: LAUNCH - }) - frames.push(options.optionSnapshot.map((option) => option.id)) - return options - }) - return { ...view, frames } -} - -beforeEach(() => { - mocks.call.mockReset() - resetHostModelCatalogSnapshotsForTests() -}) - -it.each(['codex', 'grok', 'pi', 'claude'])( - '%s keeps its picker when options answer before the first listing, with either selection source', - async (agent) => { - for (const selectionFromStatus of [false, true]) { - resetHostModelCatalogSnapshotsForTests() - const listing = Promise.withResolvers() - mocks.call.mockImplementation( - async (_target: unknown, method: string, params: { waitForListing?: boolean }) => - method === 'agentSession.modelCatalog' - ? params.waitForListing - ? listing.promise - : { origin: 'unknown', listingInProgress: true } - : { models: [], modelsKnown: false, current: {}, conversationCommands: ['clear'] } - ) - const view = renderChat(agent, selectionFromStatus) - await waitFor(() => expect(view.result.current.conversationCommands).toEqual(['clear'])) - expect(view.frames.every((frame) => frame.includes('model'))).toBe(true) - await act(async () => listing.resolve(LOADED)) - await waitFor(() => { - const snapshot = view.result.current.optionSnapshot - expect(snapshot.find((option) => option.id === 'model')?.kind).toMatchObject({ - currentValue: 'configured-model' - }) - expect(snapshot.find((option) => option.id === 'effort')?.kind).toMatchObject({ - currentValue: 'high' - }) - }) - view.unmount() - - mocks.call.mockImplementation(async (_target: unknown, method: string) => - method === 'agentSession.modelCatalog' - ? LOADED - : { models: MODELS, current: { model: 'configured-model', effort: 'high' } } - ) - const second = renderChat(agent, selectionFromStatus, 'second-chat') - expect( - second.result.current.optionSnapshot.find((option) => option.id === 'model')?.kind - ).toMatchObject({ currentValue: 'configured-model' }) - await waitFor(() => expect(second.result.current.rewind).toBeDefined()) - expect(second.frames.every((frame) => frame.includes('model'))).toBe(true) - second.unmount() - } - } -) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx index d1f48321e4a3..2d1957bca36b 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx @@ -141,50 +141,6 @@ it('uses the existing options read when an older host omits the selection field' view.unmount() }) -it('retires an accepted pick on reply even when another host change arrived first', async () => { - let selection: Readonly> = { model: 'gpt-one' } - mocks.summary.mockImplementation(() => ({ optionSelection: selection })) - const write = deferred<{ options: Record }>() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deferred result is the setOption reply the hook reads. - const mutate = vi.fn(() => write.promise) as unknown as StructuredAgentSessionMutate - const launch = { kind: 'new' as const, heldOptions: {} } - const view = renderHook( - (sessionId: string) => - useStructuredAgentSessionOptions({ - agent: 'codex', - sessionId, - target: LOCAL, - transportEnabled: true, - isVisible: true, - providerVisible: true, - optionSelection: mocks.summary()?.optionSelection, - fence: 1, - turnId: null, - unloadedTurnRevisions: undefined, - mutate, - launch - }), - { initialProps: 'session-1' } - ) - await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) - let request!: Promise - act(() => { - request = view.result.current.setStructuredOption('model', 'gpt-two') - }) - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') - selection = { model: 'gpt-two' } - view.rerender('session-1') - selection = { model: 'gpt-one' } - view.rerender('session-1') - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') - await act(async () => { - write.resolve({ options: { model: 'gpt-two' } }) - await request - }) - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') - view.unmount() -}) - it('admits one pending pick, including after the picker re-renders', async () => { mocks.summary.mockImplementation(() => ({ optionSelection: { model: 'gpt-one' } })) const write = deferred<{ options: Record }>() @@ -223,135 +179,6 @@ it('admits one pending pick, including after the picker re-renders', async () => view.unmount() }) -it('drops refused and previous-chat picks without overriding current host selection', async () => { - let selection: Readonly> = { model: 'gpt-one' } - mocks.summary.mockImplementation(() => ({ optionSelection: selection })) - const refused = deferred() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: null is a refused mutation reply. - const mutate = vi.fn(() => refused.promise) as unknown as StructuredAgentSessionMutate - const launch = { kind: 'new' as const, heldOptions: {} } - const view = renderHook( - (sessionId: string) => - useStructuredAgentSessionOptions({ - agent: 'codex', - sessionId, - target: LOCAL, - transportEnabled: true, - isVisible: true, - providerVisible: true, - optionSelection: mocks.summary()?.optionSelection, - fence: 1, - turnId: null, - unloadedTurnRevisions: undefined, - mutate, - launch - }), - { initialProps: 'session-1' } - ) - await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) - let request!: Promise - act(() => { - request = view.result.current.setStructuredOption('model', 'gpt-two') - }) - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') - view.rerender('session-2') - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') - await act(async () => { - refused.resolve(null) - await request - }) - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') - view.rerender('session-1') - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') - selection = { model: 'gpt-two' } - view.rerender('session-2') - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') - view.unmount() -}) - -it('shows the value the host accepted when it normalizes a pick', async () => { - let selection: Readonly> = { model: 'gpt-one' } - mocks.summary.mockImplementation(() => ({ optionSelection: selection })) - const write = deferred<{ options: Record }>() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reply is the setOption result this hook reads. - const mutate = vi.fn(() => write.promise) as unknown as StructuredAgentSessionMutate - const view = renderHook(() => - useStructuredAgentSessionOptions({ - agent: 'codex', - sessionId: 'session-1', - target: LOCAL, - transportEnabled: true, - isVisible: true, - providerVisible: true, - optionSelection: mocks.summary()?.optionSelection, - fence: 1, - turnId: null, - unloadedTurnRevisions: undefined, - mutate, - launch: { kind: 'new', heldOptions: {} } - }) - ) - await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) - let request!: Promise<{ snapshot: readonly SessionOptionDescriptor[] }> - act(() => { - request = view.result.current.optionSurface.setOption('model', 'gpt-two') - }) - selection = { model: 'gpt-one' } - view.rerender() - let returned!: { snapshot: readonly SessionOptionDescriptor[] } - await act(async () => { - write.resolve({ options: { model: 'gpt-one' } }) - returned = await request - }) - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') - expect(pill(returned.snapshot).model).toBe('gpt-one') - expect(mocks.settings).toHaveBeenCalledWith(LOCAL, { - type: 'apply-picks', - agent: 'codex', - picks: [{ modelId: 'gpt-one', optionId: 'model', value: 'gpt-one' }] - }) - view.unmount() -}) - -it('binds an effort-only pick and its returned snapshot to the host-selected model', async () => { - mocks.summary.mockImplementation(() => ({ optionSelection: { model: 'gpt-two', effort: 'low' } })) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reply is the setOption result this hook reads. - const mutate = vi.fn(async () => ({ - options: { effort: 'high' } - })) as unknown as StructuredAgentSessionMutate - const launch = { kind: 'new' as const, heldOptions: {} } - const view = renderHook(() => - useStructuredAgentSessionOptions({ - agent: 'codex', - sessionId: 'session-1', - target: LOCAL, - transportEnabled: true, - isVisible: true, - providerVisible: true, - optionSelection: mocks.summary()?.optionSelection, - fence: 1, - turnId: null, - unloadedTurnRevisions: undefined, - mutate, - launch - }) - ) - await waitFor(() => - expect(pill(view.result.current.optionSnapshot)).toEqual({ model: 'gpt-two', effort: 'low' }) - ) - let returned: { snapshot: readonly SessionOptionDescriptor[] } | undefined - await act(async () => { - returned = await view.result.current.optionSurface.setOption('effort', 'high') - }) - expect(pill(returned?.snapshot ?? [])).toEqual({ model: 'gpt-two', effort: 'high' }) - expect(mocks.settings).toHaveBeenCalledWith(LOCAL, { - type: 'apply-picks', - agent: 'codex', - picks: [{ modelId: 'gpt-two', optionId: 'effort', value: 'high' }] - }) - view.unmount() -}) - it('fences a late pick when the same session moves to another host', async () => { mocks.summary.mockImplementation(() => ({ optionSelection: { model: 'gpt-one' } })) const oldWrite = deferred<{ options: Record }>() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.no-catalog-effort.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.no-catalog-effort.test.tsx deleted file mode 100644 index d2cefeab9804..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.no-catalog-effort.test.tsx +++ /dev/null @@ -1,104 +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 -})) - -import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' -import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' -import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' -import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' - -const LOCAL = { kind: 'local' } as const -const LAUNCH = { kind: 'new' as const, heldOptions: {}, worktree: 'folder:one' } - -function selection(snapshot: readonly SessionOptionDescriptor[]) { - const value = (id: string) => { - const option = snapshot.find((entry) => entry.id === id) - return option?.kind.type === 'select' ? option.kind.currentValue : undefined - } - return { model: value('model'), effort: value('effort') } -} - -beforeEach(() => { - mocks.call.mockReset() - resetHostModelCatalogSnapshotsForTests() -}) - -it.each([ - ['claude', 'opus'], - ['codex', 'gpt-5.5'] -])('%s keeps a saved effort through start before its catalog loads', async (agent, model) => { - for (const selectionFromStatus of [false, true]) { - resetHostModelCatalogSnapshotsForTests() - const saved = { model, effort: 'high' } - const listing = Promise.withResolvers() - mocks.call.mockImplementation(async (_target: unknown, method: string) => - method === 'agentSession.modelCatalog' - ? listing.promise - : { models: [], modelsKnown: false, current: saved, conversationCommands: ['clear'] } - ) - const frames: ReturnType[] = [] - const view = renderHook( - ({ fence, starting }: { fence: number; starting: boolean }) => { - const options = useStructuredAgentSessionOptions({ - agent, - sessionId: 'saved-chat', - target: LOCAL, - transportEnabled: true, - isVisible: true, - providerVisible: true, - providerStarting: starting, - ...(selectionFromStatus ? { optionSelection: saved } : {}), - fence, - turnId: null, - unloadedTurnRevisions: undefined, - mutate: async () => null, - launch: LAUNCH - }) - frames.push(selection(options.optionSnapshot)) - return options - }, - { initialProps: { fence: 1, starting: false } } - ) - await waitFor(() => expect(view.result.current.conversationCommands).toEqual(['clear'])) - expect(selection(view.result.current.optionSnapshot)).toEqual(saved) - const beforeStart = frames.length - view.rerender({ fence: 2, starting: true }) - expect(selection(view.result.current.optionSnapshot)).toEqual(saved) - view.rerender({ fence: 2, starting: false }) - await waitFor(() => expect(view.result.current.rewind).toBeDefined()) - expect( - frames.slice(beforeStart).every((frame) => frame.model === model && frame.effort === 'high') - ).toBe(true) - await act(async () => - listing.resolve({ - origin: 'probe', - models: [ - { - id: model, - label: 'Host model', - isDefault: false, - efforts: [ - { value: 'low', label: 'Low' }, - { value: 'high', label: 'High' } - ] - } - ], - fetchedAt: 1 - }) - ) - await waitFor(() => - expect( - view.result.current.optionSnapshot.find((option) => option.id === 'model')?.kind - ).toMatchObject({ choices: [{ value: model, label: 'Host model' }] }) - ) - expect(selection(view.result.current.optionSnapshot)).toEqual(saved) - view.unmount() - } -}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.pending-restart.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.pending-restart.test.tsx deleted file mode 100644 index 31f5c7cc90e9..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.pending-restart.test.tsx +++ /dev/null @@ -1,98 +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(), settings: 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: mocks.settings -})) - -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' - -const LOCAL = { kind: 'local' } as const -const LAUNCH = { kind: 'new' as const, heldOptions: {} } -const SELECTION = { model: 'gpt-one' } -const MODELS = ['gpt-one', 'gpt-two'].map((id) => ({ id, label: id, efforts: [] })) - -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((settle) => (resolve = settle)) - return { promise, resolve } -} - -beforeEach(() => { - resetHostModelCatalogSnapshotsForTests() - mocks.settings.mockReset() - mocks.call.mockImplementation(async (_target: unknown, method: string) => - method === 'agentSession.modelCatalog' - ? { origin: 'live-session', models: MODELS, fetchedAt: 1 } - : { models: MODELS, current: {} } - ) -}) - -it.each([false, true])( - 'admits a later pick after restart when the earlier reply %s arrives', - async (settlesLate) => { - const abandoned = deferred<{ options: Record }>() - const latest = deferred() - const send = vi.fn().mockReturnValueOnce(abandoned.promise).mockReturnValueOnce(latest.promise) - const mutate: StructuredAgentSessionMutate = send - const initialFence: number | null = 1 - const view = renderHook, number | null>( - (fence: number | null) => - useStructuredAgentSessionOptions({ - agent: 'codex', - sessionId: 'session-1', - target: LOCAL, - transportEnabled: true, - isVisible: true, - providerVisible: true, - optionSelection: SELECTION, - fence, - turnId: null, - unloadedTurnRevisions: undefined, - mutate, - launch: LAUNCH - }), - { initialProps: initialFence } - ) - await waitFor(() => expect(view.result.current.rewind).toBeDefined()) - let first!: Promise - act(() => { - first = view.result.current.setStructuredOption('model', 'gpt-two') - }) - expect(send).toHaveBeenCalledOnce() - view.rerender(null) - view.rerender(2) - let second!: Promise - act(() => { - second = view.result.current.setStructuredOption('model', 'gpt-two') - }) - expect(send).toHaveBeenCalledTimes(2) - if (settlesLate) { - await act(async () => { - abandoned.resolve({ options: { model: 'gpt-two' } }) - expect(await first).toBe(false) - }) - expect(mocks.settings).not.toHaveBeenCalled() - await act(async () => { - expect(await view.result.current.setStructuredOption('model', 'gpt-one')).toBe(false) - }) - expect(send).toHaveBeenCalledTimes(2) - } - await act(async () => { - latest.resolve(null) - expect(await second).toBe(false) - }) - const model = view.result.current.optionSnapshot.find((option) => option.id === 'model') - expect(model?.kind.type === 'select' ? model.kind.currentValue : null).toBe('gpt-one') - view.unmount() - } -) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.scoped-default.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.scoped-default.test.tsx deleted file mode 100644 index bf6aa9e11597..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.scoped-default.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 -})) - -import type { - AgentSessionModelCatalogResult, - AgentSessionOptionsResult -} from '../../../../shared/agent-session-wire' -import { - forgetHostModelCatalogSnapshots, - recordHostModelCatalogSnapshot, - resetHostModelCatalogSnapshotsForTests -} from '@/runtime/host-model-catalog-snapshots' -import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' -import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' - -const LOCAL = { kind: 'local' } as const -const MODELS = ['gpt-one', 'gpt-two'].map((id) => ({ - id, - label: id, - isDefault: false, - efforts: [ - { value: 'medium', label: 'Medium' }, - { value: 'high', label: 'High' } - ] -})) -const LAUNCH = { kind: 'new' as const, heldOptions: {}, worktree: 'folder:one' } -const EMPTY_SELECTION = {} -const NO_MUTATE: StructuredAgentSessionMutate = async () => null - -function catalog(defaultModel = 'gpt-one'): AgentSessionModelCatalogResult { - return { - origin: 'live-session', - models: MODELS.map((model) => ({ - ...model, - isDefault: model.id === defaultModel, - ...(model.id === defaultModel ? { defaultEffort: 'medium' } : {}) - })), - fetchedAt: 1, - listingNamesConfiguredModel: true - } -} - -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((settle) => (resolve = settle)) - return { promise, resolve } -} - -function renderChat() { - return renderHook( - (fence: number) => - useStructuredAgentSessionOptions({ - agent: 'codex', - sessionId: 'session-1', - target: LOCAL, - transportEnabled: true, - isVisible: true, - providerVisible: true, - optionSelection: EMPTY_SELECTION, - fence, - turnId: null, - unloadedTurnRevisions: undefined, - mutate: NO_MUTATE, - launch: LAUNCH - }), - { initialProps: 1 } - ) -} - -function selected(view: ReturnType) { - return Object.fromEntries( - view.result.current.optionSnapshot.map((option) => [ - option.id, - option.kind.type === 'select' ? (option.kind.currentValue ?? null) : null - ]) - ) -} - -beforeEach(() => { - mocks.call.mockReset() - resetHostModelCatalogSnapshotsForTests() -}) - -it.each(['catalog-first', 'options-first'] as const)( - 'uses the scoped default with an empty host selection after %s replies', - async (order) => { - const listing = deferred() - const options = deferred() - const metadata: AgentSessionOptionsResult = { - models: MODELS, - current: { model: 'gpt-two', effort: 'high' }, - conversationCommands: ['clear'] - } - mocks.call.mockImplementation((_target: unknown, method: string) => - method === 'agentSession.modelCatalog' ? listing.promise : options.promise - ) - const view = renderChat() - await act(async () => { - if (order === 'catalog-first') { - listing.resolve(catalog()) - } else { - options.resolve(metadata) - } - }) - await act(async () => { - listing.resolve(catalog()) - options.resolve(metadata) - }) - await waitFor(() => expect(view.result.current.conversationCommands).toHaveLength(1)) - expect(selected(view)).toEqual({ model: 'gpt-one', effort: 'medium' }) - view.unmount() - } -) - -it.each(['folder config', 'account'] as const)( - 'refreshes an empty selection default after the %s changes', - async (change) => { - recordHostModelCatalogSnapshot(LOCAL, 'codex', LAUNCH.worktree, catalog()) - let defaultModel = 'gpt-one' - mocks.call.mockImplementation(async (_target: unknown, method: string) => - method === 'agentSession.modelCatalog' - ? catalog(defaultModel) - : { models: MODELS, current: {} } - ) - const view = renderChat() - await waitFor(() => expect(view.result.current.rewind).toBeDefined()) - expect(selected(view)).toEqual({ model: 'gpt-one', effort: 'medium' }) - defaultModel = 'gpt-two' - if (change === 'account') { - forgetHostModelCatalogSnapshots(LOCAL, ['codex']) - } - recordHostModelCatalogSnapshot(LOCAL, 'codex', LAUNCH.worktree, catalog(defaultModel)) - view.rerender(1) - expect(selected(view)).toEqual({ model: 'gpt-two', effort: 'medium' }) - view.rerender(2) - await waitFor(() => expect(view.result.current.rewind).toBeDefined()) - expect(selected(view)).toEqual({ model: 'gpt-two', effort: 'medium' }) - view.unmount() - } -) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.test.tsx index 0b4b18dbf371..4083682b3387 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, renderHook } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ call: vi.fn() })) @@ -11,11 +11,6 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { resetStructuredAgentSessionSendsForTests } from './structured-agent-session-message-sender' import { useStructuredAgentSessionSends } from './use-structured-agent-session-sends' -import { - createStructuredAgentSessionOptionState, - structuredAgentSessionOptionSnapshot -} from '../../../../shared/structured-agent-session-options' -import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' const QUEUEING = { capability: 'supported', enabled: true } as const @@ -24,63 +19,6 @@ afterEach(() => { mocks.call.mockReset() }) -it('omits choices when Send precedes the picker’s model answer', async () => { - mocks.call.mockImplementation(() => new Promise(() => {})) - const snapshot = structuredAgentSessionOptionSnapshot( - createStructuredAgentSessionOptionState('codex', structuredAgentSessionSeedCatalog('codex')) - ) - const { result } = renderHook(() => - useStructuredAgentSessionSends({ - sessionId: 'session-1', - target: { kind: 'local' }, - fence: 1, - submissions: [], - queuedMessageIds: [], - queue: QUEUEING, - historyLoaded: false, - executionOptionSnapshot: snapshot - }) - ) - act(() => { - result.current.send('before picker answer') - }) - await waitFor(() => - expect(mocks.call.mock.calls.some(([, method]) => method === 'agentSession.send')).toBe(true) - ) - const call = mocks.call.mock.calls.find(([, method]) => method === 'agentSession.send') - expect(call?.[2]).not.toHaveProperty('executionOptions') -}) - -// The composer moves the reader only for a message the transcript draws as a bubble. -it.each([ - ['a text follow-up while the agent works', QUEUEING, true, [], 'queued'], - ['the same follow-up when the agent is idle', QUEUEING, false, [], true], - ['with the queue setting off', { capability: 'supported', enabled: false }, true, [], true], - ['an image, which never queues', QUEUEING, true, [{ path: '/a.png', previewUri: '/a.png' }], true] -] as const)( - 'answers whether %s waits as a queued card', - (_case, queue, isWorking, images, admission) => { - mocks.call.mockImplementation(() => new Promise(() => {})) - const { result } = renderHook(() => - useStructuredAgentSessionSends({ - sessionId: 'session-1', - target: { kind: 'local' }, - fence: 1, - submissions: [], - queuedMessageIds: [], - queue, - historyLoaded: false, - isWorking - }) - ) - let sent: boolean | 'queued' = false - act(() => { - sent = result.current.send('follow up', images) - }) - expect(sent).toBe(admission) - } -) - it('takes no second send while the first is out', () => { mocks.call.mockImplementation(() => new Promise(() => {})) const { result } = renderHook(() => diff --git a/src/shared/structured-agent-session-model-catalog-application.test.ts b/src/shared/structured-agent-session-model-catalog-application.test.ts deleted file mode 100644 index ac395c67752b..000000000000 --- a/src/shared/structured-agent-session-model-catalog-application.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getAgentSessionOptionCatalog } from './agent-session-option-catalog' -import type { AgentSessionModelCatalogResult } from './agent-session-wire' -import { - applyStructuredAgentSessionModelCatalog, - applyStructuredAgentSessionOptions, - createStructuredAgentSessionOptionState, - settleStructuredAgentSessionBuiltinCatalog, - structuredAgentSessionOptionSnapshot -} from './structured-agent-session-options' - -const SEED = getAgentSessionOptionCatalog('codex')! - -const HOST_CATALOG: AgentSessionModelCatalogResult = { - origin: 'live-session', - models: [ - { - id: 'gpt-hosted', - label: 'GPT Hosted', - isDefault: true, - defaultEffort: 'high', - efforts: [ - { value: 'medium', label: 'Medium' }, - { value: 'high', label: 'High' } - ] - } - ], - fetchedAt: 1_000 -} - -// HOST_CATALOG omits `listingNamesConfiguredModel`, as an older host's does. -const LAUNCH = { newLaunch: true } - -describe('structured option state from the host model catalog', () => { - it('renders the quiet placeholder before the host answers, never a built-in label', () => { - const state = createStructuredAgentSessionOptionState('codex', SEED) - expect(structuredAgentSessionOptionSnapshot(state)).toEqual([ - expect.objectContaining({ - id: 'model', - valueSource: 'unknown', - settable: false, - kind: { type: 'select', choices: [] } - }) - ]) - }) - - it('makes the built-in list pickable, naming nothing, once the host says it has none', () => { - const state = settleStructuredAgentSessionBuiltinCatalog( - createStructuredAgentSessionOptionState('codex', SEED) - ) - expect(state.catalogSource).toBe('builtin') - const model = structuredAgentSessionOptionSnapshot(state).find((d) => d.id === 'model')! - expect(model.kind.type === 'select' && model.kind.choices.length).toBeGreaterThan(0) - expect(model.settable).toBe(true) - expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBeUndefined() - }) - - it('upgrades the seed with host models as a provisional, uncommitted default', () => { - const state = applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState('codex', SEED), - SEED, - HOST_CATALOG, - LAUNCH - ) - expect(state.catalogSource).toBe('host') - const snapshot = structuredAgentSessionOptionSnapshot(state) - const model = snapshot.find((descriptor) => descriptor.id === 'model')! - expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBe('gpt-hosted') - // Provisional provenance: a listing default, never a reported value. - expect(model.valueSource).toBe('default') - }) - - it('lists host models but names no value for a session it did not launch', () => { - // A reopened session may run a model picked in it, not the listing's default. - const state = applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState('codex', SEED), - SEED, - HOST_CATALOG, - { newLaunch: false } - ) - const snapshot = structuredAgentSessionOptionSnapshot(state) - const model = snapshot.find((descriptor) => descriptor.id === 'model')! - expect(model.kind.type === 'select' && model.kind.choices.map((c) => c.value)).toEqual([ - 'gpt-hosted' - ]) - expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBeUndefined() - const effort = snapshot.find((descriptor) => descriptor.id === 'effort') - expect(effort?.kind.type === 'select' ? effort.kind.currentValue : undefined).toBeUndefined() - }) - - it('follows the host on whether its listed default is what a new chat runs', () => { - const currentModel = (listingNamesConfiguredModel: boolean): unknown => { - const state = applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState('codex', SEED), - SEED, - { ...HOST_CATALOG, listingNamesConfiguredModel }, - LAUNCH - ) - const model = structuredAgentSessionOptionSnapshot(state).find((d) => d.id === 'model')! - return model.kind.type === 'select' ? model.kind.currentValue : null - } - expect(currentModel(true)).toBe('gpt-hosted') - // A workspace whose own config may pick another model: the host says so, over the seed. - expect(currentModel(false)).toBeUndefined() - }) - - it('names the default effort the listing states, and none it does not', () => { - const effortOf = (catalog: AgentSessionModelCatalogResult) => { - const state = applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState('codex', SEED), - SEED, - catalog, - LAUNCH - ) - return structuredAgentSessionOptionSnapshot(state).find((d) => d.id === 'effort')! - } - const stated = effortOf(HOST_CATALOG) - expect(stated.kind.type === 'select' ? stated.kind.currentValue : null).toBe('high') - expect(stated.valueSource).toBe('default') - const { defaultEffort: _stated, ...unstated } = HOST_CATALOG.models[0]! - const silent = effortOf({ ...HOST_CATALOG, models: [unstated] }) - expect(silent.kind.type === 'select' ? silent.kind.currentValue : null).toBeUndefined() - expect(silent.valueSource).toBe('unknown') - }) - - it('settles on the built-in list on an unknown or empty host answer, and keeps a host list', () => { - const seeded = createStructuredAgentSessionOptionState('codex', SEED) - for (const answer of [ - { origin: 'unknown' as const }, - { origin: 'probe' as const, models: [], fetchedAt: 1 } - ]) { - const settled = applyStructuredAgentSessionModelCatalog(seeded, SEED, answer, LAUNCH) - expect(settled.catalogSource).toBe('builtin') - expect(settled.catalog).toBe(seeded.catalog) - const hosted = applyStructuredAgentSessionModelCatalog(seeded, SEED, HOST_CATALOG, LAUNCH) - expect(applyStructuredAgentSessionModelCatalog(hosted, SEED, answer, LAUNCH)).toBe(hosted) - } - }) - - it('never downgrades a live catalog to a host one', () => { - const live = applyStructuredAgentSessionOptions( - createStructuredAgentSessionOptionState('codex', SEED), - SEED, - { - models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], - current: { model: 'gpt-live', confirmed: ['model'] } - } - ) - expect(live.catalogSource).toBe('live') - expect(applyStructuredAgentSessionModelCatalog(live, SEED, HOST_CATALOG, LAUNCH)).toBe(live) - const snapshot = structuredAgentSessionOptionSnapshot(live) - const model = snapshot.find((descriptor) => descriptor.id === 'model')! - expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBe('gpt-live') - }) - - it('keeps a known catalog and default when an options read has no loaded catalog', () => { - const hosted = applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState('codex', SEED), - SEED, - HOST_CATALOG, - LAUNCH - ) - const unknown = applyStructuredAgentSessionOptions(hosted, SEED, { - models: [], - modelsKnown: false, - current: {} - }) - expect(unknown.catalog).toBe(hosted.catalog) - expect(unknown.catalogSource).toBe('host') - expect(structuredAgentSessionOptionSnapshot(unknown)).toEqual( - structuredAgentSessionOptionSnapshot(hosted) - ) - const picked = applyStructuredAgentSessionOptions(unknown, SEED, { - models: [], - modelsKnown: false, - current: { model: 'gpt-hosted', effort: 'medium' } - }) - expect(picked.catalog).toBe(hosted.catalog) - const effort = structuredAgentSessionOptionSnapshot(picked).find( - (option) => option.id === 'effort' - ) - expect(effort?.kind).toMatchObject({ currentValue: 'medium' }) - }) - - it.each([ - ['claude', 'opus'], - ['codex', 'gpt-5.5'] - ])( - '%s shows a saved effort before its catalog loads and accepts the later list', - (agent, model) => { - const seed = getAgentSessionOptionCatalog(agent)! - const state = applyStructuredAgentSessionOptions( - createStructuredAgentSessionOptionState(agent, seed), - seed, - { models: [], modelsKnown: false, current: { model, effort: 'high' } } - ) - expect(state.catalog).toBe(seed) - expect(state.catalogSource).toBe('seed') - expect(structuredAgentSessionOptionSnapshot(state)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: 'model', - kind: expect.objectContaining({ currentValue: model }) - }), - expect.objectContaining({ - id: 'effort', - kind: expect.objectContaining({ currentValue: 'high' }) - }) - ]) - ) - const loaded = applyStructuredAgentSessionModelCatalog( - state, - seed, - { ...HOST_CATALOG, models: [{ ...HOST_CATALOG.models[0]!, id: model }] }, - LAUNCH - ) - expect(loaded.catalogSource).toBe('host') - expect( - structuredAgentSessionOptionSnapshot(loaded).find((option) => option.id === 'effort')?.kind - ).toMatchObject({ currentValue: 'high' }) - expect(loaded.catalog?.models.map((row) => row.label)).toEqual(['GPT Hosted']) - } - ) - - it('keeps an unrecognized saved model quiet until the host lists it', () => { - const state = applyStructuredAgentSessionOptions( - createStructuredAgentSessionOptionState('codex', SEED), - SEED, - { models: [], modelsKnown: false, current: { model: 'gpt-hosted', effort: 'high' } } - ) - expect(structuredAgentSessionOptionSnapshot(state)).toEqual([ - expect.objectContaining({ id: 'model', settable: false, valueSource: 'unknown' }) - ]) - const loaded = applyStructuredAgentSessionModelCatalog(state, SEED, HOST_CATALOG, LAUNCH) - expect(structuredAgentSessionOptionSnapshot(loaded)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: 'model', - kind: expect.objectContaining({ currentValue: 'gpt-hosted' }) - }), - expect.objectContaining({ - id: 'effort', - kind: expect.objectContaining({ currentValue: 'high' }) - }) - ]) - ) - }) -}) 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 087/127] 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 088/127] 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 fe7ed3ab561e41148aef456d89740c4f8a9f69ad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:59:24 -0700 Subject: [PATCH 089/127] Match startup picker fixture to saved message choices --- ...de-structured-startup-unanswered-control-request.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 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 bb958dfa745c..99cbb35f09be 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 @@ -171,14 +171,14 @@ describe('a Claude start whose CLI answers initialize but not a control request' 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'] }) + expect(await picker(host)).toEqual({ model: 'sonnet', effort: undefined, confirmed: undefined }) // 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: 'sonnet', - effort: 'high', - confirmed: ['effort'] + effort: undefined, + confirmed: undefined }) expect(record(host)?.options).toEqual({ model: 'sonnet' }) 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 090/127] 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 091/127] 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 092/127] 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 093/127] 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 094/127] 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) From 2979f938aec196a8af82990ead6c49df8f65160e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:44:33 -0700 Subject: [PATCH 095/127] Check startup effort after saved picker choices are acknowledged --- ...-structured-agent-session-options.test.tsx | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) 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 f86b60f488c2..2054bcae76f2 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 @@ -498,27 +498,36 @@ describe('useStructuredAgentSessionOptions', () => { describe('before the provider starts', () => { it('keeps a picked effort visible after a model pick during startup', async () => { + let reads = 0 answer({ - options: async () => ({ - models: ['opus', 'sonnet'].map((id) => ({ - id, - label: id, - efforts: ['low', 'high'].map((value) => ({ value, label: value })) - })), - current: { model: 'opus' } - }) + options: async () => { + if (reads++ > 0) { + return new Promise(() => {}) + } + return { + models: ['opus', 'sonnet'].map((id) => ({ + id, + label: id, + efforts: ['low', 'high'].map((value) => ({ value, label: value })) + })), + current: { model: 'opus' } + } + } }) + const { mutate, calls } = mutateWith(async () => null) + calls.mockResolvedValueOnce({ key: 'effort', value: 'low' }) + calls.mockResolvedValueOnce({ key: 'model', value: 'sonnet' }) const { result, unmount } = renderOptions( { ...REOPENED, agent: 'claude', providerStarting: true }, - mutateWith(async () => new Promise(() => {})).mutate + mutate ) await waitFor(() => expect(currentValue(result.current.optionSnapshot, 'model')).toBe('opus')) - act(() => { - void result.current.setStructuredOption('effort', 'low') + await act(async () => { + await result.current.setStructuredOption('effort', 'low') }) expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('low') - act(() => { - void result.current.setStructuredOption('model', 'sonnet') + await act(async () => { + await result.current.setStructuredOption('model', 'sonnet') }) expect(currentValue(result.current.optionSnapshot, 'model')).toBe('sonnet') expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('low') From 1b80d3f0a61aadb8deb18dabb282743845607181 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:48:19 -0700 Subject: [PATCH 096/127] Complete Stop target types and keep the effort regression concise --- .../structured-agent-session-mutation-plans.ts | 2 ++ .../native-chat/use-structured-agent-session-options.test.tsx | 3 --- 2 files changed, 2 insertions(+), 3 deletions(-) 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 a26191b2521b..d859cd6fd015 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 @@ -5,6 +5,8 @@ // prompt answer's replay answers from the resolution its receipt keeps; the journal projects the // others' current answer. +import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { 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 2054bcae76f2..30e85b0c5656 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 @@ -524,9 +524,6 @@ describe('useStructuredAgentSessionOptions', () => { await waitFor(() => expect(currentValue(result.current.optionSnapshot, 'model')).toBe('opus')) await act(async () => { await result.current.setStructuredOption('effort', 'low') - }) - expect(currentValue(result.current.optionSnapshot, 'effort')).toBe('low') - await act(async () => { await result.current.setStructuredOption('model', 'sonnet') }) expect(currentValue(result.current.optionSnapshot, 'model')).toBe('sonnet') From 24dcf692e5dbe33a067faa259655b734cc3ae43d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:52:10 -0700 Subject: [PATCH 097/127] Use requested picker values in the effort regression acknowledgement --- .../native-chat/use-structured-agent-session-options.test.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 30e85b0c5656..6f2447a30e1a 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 @@ -514,9 +514,7 @@ describe('useStructuredAgentSessionOptions', () => { } } }) - const { mutate, calls } = mutateWith(async () => null) - calls.mockResolvedValueOnce({ key: 'effort', value: 'low' }) - calls.mockResolvedValueOnce({ key: 'model', value: 'sonnet' }) + const { mutate } = mutateWith(async (_description, _method, fields) => fields) const { result, unmount } = renderOptions( { ...REOPENED, agent: 'claude', providerStarting: true }, mutate From 61efbbe166de0dbb09188bd82c4a3247c7a63119 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:54:20 -0700 Subject: [PATCH 098/127] Require owned provider spawn and one host-awaited open (from #28055) --- config/reliability-gates.jsonc | 14 +- .../scripts/vitest-sqlite-runtime-files.mjs | 2 - src/main/acp/acp-structured-acquire.ts | 4 +- .../acp-structured-adapter.test-support.ts | 26 +- .../acp/acp-structured-missing-model.test.ts | 8 +- .../acp-structured-publish-at-spawn.test.ts | 4 +- ...ructured-session-adapter-lifecycle.test.ts | 7 +- .../claude-api-retry-idle-sweep.test.ts | 3 + ...-structured-effort-default-at-rest.test.ts | 4 +- .../claude-structured-session-acquisition.ts | 2 +- .../claude-structured-session-startup.test.ts | 4 +- .../claude/claude-structured-session-state.ts | 2 +- .../codex-provider-retry-idle-sweep.test.ts | 3 + .../codex-structured-launch-resolution.ts | 2 +- .../codex-structured-question-order.test.ts | 267 ----- .../codex-structured-service-tier.test.ts | 14 - .../codex/codex-structured-session-acquire.ts | 2 +- ...odex-structured-session-adapter-fixture.ts | 2 +- .../codex/codex-structured-session-adapter.ts | 12 +- .../codex/codex-structured-session-options.ts | 25 - ...-agent-session-accept-then-deliver.test.ts | 38 +- ...structured-agent-session-acquire-aborts.ts | 4 +- ...-agent-session-acquisition-options.test.ts | 657 ------------- ...-session-acquisition-runtime-stamp.test.ts | 127 --- .../structured-agent-session-acquisition.ts | 35 +- ...nt-session-adapter-router-registry.test.ts | 213 ---- ...tured-agent-session-adapter-router.test.ts | 24 - ...structured-agent-session-adapter-router.ts | 23 +- .../structured-agent-session-adapter.test.ts | 83 -- .../structured-agent-session-adapter.ts | 68 +- ...tured-agent-session-adopted-import.test.ts | 5 + ...ured-agent-session-append-delivery.test.ts | 2 +- ...red-agent-session-at-rest-commands.test.ts | 325 ------- .../structured-agent-session-attach-flow.ts | 17 +- ...ured-agent-session-attach-orchestration.ts | 10 +- ...red-agent-session-claude-root-exit.test.ts | 2 +- ...d-agent-session-conversation-close.test.ts | 264 ----- ...tured-agent-session-crash-turn-end.test.ts | 14 + .../structured-agent-session-delivery-loop.ts | 4 +- ...ructured-agent-session-drivability.test.ts | 348 ------- ...structured-agent-session-event-recovery.ts | 8 +- ...ession-failed-create-owner-verdict.test.ts | 4 + ...session-failed-create-sink-release.test.ts | 147 --- ...tured-agent-session-grouped-prompt.test.ts | 283 ------ ...d-agent-session-held-start.test-support.ts | 58 +- ...nt-session-host-held-child-renewal.test.ts | 116 --- ...structured-agent-session-host-mutations.ts | 4 +- ...ed-agent-session-host-test-abandon.test.ts | 126 --- ...uctured-agent-session-host-test-harness.ts | 4 + .../structured-agent-session-host-types.ts | 2 +- ...gent-session-instant-start.test-support.ts | 18 +- ...ured-agent-session-late-settlement.test.ts | 5 +- ...t-session-launch-send-after-create.test.ts | 4 +- ...gent-session-legacy-handoff-record.test.ts | 5 +- ...ssion-main-agent-working-agreement.test.ts | 258 ----- ...ctured-agent-session-option-restoration.ts | 53 - ...ed-agent-session-option-settlement.test.ts | 241 ----- ...ent-session-pre-spawn-first-answer.test.ts | 251 ----- ...nt-session-processless-reservation.test.ts | 43 +- ...gent-session-provider-child-record.test.ts | 908 ------------------ ...ed-agent-session-provider-open-contract.ts | 2 +- ...ctured-agent-session-provider-open.test.ts | 1 - .../structured-agent-session-provider-open.ts | 19 +- ...red-agent-session-provider-restore.test.ts | 111 --- ...t-session-provider-started-catalog.test.ts | 113 --- ...red-agent-session-provider-started.test.ts | 266 ----- ...ructured-agent-session-provider-started.ts | 18 +- ...ructured-agent-session-provider-support.ts | 2 +- ...ueued-message-rig-provider.test-fixture.ts | 14 +- ...session-queued-message-rig.test-fixture.ts | 18 +- ...tured-agent-session-recovery-exits.test.ts | 16 +- ...ctured-agent-session-refusal-retry.test.ts | 331 ------- ...gent-session-registered-definition.test.ts | 199 ---- ...uctured-agent-session-repeat-press.test.ts | 262 ----- .../structured-agent-session-rest-test-rig.ts | 12 +- ...tured-agent-session-restart-resume-host.ts | 1 - ...session-restart-status-publication.test.ts | 162 ---- ...ed-agent-session-restart-witnesses.test.ts | 1 - ...uctured-agent-session-restart-witnesses.ts | 2 - ...tured-agent-session-rewind-at-rest.test.ts | 4 +- ...red-agent-session-rewind-live-send.test.ts | 4 +- .../structured-agent-session-rewind.test.ts | 4 +- ...red-agent-session-send-preparation.test.ts | 699 -------------- ...session-send-restarts-failed-start.test.ts | 21 +- ...agent-session-settled-attach-retry.test.ts | 421 -------- ...uctured-agent-session-start-failure-row.ts | 42 +- ...agent-session-start-failure-writer.test.ts | 2 +- ...-agent-session-startup-attempt-contract.ts | 4 +- ...agent-session-startup-attempt-host.test.ts | 74 +- ...sion-startup-attempt-registrations.test.ts | 1 - ...ured-agent-session-startup-attempt.test.ts | 249 ----- ...tructured-agent-session-startup-attempt.ts | 14 +- ...gent-session-stop-note-opened-turn.test.ts | 3 + ...t-session-stop-note-withdrawn-send.test.ts | 3 + ...ctured-agent-session-stop-receipts.test.ts | 2 + ...red-agent-session-surface-lifetime.test.ts | 873 ----------------- ...t-session-wedged-profile-migration.test.ts | 9 +- ...tured-agent-session-working-at-teardown.ts | 8 +- .../structured-conversation-command.test.ts | 14 +- ...ructured-chat-naming-command-first.test.ts | 151 --- ...structured-chat-naming-status-hook.test.ts | 158 --- src/main/pi/rpc-configured-default.test.ts | 423 -------- .../pi/rpc-session-adapter.test-fixture.ts | 2 +- src/main/pi/rpc-session-adapter.ts | 3 +- .../agent-session-record-options.test.ts | 173 ---- .../runtime/agent-session-record-store.ts | 10 +- ...ured-agent-session-adoption-replay.test.ts | 4 + .../structured-agent-session-hold.test.ts | 237 ----- .../structured-chat-tab-table.test-fixture.ts | 4 + ...ed-agent-runtime-registrations-acp.test.ts | 3 + ...ured-agent-session-failure-logging.test.ts | 343 ------- ...ss-version-agent-session-wire.unit.test.ts | 2 +- .../cross-version-stop-note-test-rig.ts | 45 +- .../structured-agent-session-host-fixture.ts | 1 + ...-chat-owner-status-activation.unit.test.ts | 2 +- 115 files changed, 407 insertions(+), 10363 deletions(-) delete mode 100644 src/main/codex/codex-structured-question-order.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts delete mode 100644 src/main/native-chat/structured-chat-naming-command-first.test.ts delete mode 100644 src/main/native-chat/structured-chat-naming-status-hook.test.ts delete mode 100644 src/main/pi/rpc-configured-default.test.ts delete mode 100644 src/main/runtime/agent-session-record-options.test.ts delete mode 100644 src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts delete mode 100644 src/main/runtime/structured-agent-session-failure-logging.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 160002722a5e..1818c7f16555 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-09-11", + "updatedAt": "2026-10-13", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -1478,14 +1478,14 @@ "invariant": "One structured-send operation id causes at most one provider dispatch. Each user Send on phone or desktop is a new action with a fresh id and one request; neither automatically resends an ambiguous action. The host retains the original action's outcome, and a later phone Send remains possible with equal text, after remount, or when phone storage fails. Repeated transmission of the same operation remains protected by host deduplication.", "oracle": "Inject adapter/RPC acknowledgement loss, caller replacement, logical-client close after response, auth recovery with a written request, missing host submissions, legacy pending rows, stale fences, expiry, mobile remount, and phone storage refusal. Assert at most one dispatch for repeated transmission of one operation. For a later phone Send, assert a fresh id and second delivery while the original host entry remains unknown; equal images use current uploaded paths and delivery fields follow current host capabilities.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-structured-send-new-action.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-background-tasks.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts ../mobile/src/mobile-web-shell/page-storage-keys.test.ts ../mobile/src/mobile-web-shell/bridge/page-async-storage.test.ts ../mobile/src/mobile-web-shell/use-page-host-snapshot.test.tsx ../mobile/src/mobile-web-shell/bridge-host-init.test.ts ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts --maxWorkers=2" ], "testFiles": [ "src/shared/agent-session-operation-ledger.test.ts", "src/shared/structured-agent-session-send-evidence.test.ts", "src/main/runtime/agent-session-operation-admission.test.ts", - "src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts", + "src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts", "src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts", "src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts", "src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts", @@ -1582,13 +1582,13 @@ "summary": "Nineteen focused mobile files passed 241 tests for fresh Send identity, remount, attachment paths, storage refusal, mixed host capabilities, queued cards, and written-request ambiguity." }, { - "date": "2026-10-06", + "date": "2026-10-13", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "result": "passed", - "durationSeconds": 14, - "summary": "Eleven focused host, shared, renderer, and orchestration files passed 173 tests after the desktop outbox gave way to the in-memory sender, which makes one request per send and never resends." + "durationSeconds": 6.47, + "summary": "Eleven focused host, shared, renderer and orchestration files passed 196 tests, including unknown send outcomes, reused message IDs and host replay without duplicate dispatch. The retained send-idempotency suite replaces the removed branch-by-branch refusal matrix." } ], "runtimeBudget": { diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index fcf21463b715..bc1f580d25d2 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -179,8 +179,6 @@ 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-permission-mode.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts', diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index d5565f9fdfd7..a762992e23d7 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -39,7 +39,7 @@ export async function acquireAcpStructuredSession(input: { acquire: StructuredAgentSessionAcquireInput deps: AcpStructuredSessionAdapterDeps generation: string - /** The acquire was aborted; checked until the spawn, after which `track` hands it the connection. */ + /** The spawn was aborted; checked until the spawn, after which `track` hands it the connection. */ abandoned: () => boolean /** Registers the connection so a close during the acquire can stop it. */ track: (connection: AcpStructuredConnection) => void @@ -269,7 +269,7 @@ export async function acquireAcpStructuredSession(input: { session = child assertAcpStartingSession(child, input.abandoned) return { - acquisition: { process, acquisitionGeneration: generation, providerChildPhase: 'starting' }, + acquisition: { process, acquisitionGeneration: generation }, session: child } } diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index 3620304c5cd7..836bfacb08d4 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -6,10 +6,8 @@ export { FakeAcpChild, PID } from './acp-scripted-connection.test-fixture' import { vi } from 'vitest' import { z } from 'zod' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' -import type { - StructuredAgentSessionLifecycleEvent, - StructuredAgentSessionStartedEvent -} 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 { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import { openProviderTimelineRig, SESSION, @@ -56,7 +54,7 @@ export type AcpAdapterRig = { spawned: string[] lifecycle: StructuredAgentSessionLifecycleEvent[] readonly ended: Extract[] - started(): StructuredAgentSessionStartedEvent + started(): StructuredAgentSessionProviderOpenResult openFailure(): unknown settled: Parameters>[0][] acquire(options?: { @@ -89,6 +87,7 @@ export async function openAcpAdapterRig( const lifecycle: StructuredAgentSessionLifecycleEvent[] = [] const settled: AcpAdapterRig['settled'] = [] let openFailure: unknown + let opened: StructuredAgentSessionProviderOpenResult | null = null const startListeners = new Set<() => void>() const spec = options.spec ?? GROK const resolveLaunch: AcpStructuredSessionAdapterDeps['resolveLaunch'] = @@ -173,16 +172,16 @@ export async function openAcpAdapterRig( return lifecycle.filter((event) => event.type === 'ended') }, started: () => { - const event = lifecycle.findLast((entry) => entry.type === 'started') - if (event?.type !== 'started') { + if (!opened) { throw new Error('ACP session has not started') } - return event + return opened }, openFailure: () => openFailure, settled, acquire: async (acquireOptions = {}) => { openFailure = undefined + opened = null const acquisition = await adapter.acquire({ identity: { sessionId: SESSION, @@ -203,19 +202,12 @@ export async function openAcpAdapterRig( }) if (acquireOptions.waitForStart !== false) { try { - const opened = await adapter.open({ + opened = await adapter.open({ sessionId: SESSION, fence: acquireOptions.fence ?? 1, - acquisitionGeneration: acquisition.acquisitionGeneration ?? null, + acquisitionGeneration: acquisition.acquisitionGeneration, 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) diff --git a/src/main/acp/acp-structured-missing-model.test.ts b/src/main/acp/acp-structured-missing-model.test.ts index 0aa9dca3876b..15cca2559921 100644 --- a/src/main/acp/acp-structured-missing-model.test.ts +++ b/src/main/acp/acp-structured-missing-model.test.ts @@ -2,7 +2,6 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import { 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' @@ -45,10 +44,7 @@ function writtenOptions(provider: string, reader: AcpStructuredOptions) { unreadableRecords: new Map(), sessionTabs: null } - // What the host writes from the report a started child sends. - const options = nativeSessionOptionsFromReport({ - reported: reader.read().current - }) + const options = reader.reported() const changed = replaceAgentSessionRecordOptions(record, { sessionId: record.sessionId, fence: record.lease.runtimeFence, @@ -67,7 +63,7 @@ function writtenOptions(provider: string, reader: AcpStructuredOptions) { } describe('ACP sessions without a reported model', () => { - it('writes the captured Windows OMP startup through the live reader and shared normalizer', async () => { + it('writes the captured Windows OMP startup through the provider reader', async () => { const reader = new AcpStructuredOptions() reader.adoptSession(response) expect(reader.read()).toEqual({ 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 33e04d733eaa..1ff02a264922 100644 --- a/src/main/acp/acp-structured-publish-at-spawn.test.ts +++ b/src/main/acp/acp-structured-publish-at-spawn.test.ts @@ -85,7 +85,7 @@ describe('ACP child published before its handshake', () => { .open({ sessionId: SESSION, fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) .catch((error: unknown) => error) @@ -146,7 +146,7 @@ describe('ACP child published before its handshake', () => { rig.adapter.open({ sessionId: SESSION, fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) ).rejects.toMatchObject({ reason: 'notSignedIn' }) 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 b6c895758598..4c5b7174ed05 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -339,7 +339,7 @@ describe('ACP startup that never answers', () => { .open({ sessionId: SESSION, fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) .catch((error: unknown) => error) @@ -348,7 +348,6 @@ describe('ACP startup that never answers', () => { 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) }) it('leaves a published child alone when the acquisition signal aborts later', async () => { @@ -368,7 +367,7 @@ describe('ACP startup that never answers', () => { .open({ sessionId: SESSION, fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) .catch((error: unknown) => error) @@ -392,7 +391,7 @@ describe('ACP startup that never answers', () => { .open({ sessionId: SESSION, fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) .catch((error: unknown) => error) diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index c5a05cfb5569..c2f833314f9e 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -58,6 +58,9 @@ beforeEach(async () => { closeSession = vi.fn(async () => true) const store = await openTestAgentSessionRecordStore(root) const adapter: StructuredAgentSessionAdapter = { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: async ({ fence, spawnToken, events }) => { sink = events ?? null return { diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 72d91fc93653..e2768c7c759b 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -17,7 +17,6 @@ import type { StructuredAgentSessionMutationContext } from '../native-chat/agent import { readStructuredAgentSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-options-read' import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import { composeCodexSessionOptionCatalog } from '../codex/codex-structured-model-catalog' -import { nativeSessionOptionsFromReport } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' import { ClaudeStructuredSessionAdapter, type ClaudeStructuredSessionEvent @@ -401,8 +400,7 @@ describe('Claude effort default at rest', () => { const reported = (await claudeOpened(adapter, SESSION)).reportedOptions expect(reported).not.toHaveProperty('effort') - // The record the start persists names the listed row the catalog learned under. - const record = restingRecord(nativeSessionOptionsFromReport({ reported: reported })) + const record = restingRecord({ model: 'opus[1m]' }) expect(record.options).toEqual({ model: 'opus[1m]' }) expect((await readAtRest(store, record)).current.effort).toBe('medium') expect(record.options).toEqual({ model: 'opus[1m]' }) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index bba3eb1a1ee3..80bb0d9a6f96 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -266,7 +266,7 @@ export async function acquireClaudeSession({ ) } // The host calls open after publication and holds input until it settles. - return { ...publication.acquisition, providerChildPhase: 'starting' } + return publication.acquisition } catch (error) { unbindReadingControl?.() const acquisitionError = await resolveClaudeAcquisitionError({ diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index a36d490ecc4a..bdec578302cf 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -23,7 +23,7 @@ describe('Claude host-awaited open', () => { const claude = fakeClaude({ initDelayMs: 12_000 }) const { adapter, events } = adapterFor(claude) const acquisition = await adapter.acquire(ACQUIRE) - expect(acquisition).toMatchObject({ providerChildPhase: 'starting', process: { pid: 4321 } }) + expect(acquisition).toMatchObject({ process: { pid: 4321 } }) expect(acquisition.link).toBeUndefined() expect(claude.connections[0].calls).toEqual([]) expect(events.some((event) => event.type === 'options')).toBe(false) @@ -129,7 +129,7 @@ describe('Claude host-awaited open', () => { const open = adapter.open({ sessionId: 'session-1', fence: 7, - acquisitionGeneration: acquisition.acquisitionGeneration ?? null, + acquisitionGeneration: acquisition.acquisitionGeneration, signal: controller.signal }) controller.abort(new Error('host start expired')) diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 2068b5d64fe1..2fd4b8b4fccd 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -170,7 +170,7 @@ export type ClaudeSession = { confirmedOptions: Set /** The model the child was launched with, as `--model`; null when it runs the CLI's own. */ launchedModel: string | null - /** The launch left the saved Fast out, so the start applies it once the settings are read. */ + /** Fast was omitted from launch settings and needs confirmation at message input. */ fastModeAtStart: boolean /** CLI-advertised protocol capabilities from init; gates interrupt-receipt handling. */ capabilities: readonly string[] diff --git a/src/main/codex/codex-provider-retry-idle-sweep.test.ts b/src/main/codex/codex-provider-retry-idle-sweep.test.ts index d0263b0c100e..ec98636ff014 100644 --- a/src/main/codex/codex-provider-retry-idle-sweep.test.ts +++ b/src/main/codex/codex-provider-retry-idle-sweep.test.ts @@ -56,6 +56,9 @@ beforeEach(async () => { closeSession = vi.fn(async () => true) const store = await openTestAgentSessionRecordStore(root) const adapter: StructuredAgentSessionAdapter = { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: async ({ fence, spawnToken, events }) => { sink = events ?? null return { diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 22c30ba8f9fb..60c3231ea14d 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -1,7 +1,7 @@ // How a durable session record becomes a Codex process launch. // // Every input is read back from the record the store already made durable, not -// from the call that triggered the acquire. A client that attaches twice must +// from the call that triggered spawn. A client that attaches twice must // land in the same working directory under the same account home, and a resume // must name the thread this session actually proved — never one a caller asks // for, which is how a resume becomes a fork wearing a resume's name. diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts deleted file mode 100644 index 4a85cbffa7f1..000000000000 --- a/src/main/codex/codex-structured-question-order.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -// A Codex ask with several questions, driven from the real host journal through -// the client's session reducer to the rows the transcript list draws. -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalRenderItem -} from '../../shared/agent-session-journal-types' -import { - EMPTY_STRUCTURED_AGENT_SESSION, - reduceStructuredAgentSession, - type StructuredAgentSessionState -} from '../../shared/structured-agent-session-reducer' -import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' -import { projectNativeChatTranscriptMessages } from '../../shared/native-chat-transcript-projection' -import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' -import { CodexJournalPrompts } from './codex-structured-journal-prompts' -import { CODEX_USER_INPUT_METHOD } from './codex-structured-prompt-replies' -import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' -import { - HOST_TEST_NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { - projectStructuredQuestionMessages, - structuredQuestionTranscript -} from '../../renderer/src/components/native-chat/structured-agent-question-projection' -import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' -import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } - -type Asked = readonly { id: string; question: string }[] - -// Codex's question ids are the model's own words, so their text order is not -// the order it asked in. These two asks spell the two orders live QA saw. -const ASKED: Asked = [ - { id: 'scope', question: 'Which files are in scope?' }, - { id: 'priority', question: 'What matters most?' }, - { id: 'deadline', question: 'When is it due?' } -] -const ASKED_OUT_OF_ORDER: Asked = [ - { id: 'format', question: 'Which format?' }, - { id: 'audience', question: 'Who reads it?' }, - { id: 'length', question: 'How long?' } -] - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let sink: StructuredAgentSessionEventSink | null -let client: StructuredAgentSessionState -let clock: number - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-codex-question-order-')) - resetHostTestOperationIds() - sink = null - clock = HOST_TEST_NOW - const adapter: StructuredAgentSessionAdapter = { - acquire: vi.fn(async ({ fence, events }) => { - sink = events ?? null - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: HOST_TEST_NOW - } - } - }), - releaseAcquisition: vi.fn(async () => true), - dispatch: vi.fn(), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async ({ commit }) => commit()), - setOption: vi.fn(async () => undefined) - } - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - // Every write lands on its own millisecond, as it does live. - now: () => (clock += 1) - }) - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - if (!page.ok) { - throw new Error('no history page') - } - client = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { - type: 'history-page', - page: page.page - }) - host.subscribe({ - id: 'client', - sessionId: SESSION, - cursor: page.page.liveCursor ?? page.page.window.nextCursor, - emit: (event) => { - client = reduceStructuredAgentSession(client, { type: 'event', event }) - } - }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function codexPrompts(): CodexJournalPrompts { - if (!sink) { - throw new Error('session was never acquired') - } - return new CodexJournalPrompts( - { sink, attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }, - () => null, - () => 'turn-1' - ) -} - -async function ask(prompts: CodexJournalPrompts, asked: Asked = ASKED): Promise { - prompts.handle({ - threadId: THREAD, - method: CODEX_USER_INPUT_METHOD, - codexItemId: 'codex-item-1', - promptKey: '7', - params: { - threadId: THREAD, - turnId: 'turn-1', - questions: asked.map((question) => ({ - ...question, - options: [ - { label: 'Yes', description: '' }, - { label: 'No', description: '' } - ] - })) - } - }) - await host.flushStreamedEvents(SESSION) -} - -function questionItem(question: string): AgentJournalRenderItem { - const item = client.items.find( - (candidate) => candidate.body.kind === 'question' && candidate.body.question === question - ) - if (!item || item.body.kind !== 'question') { - throw new Error(`no journal item for ${question}`) - } - return item -} - -async function answer(question: string): Promise { - const item = questionItem(question) - if (item.body.kind !== 'question') { - return - } - const fields = { - itemId: item.itemId, - expectedRevision: item.revision, - optionId: item.body.options[0]!.id - } - const result = await host.respondToPrompt(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.respondTo:question', - sessionId: SESSION, - fields - }) - }, - kind: 'question', - ...fields - }) - expect(result.ok).toBe(true) - await host.flushStreamedEvents(SESSION) -} - -/** The prompt rows the transcript list draws, top to bottom, as the questions each one shows. */ -function drawnPromptRows(): string[][] { - const { receipts } = structuredQuestionTranscript(client.items) - // The desktop list's projection: its comparator adds only a rank for rows the host never writes. - const rows = projectNativeChatTranscriptMessages( - projectStructuredAgentSessionMessages( - client.items, - [], - client.submissions, - projectStructuredQuestionMessages - ) - ) - return rows.flatMap((row) => { - const prompt = receipts.get(row.id) - if (!prompt || prompt.kind !== 'question') { - return [] - } - const questions = prompt.questions?.length ? prompt.questions : [prompt] - return [questions.map(({ question }) => `${question} (${prompt.resolution.state})`)] - }) -} - -describe('a Codex ask with several questions', () => { - it('keeps the pending rest of the ask below the question already answered', async () => { - await ask(codexPrompts()) - await answer(ASKED[0]!.question) - - expect(drawnPromptRows()).toEqual([ - ['Which files are in scope? (resolved)'], - ['What matters most? (pending)', 'When is it due? (pending)'] - ]) - }) - - it('draws every answered question in the order Codex asked it', async () => { - await ask(codexPrompts()) - for (const { question } of ASKED) { - await answer(question) - } - - expect(drawnPromptRows()).toEqual([ - ['Which files are in scope? (resolved)'], - ['What matters most? (resolved)'], - ['When is it due? (resolved)'] - ]) - // Mobile draws the shared projection in journal order, one row per question. - expect( - projectStructuredAgentSessionMessages(client.items, [], client.submissions).map( - ({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null) - ) - ).toEqual(ASKED.map(({ question }) => question)) - }) - - it('draws a cancelled ask in the order Codex asked it', async () => { - const prompts = codexPrompts() - await ask(prompts, ASKED_OUT_OF_ORDER) - prompts.cancel(questionItem(ASKED_OUT_OF_ORDER[0]!.question).itemId) - await host.flushStreamedEvents(SESSION) - - expect(drawnPromptRows()).toEqual([ - ['Which format? (cancelled)'], - ['Who reads it? (cancelled)'], - ['How long? (cancelled)'] - ]) - }) -}) diff --git a/src/main/codex/codex-structured-service-tier.test.ts b/src/main/codex/codex-structured-service-tier.test.ts index bb822d1cceb9..b73d8c8df380 100644 --- a/src/main/codex/codex-structured-service-tier.test.ts +++ b/src/main/codex/codex-structured-service-tier.test.ts @@ -17,7 +17,6 @@ import { import { agentModelCatalogSessionAccess } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' import type { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' -import { nativeSessionOptionsFromReport } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' function listing(tier = 'priority') { return { @@ -261,17 +260,4 @@ describe('Codex service tier across clients and rest', () => { expect(CODEX_STRUCTURED_AGENT.restingOptions?.acceptsKey('serviceTier')).toBe(true) expect(CODEX_STRUCTURED_AGENT.restingOptions?.acceptsKey('fastMode')).toBe(false) }) - - it('keeps only the reported tier, not the Fast it implies, on the record', () => { - expect( - nativeSessionOptionsFromReport({ - reported: { model: 'gpt-live', serviceTier: 'priority', fastMode: true }, - priorOptions: { fastMode: 'true', serviceTier: 'ultrafast' } - }) - ).toEqual({ model: 'gpt-live', serviceTier: 'priority' }) - // An agent that reports Fast with no tier still keeps it. - expect(nativeSessionOptionsFromReport({ reported: { fastMode: true } })).toEqual({ - fastMode: 'true' - }) - }) }) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index b84971cdf490..872e0a153cc2 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -249,7 +249,7 @@ export async function acquireCodexStructuredSession(input: { }, openInput ) - return { process, acquisitionGeneration, providerChildPhase: 'starting' } + return { process, acquisitionGeneration } } catch (error) { if (sessions.get(sessionId)?.connection !== acquisition.connection) { return closeFailedCodexAcquisition({ diff --git a/src/main/codex/codex-structured-session-adapter-fixture.ts b/src/main/codex/codex-structured-session-adapter-fixture.ts index c9994a1c45f3..c759a3f08cdb 100644 --- a/src/main/codex/codex-structured-session-adapter-fixture.ts +++ b/src/main/codex/codex-structured-session-adapter-fixture.ts @@ -50,7 +50,7 @@ export async function acquireReadyCodexForTest( const result = await adapter.open({ sessionId: input.identity.sessionId, fence: input.fence, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) startReports.get(adapter)?.set(input.identity.sessionId, result) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index dc239fdb91b7..87cde9ec3cb7 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -18,8 +18,7 @@ import { CodexStructuredSessionTeardown } from './codex-structured-session-teard import { applyCodexStructuredSessionOption, prepareLiveCodexSessionOptions, - readLiveCodexSessionOptions, - readCodexAcquisitionOptions + readLiveCodexSessionOptions } from './codex-structured-session-options' import { resolvedCodexMessageOptions } from './codex-structured-message-options' import { @@ -314,13 +313,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap prepareReadOptions = (input: { sessionId: string; fence: number }) => prepareLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) - readAcquisitionOptions = (input: { - sessionId: string - fence: number - priorOptions?: Readonly> - }) => readCodexAcquisitionOptions(this.session(input.sessionId), input.priorOptions) - - closeSession = (sessionId: string): Promise => this.teardown.close(sessionId) + closeSession: NonNullable = (sessionId) => + this.teardown.close(sessionId) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) disposeSession = (sessionId: string): Promise => this.teardown.close(sessionId) closeAll = (): Promise => this.teardown.closeAll() diff --git a/src/main/codex/codex-structured-session-options.ts b/src/main/codex/codex-structured-session-options.ts index e9118f111ac1..bbac1d4c3006 100644 --- a/src/main/codex/codex-structured-session-options.ts +++ b/src/main/codex/codex-structured-session-options.ts @@ -26,31 +26,6 @@ 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 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 5bea0c49a235..87138afd2add 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -70,7 +70,6 @@ let idleMs: number let clock: number const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, acquisitionGeneration: `generation-${acquire.mock.calls.length}`, link: { @@ -102,7 +101,12 @@ async function startHost(): Promise { cancelTurn: vi.fn(async () => ({ cancelled: false })), answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined), - ...adapterExtras + ...adapterExtras, + open: + adapterExtras.open ?? + (async () => ({ + reportedOptions: { model: '' } + })) }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', @@ -236,16 +240,6 @@ function framedStates(events: AgentSessionSubscribeEvent[], id: string): string[ ) } -function deferred() { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((next, fail) => { - resolve = next - reject = fail - }) - return { promise, resolve, reject } -} - /** Rows written by an earlier host process that ended before handing them over. */ async function writeAsEarlierProcess( write: (journal: AgentSessionJournal, fence: number) => Promise @@ -281,13 +275,13 @@ function earlierSubmission(id: string, text: string, handoverRecorded?: true) { describe('a send is answered at acceptance', () => { it('answers before the child starts, then an open chat sees the handover and the reply (W2)', async () => { await host.close(SESSION, 'evict') - const starting = deferred() + const starting = Promise.withResolvers() acquire.mockImplementationOnce(async (input) => { await starting.promise return spawnChild(input) }) - const answering = deferred() + const answering = Promise.withResolvers() const answer = dispatch.getMockImplementation()! dispatch.mockImplementationOnce(async (input) => { await answering.promise @@ -309,7 +303,7 @@ describe('a send is answered at acceptance', () => { it('accepts a second send while the first one starts the child, before handing either over (W6)', async () => { await host.close(SESSION, 'evict') - const starting = deferred() + const starting = Promise.withResolvers() acquire.mockImplementationOnce(async (input) => { await starting.promise return spawnChild(input) @@ -605,8 +599,8 @@ describe('what an earlier host process left behind', () => { }) }) -describe('a child that exits before its message is handed over', () => { - it('rejects the message with the exit reason instead of starting another child (W24)', async () => { +describe('a child that exits before open completes', () => { + it('rejects the message with the start failure instead of starting another child (W24)', async () => { await host.close(SESSION, 'evict') await startHost() // The child exits before the host can finish its protocol open. @@ -715,7 +709,7 @@ describe('Stop withdraws what is queued', () => { it('withdraws a message whose start holds the queue: nothing is handed over (W17b)', async () => { await host.close(SESSION, 'evict') - const starting = deferred() + const starting = Promise.withResolvers() acquire.mockImplementationOnce(async (input) => { await starting.promise return spawnChild(input) @@ -758,7 +752,7 @@ describe('Stop withdraws what is queued', () => { describe('an eviction between acceptance and handover', () => { it('rejects the message as not sent, never leaves it in doubt (W24)', async () => { - const started = deferred() + const started = Promise.withResolvers() await host.close(SESSION, 'evict') await startHost() acquire.mockImplementationOnce(async (input) => { @@ -786,7 +780,7 @@ describe('an eviction between acceptance and handover', () => { const handed = await accept('handed over') await eventually(async () => expect((await submission(handed))?.handedOverAt).toBeDefined()) // Held, the second send is accepted and the close runs next, ahead of its handover. - const second = deferred() + const second = Promise.withResolvers() void host['tasks'].serialize(SESSION, () => second.promise) const accepting = accept('still queued') const closing = host.close(SESSION, 'evict') @@ -804,7 +798,7 @@ describe('an eviction between acceptance and handover', () => { it('does not stop an idle child while a message is still queued for it (W24)', async () => { idleMs = 1_000 - const started = deferred() + const started = Promise.withResolvers() await host.close(SESSION, 'evict') await startHost() acquire.mockImplementationOnce(async (input) => { @@ -838,7 +832,7 @@ describe('a close that stops the child and then a wind-down step fails', () => { it.each([{ end: 'evict', starting: true, kind: 'chatClosed', verdict: null }] as const)( 'rejects what is queued as $kind after a $end (during startup: $starting)', async ({ end, starting, kind, verdict }) => { - const started = deferred() + const started = Promise.withResolvers() adapterExtras = { // Once: the host's own teardown acknowledges again. acknowledgeSessionRelease: vi.fn().mockImplementationOnce(() => { 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 98ac767a664d..9aef5be41d65 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 @@ -8,7 +8,7 @@ export class StructuredAgentSessionAcquireAborts { /** Set by quit: the host is going away, so an attach that begins after it starts aborted. */ private quitReason: Error | null = null - /** For the attach about to run; `end` once it settles. */ + /** For the provider wait about to run; `end` once it settles. */ begin(sessionId: string, handover = false): { signal: AbortSignal; end: () => void } { const controller = new AbortController() if (this.quitReason) { @@ -40,7 +40,7 @@ export class StructuredAgentSessionAcquireAborts { return stoppedHandover } - /** Quit: every start under way stops, and so does any the attach drain still runs. */ + /** Quit: every provider wait under way stops, including those queued in the attach drain. */ abortAll(reason: string): void { this.quitReason ??= new Error(reason) for (const waits of this.inFlight.values()) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts deleted file mode 100644 index 6da0e33acc0d..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ /dev/null @@ -1,657 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { digestPayload } from '../agent-session-journal/journal-payload-bounds' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' -import { - attachFingerprintFields, - type AgentSessionAttachParams -} from './structured-agent-session-attach' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import { performAttach } from './structured-agent-session-attach-flow' -import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const NOW = 1_800_000_000_000 -const SESSION = 'legacy-session' -const CREATE_OPERATION = `${NOW}-${'1'.padStart(32, '0')}` -const RESUME_OPERATION = `${NOW}-${'2'.padStart(32, '0')}` -let root: string | null = null - -afterEach(async () => { - if (root) { - await rm(root, { recursive: true, force: true }) - } - root = null -}) - -function attachParams( - operationId: string, - expectedRuntimeFence: number | null, - options?: Readonly> -): AgentSessionAttachParams { - const params: AgentSessionAttachParams = { - envelope: { - sessionId: SESSION, - clientOperationId: operationId, - expectedRuntimeFence, - payloadFingerprint: '' - }, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'codex', - agent: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - runtimeKind: 'native', - ...(options ? { options } : {}), - providerHandle: { kind: 'codex', threadId: 'legacy-thread' } - } - return { - ...params, - envelope: { - ...params.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(params) - }) - } - } -} - -function adapter(input: { - origin: 'created' | 'resumed' - options?: AgentSessionOptionsResult -}): StructuredAgentSessionAdapter { - return { - acquire: vi - .fn() - .mockImplementation(async ({ fence, spawnToken }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW, - spawnToken - }, - link: { - linkId: `${input.origin}-link`, - handle: codexProviderHandle('legacy-thread'), - origin: input.origin, - mintedAtFence: fence, - observedAt: NOW - } - })), - ...(input.options ? { readOptions: vi.fn(async () => input.options!) } : {}), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } -} - -function expectSettledAttachLease(record: AgentSessionRecord | null): void { - expect(record).not.toBeNull() - const lease = record!.lease - const durableState = lease.handoffStage ?? lease.claimStatus - expect(['live', 'released', 'recovering']).toContain(durableState) - expect(lease.handoffStage).not.toBe('new-owner-proving') -} - -describe('structured session acquisition options', () => { - it('samples provider history before acquiring a replacement child', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-history-before-acquire-')) - const initialStore = await openTestAgentSessionRecordStore(root) - let childAcquired = false - const historyWindow = (): ProviderHistoryWindow => ({ - items: [], - boundaryConsistent: true, - turnInFlight: childAcquired - }) - const withHistory = (origin: 'created' | 'resumed'): StructuredAgentSessionAdapter => { - const sessionAdapter = adapter({ origin }) - const acquire = vi.mocked(sessionAdapter.acquire) - acquire.mockImplementation(async (input) => { - childAcquired = true - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW, - spawnToken: input.spawnToken - }, - link: { - linkId: `${origin}-link`, - handle: codexProviderHandle('legacy-thread'), - origin, - mintedAtFence: input.fence, - observedAt: NOW - } - } - }) - sessionAdapter.providerHistoryWindow = vi.fn(async () => historyWindow()) - return sessionAdapter - } - - let firstJournal: AgentSessionJournal | undefined - const first = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store: initialStore, - adapter: withHistory('created'), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null), - now: () => NOW, - onAttached: (attached) => { - firstJournal = attached.journal - } - }) - expect(first).toMatchObject({ ok: true }) - await firstJournal!.appendSubmission({ - clientMessageId: 'crashed-send', - payloadFingerprint: digestPayload('deploy the thing'), - body: { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: 'deploy the thing' }] - } satisfies AgentJournalMessageItem, - fence: 1 - }) - await firstJournal!.close() - const store = await openTestAgentSessionRecordStore(root) - await store.reconcileOnRestart({ - probe: async () => ({ outcome: 'pid-absent' }), - now: NOW + 1 - }) - childAcquired = false - const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - const second = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: withHistory('resumed'), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-b', - claimKeyId: 'key-1', - handoffOperationId: RESUME_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(RESUME_OPERATION, releasedFence), - now: () => NOW + 1, - onAttached: () => {} - }) - - expect(second).toMatchObject({ ok: true, value: { unconfirmedClientMessageIds: [] } }) - expect(second).toMatchObject({ - value: { - page: { submissions: [{ clientMessageId: 'crashed-send', dispatchState: 'rejected' }] } - } - }) - }) - - it('persists create defaults before the first provider acquisition', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-create-options-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter = adapter({ origin: 'created' }) - const options = { model: 'gpt-5.6-sol', effort: 'medium', fastMode: 'false' } - const recordPhase = vi.fn() - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, options), - now: () => NOW, - recordPhase, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(sessionAdapter.acquire).toHaveBeenCalledWith( - expect.objectContaining({ options, recordPhase }) - ) - expect(store.getRecord(SESSION)?.options).toEqual(options) - }) - - it('replays a create retried after the host re-resolved different options', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-create-retry-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter = adapter({ origin: 'created' }) - const attempt = async (options: Readonly>, spawnToken: string) => - performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken, - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, options), - now: () => NOW, - onAttached: () => {} - }) - - const created = await attempt({ model: 'gpt-5.6-sol', effort: 'medium' }, 'spawn-a') - // Why: the user may reselect a model between an unknown-outcome create and the - // retry that reuses its operation id; the retry must replay, not conflict. - const retried = await attempt({ model: 'gpt-5.5', effort: 'high' }, 'spawn-b') - - expect(created).toMatchObject({ ok: true }) - expect(retried).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5.6-sol', effort: 'medium' }) - }) - - it('preserves saved next-message choices when a provider reports its live options', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-')) - const store = await openTestAgentSessionRecordStore(root) - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter({ origin: 'created' }), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null), - now: () => NOW, - onAttached: () => {} - }) - expect(created).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toBeUndefined() - await store.replaceSessionOptions({ - sessionId: SESSION, - fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, - options: { approvalPolicy: 'on-request', personality: 'concise', fastMode: 'true' }, - now: NOW - }) - - const resumedStore = await openTestAgentSessionRecordStore(root) - await resumedStore.reconcileOnRestart({ - probe: async () => ({ outcome: 'pid-absent' }), - now: NOW + 1 - }) - const releasedFence = resumedStore.getRecord(SESSION)?.lease.runtimeFence ?? 0 - const resumed = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store: resumedStore, - adapter: adapter({ - origin: 'resumed', - options: { - current: { model: 'gpt-5.6-terra', effort: 'medium', fastMode: false }, - models: [] - } - }), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-b', - claimKeyId: 'key-1', - handoffOperationId: RESUME_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(RESUME_OPERATION, releasedFence), - now: () => NOW + 1, - onAttached: () => {} - }) - - expect(resumed).toMatchObject({ ok: true }) - const reopened = await openTestAgentSessionRecordStore(root) - expect(reopened.getRecord(SESSION)?.options).toEqual({ - approvalPolicy: 'on-request', - personality: 'concise', - fastMode: 'true' - }) - }) - - it('keeps a saved Fast choice without an acquisition report', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-fast-restore-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter = adapter({ - origin: 'created', - options: { current: { model: 'gpt-standard' }, models: [] } - }) - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, { - model: 'gpt-standard', - fastMode: 'true' - }), - now: () => NOW, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-standard', fastMode: 'true' }) - }) - - it('proves an opened Codex owner without replacing saved choices from readback', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-known-options-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter: StructuredAgentSessionAdapter = { - ...adapter({ origin: 'created' }), - readOptions: vi.fn(async () => { - throw new Error('model list unavailable') - }), - readAcquisitionOptions: vi.fn(() => ({ model: 'gpt-reported', effort: 'high' })) - } - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, { model: 'gpt-saved', effort: 'medium' }), - now: () => NOW, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(sessionAdapter.readOptions).not.toHaveBeenCalled() - expect(sessionAdapter.readAcquisitionOptions).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-saved', effort: 'medium' }) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) - - it('retains saved options when an opened Codex thread reports no effective model', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-saved-options-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter: StructuredAgentSessionAdapter = { - ...adapter({ origin: 'created' }), - readOptions: vi.fn(async () => { - throw new Error('model list unavailable') - }), - readAcquisitionOptions: vi.fn(() => undefined) - } - const options = { model: 'gpt-saved', effort: 'medium' } - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, options), - now: () => NOW, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual(options) - expect(sessionAdapter.readOptions).not.toHaveBeenCalled() - }) - - describe.each([ - ['adapter acquire', 'acquire'], - ['identity commit', 'identity'], - ['owner proof', 'proof'], - ['journal attach', 'journal'] - ] as const)('%s failure', (_label, failurePoint) => { - it.each([ - ['proven cleanup', true], - ['unproven cleanup', false], - ['cleanup error', 'throws'] - ] as const)('atomically settles the lease and operation after %s', async (_case, cleanup) => { - const exitProven = cleanup === true - root = await mkdtemp(join(tmpdir(), `orca-acquisition-${failurePoint}-`)) - const store = await openTestAgentSessionRecordStore(root) - const base = adapter({ - origin: 'created', - options: { current: { model: 'gpt-5.6-terra' }, models: [] } - }) - const injected = new Error(`${failurePoint} failed`) - const acquire = vi.mocked(base.acquire) - if (failurePoint === 'acquire') { - acquire.mockRejectedValueOnce(injected) - } else if (failurePoint === 'identity') { - vi.spyOn(store, 'commitProcessIdentity').mockRejectedValueOnce(injected) - } else if (failurePoint === 'proof') { - vi.spyOn(store, 'proveOwner').mockRejectedValueOnce(injected) - } else if (failurePoint === 'journal') { - acquire.mockImplementation(async ({ fence, spawnToken }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW, - spawnToken - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle('legacy-thread'), - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW - } - })) - } - const releaseAcquisition = vi.fn(async () => { - if (cleanup === 'throws') { - throw new Error('cleanup failed') - } - return cleanup - }) - const failingAdapter = { ...base, acquire, releaseAcquisition } - let journalFailure = failurePoint === 'journal' ? injected : null - const perform = ( - target: AgentSessionRecordStore, - operationId: string, - fence: number | null - ) => - performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store: target, - adapter: failingAdapter, - openConversation: async (record) => { - const failure = journalFailure - journalFailure = null - if (failure) { - throw failure - } - return openTestAttachConversation(openTestJournalHostDatabase(root!))(record) - }, - authority: { - spawnToken: operationId === CREATE_OPERATION ? 'spawn-a' : 'spawn-b', - claimKeyId: 'key-1', - handoffOperationId: operationId, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(operationId, fence), - now: () => NOW, - onAttached: () => {} - }) - - // A proven exit before the journal opens is answered once, as the refusal its replay gives; - // no exit was observed, so it names no situation. - const failed = perform(store, CREATE_OPERATION, null) - await (exitProven && failurePoint !== 'journal' - ? expect(failed).resolves.toEqual({ - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: "Codex couldn't restart. Send your message to try again." - } - }) - : expect(failed).rejects.toThrow( - exitProven ? injected.message : 'agent_session_acquisition_exit_unproven' - )) - - const reopened = await openTestAgentSessionRecordStore(root) - const failedRecord = reopened.getRecord(SESSION) - expectSettledAttachLease(failedRecord) - expect( - reopened.listOperationRows().find((row) => row.operationId === CREATE_OPERATION)?.outcome - ).toMatchObject({ status: 'failed' }) - - await reopened.reconcileOnRestart({ - probe: async (record) => - exitProven || record.lease.ownerProcess === null - ? exitProven - ? { outcome: 'reservation-unused' } - : { outcome: 'indeterminate', reason: 'owner identity was never committed' } - : { outcome: 'identity-matched', matchedOn: ['process-start-time'] }, - now: NOW + 1 - }) - - if (exitProven) { - expect(failedRecord?.lease).toMatchObject({ - runtimeFence: 2, - claimStatus: 'released', - handoffStage: null, - handoffOperationId: null, - ownerProcess: null, - reservedSpawnToken: null - }) - await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: true }) - expectSettledAttachLease(reopened.getRecord(SESSION)) - } else if (failurePoint === 'proof' || failurePoint === 'journal') { - // A recorded owner goes to recovery, which concludes about it before the next start. - expect(failedRecord?.lease).toMatchObject({ - runtimeFence: 1, - claimStatus: failurePoint === 'journal' ? 'live' : 'reserved', - handoffStage: 'recovering', - // The settled operation must not stay named by the lease as an in-flight transfer. - handoffOperationId: null, - reservedSpawnToken: 'spawn-a' - }) - await expect(perform(reopened, RESUME_OPERATION, 1)).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_ownership_unknown' } - }) - } else { - // No owner was recorded, and the adapter closed the stdio of anything it spawned: released, - // with no death evidence, since nothing proved one. - expect(failedRecord?.lease).toMatchObject({ - runtimeFence: 2, - claimStatus: 'released', - handoffStage: null, - handoffOperationId: null, - ownerProcess: null, - reservedSpawnToken: null, - deathEvidence: null - }) - await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: true }) - } - }) - }) -}) - -describe('the tab a create reserves', () => { - async function openStore() { - root = await mkdtemp(join(tmpdir(), 'orca-surface-tab-id-')) - return openTestAgentSessionRecordStore(root) - } - - function attachWith(store: AgentSessionRecordStore, surfaceTabId?: string) { - return performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter({ origin: 'created' }), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - // Beside the fingerprinted fields, like `options`: which tab shows the chat is not which - // conversation this attaches to. - params: { - ...attachParams(CREATE_OPERATION, null), - ...(surfaceTabId ? { surfaceTabId } : {}) - }, - now: () => NOW, - onAttached: () => {} - }) - } - - it('takes no tab at attach, then answers a retry naming another tab with the one it was given', async () => { - const store = await openStore() - const created = await attachWith(store, 'chat-tab-1') - // Publishing the tab takes the id, so a create that never gets there leaves nothing behind. - expect(created.ok && created.value.tabId).toBeUndefined() - expect(store.getSessionTabId(SESSION)).toBeNull() - - await store.setSessionTabVisibility(SESSION, true, 'chat-tab-1') - expect(await attachWith(store, 'chat-tab-2')).toMatchObject({ - ok: true, - value: { tabId: 'chat-tab-1' } - }) - expect(store.getSessionTabId(SESSION)).toBe('chat-tab-1') - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts deleted file mode 100644 index 90c94306db1d..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -// A chat starts whatever the store stamped on its owner: the Orca runtime holding the process is -// recorded beside its identity, and the start compares the process, not the stamp. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { agentSessionRuntimeIncarnation } from '../../runtime/agent-session-runtime-attribution' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { - attachFingerprintFields, - type AgentSessionAttachParams -} from './structured-agent-session-attach' -import { performAttach } from './structured-agent-session-attach-flow' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const NOW = 1_800_000_000_000 -const SESSION = 'stamped-owner-session' -const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` -let root: string | null = null - -afterEach(async () => { - if (root) { - await rm(root, { recursive: true, force: true }) - } - root = null -}) - -function attachParams(): AgentSessionAttachParams { - const params: AgentSessionAttachParams = { - envelope: { - sessionId: SESSION, - clientOperationId: OPERATION, - expectedRuntimeFence: null, - payloadFingerprint: '' - }, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'codex', - agent: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - runtimeKind: 'native', - providerHandle: { kind: 'codex', threadId: 'thread-1' } - } - return { - ...params, - envelope: { - ...params.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(params) - }) - } - } -} - -it('starts a chat whose recorded owner carries the runtime stamp', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-stamped-owner-')) - const store = await openTestAgentSessionRecordStore(root) - // The record as the store published it, stamp included, rather than the transition's own copy. - const commit = store.commitProcessIdentity.bind(store) - vi.spyOn(store, 'commitProcessIdentity').mockImplementation(async (input) => { - await commit(input) - const published = store.getRecord(input.sessionId) - if (!published) { - throw new Error('the committed record is missing') - } - return published - }) - const adapter: StructuredAgentSessionAdapter = { - acquire: vi.fn( - async ({ fence, spawnToken, onSpawned }) => { - const process = { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken } - await onSpawned?.(process) - return { - process, - link: { - linkId: 'created-link', - handle: codexProviderHandle('thread-1'), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - } - } - ), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } - - const attached = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(), - now: () => NOW, - onAttached: () => {} - }) - - expect(attached).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.lease.ownerProcess).toMatchObject({ - pid: 4242, - runtime: agentSessionRuntimeIncarnation() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index d239c2c0c599..5ef3064a0d21 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 @@ -5,14 +5,12 @@ import type { } from '../../../shared/agent-session-record' import { AgentSessionPreSpawnError, - isAgentSessionPreSpawnError, - type StructuredAgentSessionProviderChildPhase + isAgentSessionPreSpawnError } from './structured-agent-session-adapter' import { rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-provider-exit-proof' import { journalIdentityFor } from './structured-agent-session-attach' import type { AttachFlowInput } from './structured-agent-session-attach-flow' import { mintStructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt' -import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' /** The same process, whatever Orca runtime the store stamped on its record (`runtime`): that stamp * is about who holds the process, not which process it is. */ @@ -25,23 +23,6 @@ function sameOwnerProcess( return isDeepStrictEqual(storedProcess, acquiredProcess) } -/** Bookkeeping: a failure is logged, never the proven start's. */ -function handOverStartCatalogListing( - input: AttachFlowInput, - sessionId: string, - listing: AgentModelCatalogLiveListing -): void { - try { - input.onStartCatalogListing?.(listing) - } catch (error) { - input.logger.warn('saving what a started provider listed failed', { - scope: 'provider-started-catalog', - sessionId, - error - }) - } -} - /** A reservation with no process behind it is only a promise to spawn; the * adapter makes it real and the store then grants the writer. */ export async function acquireOwner( @@ -49,8 +30,7 @@ export async function acquireOwner( record: AgentSessionRecord ): Promise<{ record: AgentSessionRecord - acquisitionGeneration: string | null - providerChildPhase: StructuredAgentSessionProviderChildPhase + acquisitionGeneration: string }> { const fence = record.lease.runtimeFence const spawnToken = record.lease.reservedSpawnToken @@ -91,7 +71,6 @@ export async function acquireOwner( }) } }) - const providerChildPhase = acquired.providerChildPhase ?? 'ready' if (record.lease.ownerProcess === null) { await input.store.commitProcessIdentity({ sessionId: record.sessionId, @@ -102,20 +81,14 @@ export async function acquireOwner( } else if (!sameOwnerProcess(record.lease.ownerProcess, acquired.process)) { throw new Error('agent_session_ownership_unknown') } + // The process owns the lease now; a handle the provider answers with is returned by `open`. const proved = await input.store.proveOwner({ sessionId: record.sessionId, fence, ...(acquired.link ? { link: acquired.link } : {}), now: input.now() }) - if (providerChildPhase === 'ready' && acquired.catalogListing) { - handOverStartCatalogListing(input, record.sessionId, acquired.catalogListing) - } - return { - record: proved, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, - providerChildPhase - } + return { record: proved, acquisitionGeneration: acquired.acquisitionGeneration } } catch (error) { if (isAgentSessionPreSpawnError(error)) { throw error diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts deleted file mode 100644 index 6ab54a89eec0..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' -import type { - AgentSessionAcquisition, - StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' -import { StructuredAgentRegistry } from './structured-agent-registry' -import type { StructuredAgentDefinition } from './structured-agent-definition' - -const LOCAL: AgentSessionExecutionLocation = { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' -} - -function identity(sessionId: string, agent: string): AgentSessionJournalIdentity { - return { - sessionId, - workspaceId: 'workspace-1', - hostId: 'local', - agent, - providerHandle: claudeProviderHandle('provider-session-1', null) - } -} - -function acquisition(fence: number, spawnToken: string): AgentSessionAcquisition { - return { - process: { hostId: 'local', pid: 1, processStartTimeMs: 1, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: claudeProviderHandle('provider-session-1', null), - origin: 'created', - mintedAtFence: fence, - observedAt: 1 - } - } -} - -function fakeAdapter( - overrides: Partial = {} -): StructuredAgentSessionAdapter { - return { - supportsLocation: () => true, - acquire: vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn(), - ...overrides - } -} - -/** An agent this build does not ship, declared the way a new adapter would declare itself. */ -const PILOT: StructuredAgentDefinition = { - agent: 'grok', - handleTransport: 'acp', - accountHomeVariable: 'GROK_HOME', - capabilities: { - rewind: false, - compact: false, - threadGoal: false, - contextUsage: false, - imagePrompts: false, - steering: 'queue', - approvalEnforcement: 'orca' - }, - restingOptions: { - acceptsKey: () => false, - fallbackModels: () => null, - effortDefaultsToModel: false - } -} - -/** A double with every method Claude's and Codex's declarations need. */ -function declaringAdapter( - overrides: Partial = {} -): StructuredAgentSessionAdapter { - return fakeAdapter({ - compact: vi.fn(), - changeThreadGoal: vi.fn(), - rewind: vi.fn(), - recoverRewind: vi.fn(), - ...overrides - }) -} - -function claudeAndCodex( - adapters: { claude?: StructuredAgentSessionAdapter; codex?: StructuredAgentSessionAdapter } = {} -): StructuredAgentRegistry { - return new StructuredAgentRegistry([ - { definition: CLAUDE_STRUCTURED_AGENT, adapter: adapters.claude ?? declaringAdapter() }, - { definition: CODEX_STRUCTURED_AGENT, adapter: adapters.codex ?? declaringAdapter() } - ]) -} - -describe('StructuredAgentSessionAdapterRouter registry', () => { - it('routes a registered agent the router has no code for, and refuses an unregistered one', async () => { - const pilot = fakeAdapter() - const router = new StructuredAgentSessionAdapterRouter( - new StructuredAgentRegistry([{ definition: PILOT, adapter: pilot }]), - async () => {} - ) - - expect(router.supportsCreate(LOCAL, 'grok')).toBe(true) - expect(router.supportsCreate(LOCAL, 'claude')).toBe(false) - await router.acquire({ identity: identity('session-1', 'grok'), fence: 1, spawnToken: 's-1' }) - expect(pilot.acquire).toHaveBeenCalledOnce() - await expect( - router.acquire({ identity: identity('session-2', 'claude'), fence: 1, spawnToken: 's-2' }) - ).rejects.toThrow('structured sessions do not support claude') - }) - - it('refuses two registrations for one agent', () => { - expect( - () => - new StructuredAgentRegistry([ - { definition: PILOT, adapter: fakeAdapter() }, - { definition: PILOT, adapter: fakeAdapter() } - ]) - ).toThrow('structured agent grok is registered twice') - }) - - it.each([ - ['compact', 'compact'], - ['threadGoal', 'changeThreadGoal'], - ['rewind', 'rewind'], - ['rewind', 'recoverRewind'] - ] as const)('refuses a declared %s whose adapter has no %s', (capability, method) => { - const declared: StructuredAgentDefinition = { - ...PILOT, - capabilities: { ...PILOT.capabilities, [capability]: true } - } - const adapter = declaringAdapter({ [method]: undefined }) - - expect(() => new StructuredAgentRegistry([{ definition: declared, adapter }])).toThrow( - `structured agent grok declares a capability its adapter has no ${method} for` - ) - expect(() => new StructuredAgentRegistry([{ definition: PILOT, adapter }])).not.toThrow() - }) - - it('answers what each registered agent declares, and nothing for an unregistered one', () => { - const agents = claudeAndCodex() - - expect(agents.capabilities('codex')).toBe(CODEX_STRUCTURED_AGENT.capabilities) - expect(agents.capabilities('claude')).toBe(CLAUDE_STRUCTURED_AGENT.capabilities) - expect(agents.capabilities('grok')).toBeNull() - expect(agents.definition('grok')).toBeNull() - expect(agents.definitions()).toEqual([CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT]) - }) - - it('lets a session narrow a declared rewind but never widen an undeclared one', async () => { - const narrowing = declaringAdapter({ - rewindSupport: () => ({ supported: false, reason: 'history-not-paginated' }) - }) - const widening = declaringAdapter({ rewindSupport: () => ({ supported: true }) }) - const router = new StructuredAgentSessionAdapterRouter( - claudeAndCodex({ codex: narrowing, claude: widening }), - async () => {} - ) - const unsupported = { supported: false, reason: 'unsupported' } - - expect(router.rewindSupport('session-1', 'codex')).toEqual({ - supported: false, - reason: 'history-not-paginated' - }) - // Claude declares no rewind; its adapter's answer cannot claim one, at rest or live. - expect(router.rewindSupport('session-1', 'claude')).toEqual(unsupported) - await router.acquire({ identity: identity('session-1', 'claude'), fence: 1, spawnToken: 's' }) - expect(router.rewindSupport('session-1')).toEqual(unsupported) - expect(router.rewindSupport('session-2', 'grok')).toEqual(unsupported) - }) -}) - -describe('structured agent definitions', () => { - it('declares what Claude and Codex structured chats already did', () => { - expect(CLAUDE_STRUCTURED_AGENT.capabilities).toEqual({ - rewind: false, - compact: true, - threadGoal: false, - contextUsage: true, - imagePrompts: true, - steering: 'inject', - approvalEnforcement: 'provider' - }) - expect(CODEX_STRUCTURED_AGENT.capabilities).toEqual({ - rewind: true, - compact: true, - threadGoal: true, - contextUsage: true, - imagePrompts: true, - steering: 'inject', - approvalEnforcement: 'provider' - }) - }) - - it('keeps each agent’s resting option rules with its definition', () => { - const claude = CLAUDE_STRUCTURED_AGENT.restingOptions - const codex = CODEX_STRUCTURED_AGENT.restingOptions - expect(claude.fallbackModels()?.length).toBeGreaterThan(0) - expect(codex.fallbackModels()).toBeNull() - expect(claude.effortDefaultsToModel).toBe(true) - expect(codex.effortDefaultsToModel).toBe(false) - expect(claude.acceptsKey('model')).toBe(true) - expect(codex.acceptsKey('model')).toBe(true) - expect(claude.acceptsKey('no-such-option')).toBe(false) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts index 27c4a323506d..7f8ad5c91067 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -63,30 +63,6 @@ describe('StructuredAgentSessionAdapterRouter.releaseAcquisition', () => { }) }) -describe('StructuredAgentSessionAdapterRouter.readAcquisitionOptions', () => { - it('keeps the existing live read for providers without acquisition options', async () => { - const claude = adapterOf(vi.fn(async () => true)) - claude.readOptions = vi.fn(async () => ({ - current: { model: 'reported-model', effort: 'high' }, - models: [] - })) - const router = claudeAndCodexRouter( - { claude, codex: adapterOf(vi.fn(async () => false)) }, - async () => {} - ) - await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) - - await expect( - router.readAcquisitionOptions({ - sessionId: 'session-1', - fence: 1, - priorOptions: { model: 'saved-model', personality: 'concise' } - }) - ).resolves.toEqual({ model: 'reported-model', effort: 'high', personality: 'concise' }) - expect(claude.readOptions).toHaveBeenCalledOnce() - }) -}) - describe('StructuredAgentSessionAdapterRouter.closeSession', () => { it('retains the owner after an unproven close so a later retry reaches the same adapter', async () => { const claude = adapterOf(vi.fn(async () => true)) 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 971556a942a2..146c966e3356 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -5,7 +5,6 @@ import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { readNativeSessionOptions } from './structured-agent-session-option-restoration' import type { StructuredAgentRegistration, StructuredAgentRegistry @@ -68,13 +67,7 @@ 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) - } + open: StructuredAgentSessionAdapter['open'] = (input) => this.owner(input.sessionId).open(input) dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) @@ -168,9 +161,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi setOption: StructuredAgentSessionAdapter['setOption'] = (input) => this.owner(input.sessionId).setOption(input) - startAnswered = (sessionId: string): boolean | undefined => - this.liveOwnerOrNull(sessionId)?.startAnswered?.(sessionId) - prepareReadOptions = (input: { sessionId: string; fence: number }) => this.liveOwnerOrNull(input.sessionId)?.prepareReadOptions?.(input) @@ -182,17 +172,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return reader(input) } - readAcquisitionOptions = (input: { - sessionId: string - fence: number - priorOptions?: Readonly> - }) => { - const adapter = this.owner(input.sessionId) - return adapter.readAcquisitionOptions - ? adapter.readAcquisitionOptions(input) - : readNativeSessionOptions({ adapter, ...input }) - } - providerHistoryWindow = (input: { identity: AgentSessionJournalIdentity accountHome: AgentSessionAccountHome diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts deleted file mode 100644 index daa7d2028428..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { - AgentSessionAcquisitionExitProvenError, - AgentSessionAcquisitionExitUnprovenError, - AgentSessionAcquisitionRefusal, - AgentSessionAcquisitionRootExitObservedError -} from './structured-agent-session-adapter' -import { rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-provider-exit-proof' - -describe('failed agent-session acquisition cleanup', () => { - it('names a failure exit-proven after proven cleanup, keeping its diagnostic and cause', async () => { - const cause = new Error('proof failed') - const thrown = await rethrowAfterAgentSessionAcquisitionCleanup( - { releaseAcquisition: vi.fn(async () => true) }, - 'session-1', - cause - ).catch((error: unknown) => error) - - expect(thrown).toBeInstanceOf(AgentSessionAcquisitionExitProvenError) - expect(thrown).toMatchObject({ message: 'proof failed', cause }) - }) - - it.each([ - ['a refusal', new AgentSessionAcquisitionRefusal('not signed in')], - ['a root exit', new AgentSessionAcquisitionRootExitObservedError(new Error('exited'))], - ['a host store code', new Error('agent_session_checkpoint_stale')] - ])('keeps %s that already names its verdict after proven cleanup', async (_label, cause) => { - await expect( - rethrowAfterAgentSessionAcquisitionCleanup( - { releaseAcquisition: vi.fn(async () => true) }, - 'session-1', - cause - ) - ).rejects.toBe(cause) - }) - - it('reports unproven exit when cleanup returns false', async () => { - await expect( - rethrowAfterAgentSessionAcquisitionCleanup( - { releaseAcquisition: vi.fn(async () => false) }, - 'session-1', - new Error('proof failed') - ) - ).rejects.toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) - }) - - it('keeps a first-hand root exit that cleanup observed, with the provider diagnostic', async () => { - const cause = new Error('proof failed') - const exit = new AgentSessionAcquisitionRootExitObservedError( - new Error('claude stream-json exited (code 1): crashed') - ) - const error = await rethrowAfterAgentSessionAcquisitionCleanup( - { - releaseAcquisition: vi.fn(async () => { - throw exit - }) - }, - 'session-1', - cause - ).catch((thrown: unknown) => thrown) - - expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) - expect(error).not.toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) - expect((error as Error).message).toBe('claude stream-json exited (code 1): crashed') - expect((error as Error).cause).toMatchObject({ errors: [cause, exit] }) - }) - - it('reports unproven exit when cleanup throws', async () => { - const error = await rethrowAfterAgentSessionAcquisitionCleanup( - { - releaseAcquisition: vi.fn(async () => { - throw new Error('cleanup failed') - }) - }, - 'session-1', - new Error('proof failed') - ).catch((cause: unknown) => cause) - - expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) - expect(error).toMatchObject({ cause: expect.any(AggregateError) }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 5191cf1a6ea4..6670b72251fa 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 @@ -54,7 +54,10 @@ 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' +import type { + StructuredAgentSessionProviderOpen, + StructuredAgentSessionProviderOpenResult +} from './structured-agent-session-provider-open-contract' /** A live options read, plus what the child listed for its account's saved catalog. The host * saves that listing and strips it before answering; an adapter whose listing reaches the @@ -104,22 +107,14 @@ export { AgentSessionAcquisitionRootExitObservedError } from './structured-agent-session-acquisition-errors' -/** What a reservation turns into once something is actually running under it: - * the process the host can probe, and the provider handle it was minted with. */ +/** The owned process returned before protocol construction begins. */ export type AgentSessionAcquisition = { process: AgentSessionProcessIdentity + /** Only a handle the adapter already holds at spawn; protocol handles come from open. */ link?: AgentSessionProviderHandleLink /** Host-local identity for this exact provider child, distinct even when the durable fence is * reused by a superseding acquisition. */ - acquisitionGeneration?: string - /** `starting`: published at spawn, proven later by a `started` event. Absent means `ready`, an - * acquire that ran the whole handshake before answering; that bridge ends once every adapter - * publishes at spawn. Either way the host holds input until the child is `ready`. */ - providerChildPhase?: StructuredAgentSessionProviderChildPhase - /** What a `ready` child listed at its start, with the configured default its start resolved; - * the host saves it once. A `starting` child (Claude) hands its listing on `started` and its - * resolved default on the settings readback's `options-reported`. */ - catalogListing?: AgentModelCatalogLiveListing + acquisitionGeneration: string } /** A refusal before spawn that a person can act on; the site that refused names it. */ @@ -207,37 +202,20 @@ export type StructuredAgentSessionEndedEvent = { startupUnanswered?: true } -/** The child a publish-first acquire handed over has now proven its start: startup facts applied. - * What it reports from here on is fact, not a catalog guess. */ -export type StructuredAgentSessionStartedEvent = { - /** The handle supplied when the provider finishes starting. */ - link?: AgentSessionProviderHandleLink - type: 'started' - sessionId: string - fence: number - acquisitionGeneration: string - /** What the child proved, snapshotted by the adapter from what startup already read. The host - * handles this inside the session's serialized step, so it must not ask the CLI. */ - reportedOptions: AgentSessionOptionsResult['current'] - /** What the child listed at startup, saved as its account's catalog even if no view reads it. */ - catalogListing?: AgentModelCatalogLiveListing -} - -/** What a child that already proved its start reports later, such as an optional read that came - * after `started`: persisted as the start's report is. Never delays a start. */ -export type StructuredAgentSessionOptionsReportedEvent = Omit< - StructuredAgentSessionStartedEvent, - 'type' | 'link' -> & { type: 'options-reported' } +/** An optional report after open; it never delays readiness. */ +export type StructuredAgentSessionOptionsReportedEvent = + StructuredAgentSessionProviderOpenResult & { + type: 'options-reported' + sessionId: string + fence: number + acquisitionGeneration: string + } export type StructuredAgentSessionLifecycleEvent = | StructuredAgentSessionEndedEvent - | StructuredAgentSessionStartedEvent | StructuredAgentSessionOptionsReportedEvent -/** Whether the provider child behind an acquisition has proven its start. A publish-first - * acquire hands over a `starting` child, which the host gives no input until the `started` - * lifecycle event flips it. */ +/** Whether the host's open has proven the child ready for input. */ export type StructuredAgentSessionProviderChildPhase = 'starting' | 'ready' /** The host's startup attempt (`structured-agent-session-startup-attempt`), which the host always @@ -265,7 +243,7 @@ export type StructuredAgentSessionSetOptionInput = { export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & { /** Opens the published process under the host's startup signal. */ - open?: StructuredAgentSessionProviderOpen + open: StructuredAgentSessionProviderOpen /** Provider-aware capability check for hosts that route more than one adapter. */ supportsCreate?(location: AgentSessionExecutionLocation, agent: string): boolean /** Provider/runtime support, kept here so remote enablement changes adapter data, not UI logic. */ @@ -389,9 +367,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & setOption( input: StructuredAgentSessionSetOptionInput ): Promise>> - /** False while the live child has not answered its start, so it has run nothing it was handed. - * Absent or undefined reads as answered. */ - startAnswered?(sessionId: string): boolean | undefined /** Fetch off the lane, then apply the result under the same child's fence. */ prepareReadOptions?(input: { sessionId: string @@ -401,15 +376,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & sessionId: string fence: number }): Promise - /** Effective options already known after acquisition, without discovering picker choices. */ - readAcquisitionOptions?(input: { - sessionId: string - fence: number - priorOptions?: Readonly> - }): - | Promise> | undefined> - | Readonly> - | undefined /** Provider history for restart reconciliation, bounded to what the provider * recorded after the journal's last committed item. Only the adapter can say * whether the read has a proven start and whether a turn is still running, so diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 7b478ff10a54..859b3055cdb9 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 @@ -106,9 +106,13 @@ function attachParams( function adapter(): StructuredAgentSessionAdapter { return { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi .fn() .mockImplementation(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, link: { linkId: 'resumed-link', @@ -184,6 +188,7 @@ describe('adopting a provider conversation on create', () => { const handle = { transport: 'acp', agent: 'grok', nativeId: THREAD } const sessionAdapter = adapter() vi.mocked(sessionAdapter.acquire).mockImplementation(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: `generation-${fence}`, process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, link: { linkId: 'resumed-link', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index b8ebf78b9370..7abddf6130be 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 @@ -117,7 +117,6 @@ beforeEach(async () => { resetHostTestOperationIds() generation = 0 acquire = vi.fn(async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -134,6 +133,7 @@ beforeEach(async () => { logger: createStructuredAgentSessionLogger(), store, adapter: { + open: () => new Promise(() => {}), acquire, releaseAcquisition: vi.fn(async () => true), closeSession: vi.fn(async () => true), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts deleted file mode 100644 index 6a54478a4613..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -// A Claude chat whose Claude is not running offers the `/` commands and skills Claude would read -// from its folders on the host that runs it: after a /clear, after a relaunch, as files change. - -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { - AgentSessionSlashCommand, - AgentSessionSubscribeEvent -} from '../../../shared/agent-session-wire' -import { - CLAUDE_AT_REST_COMMANDS_TTL_MS, - ClaudeAtRestCommandCatalog -} from '../../claude/claude-at-rest-commands' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' -import { - HOST_TEST_LOCATION, - HOST_TEST_NOW, - HOST_TEST_SESSION as SESSION, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const caller = { callerKey: 'desktop' } -const CLAUDE_SESSION = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' - -let directory: string -let workspace: string -let account: string -let clock: number -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost - -/** Claude is never running here, so the only `/` surface is the one read from its folders. */ -function adapter(catalog: ClaudeAtRestCommandCatalog): StructuredAgentSessionAdapter { - return { - supportsLocation: () => true, - supportsCreate: () => true, - acquire: vi.fn(async (input) => ({ - process: { - hostId: 'local', - pid: 4001, - processStartTimeMs: HOST_TEST_NOW, - spawnToken: input.spawnToken - }, - link: { - linkId: `link-${input.fence}`, - mintedAtFence: input.fence, - observedAt: HOST_TEST_NOW, - origin: 'created' as const, - handle: claudeProviderHandle(CLAUDE_SESSION, null) - } - })), - atRestCommands: catalog, - dispatch: vi.fn(async () => ({ state: 'unknown' as const, reason: 'test' })), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => {}, - setOption: async () => {}, - releaseAcquisition: async () => true, - closeSession: async () => true - } -} - -function catalogFor(workspacePath: string): ClaudeAtRestCommandCatalog { - return new ClaudeAtRestCommandCatalog({ - resolveWorkspacePath: async () => workspacePath, - now: () => clock, - // Only the folders this test writes, never the machine's own home. - discover: async (args) => { - const { discoverSkills } = await import('../../skills/discovery') - return discoverSkills({ ...args, homeDir: join(directory, 'home'), refresh: true }) - } - }) -} - -async function openHost(catalog = catalogFor(workspace)): Promise { - store = await openTestAgentSessionRecordStore(directory) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - store, - adapter: adapter(catalog), - journalDatabase: openTestJournalHostDatabase(directory), - claimKeyId: 'key', - logger: recordingStructuredAgentSessionLogger().logger, - now: () => clock, - probeOwner: async () => ({ outcome: 'pid-absent' }) - }) -} - -async function writeSkill(root: string, name: string): Promise { - await mkdir(join(root, name), { recursive: true }) - await writeFile(join(root, name, 'SKILL.md'), `---\nname: ${name}\ndescription: A skill\n---\n`) -} - -async function writeCommand(root: string, file: string): Promise { - await mkdir(join(root, file, '..'), { recursive: true }) - await writeFile(join(root, file), '---\ndescription: A command\n---\nDo it.\n') -} - -/** The `/` surface a pane subscribed to `sessionId` shows once the scan has landed. */ -async function menuOf(sessionId: string): Promise { - let commands: AgentSessionSlashCommand[] | null | undefined - const emit = (event: AgentSessionSubscribeEvent) => { - if ('commands' in event) { - commands = event.commands - } - } - const unsubscribe = await host.subscribe({ id: `pane-${sessionId}`, sessionId, emit }) - await vi.waitFor(() => expect(commands).toBeTruthy()) - unsubscribe() - return commands ?? null -} - -const namesOf = (menu: AgentSessionSlashCommand[] | null, kind: 'skill' | 'command') => - (menu ?? []).filter((entry) => entry.kind === kind).map((entry) => entry.name) - -beforeEach(async () => { - resetHostTestOperationIds() - clock = HOST_TEST_NOW - directory = await mkdtemp(join(tmpdir(), 'orca-at-rest-commands-')) - workspace = join(directory, 'workspace') - account = join(directory, 'claude-account') - await writeSkill(join(workspace, '.claude', 'skills'), 'review-pr') - await writeCommand(join(account, 'commands'), 'deploy.md') - await openHost() - const attached = await host.attach( - caller, - hostTestAttachParams(null, { - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: account }, - providerHandle: { kind: 'claude', sessionId: CLAUDE_SESSION, leafUuid: null } - }) - ) - expect(attached).toMatchObject({ ok: true }) - await host.setSessionTabVisibility(SESSION, true) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(directory, { recursive: true, force: true }) -}) - -describe("a Claude chat whose Claude isn't running shows the `/` surface from its folders", () => { - it('after /clear, before the fresh agent context starts', async () => { - const cleared = await host.conversationCommand(caller, { - command: 'clear', - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.conversationCommand', - sessionId: SESSION, - fields: { command: 'clear' } - }) - } - }) - expect(cleared).toMatchObject({ ok: true }) - const replacement = SESSION - const menu = await menuOf(replacement) - expect(namesOf(menu, 'skill')).toEqual(['review-pr']) - // Everything the menu offered before a list existed, then what the folders add. - expect(namesOf(menu, 'command')).toEqual(['model', 'effort', 'clear', 'compact', 'deploy']) - }) - - it('keeps a slow scan once it answers, never a staler one after a newer, and runs at most two', async () => { - const pending: ((skills: string[]) => void)[] = [] - const catalog = new ClaudeAtRestCommandCatalog({ - resolveWorkspacePath: async () => workspace, - now: () => clock, - discover: () => - new Promise((resolve) => { - pending.push((names) => - resolve({ - skills: names.map((name) => ({ - id: name, - name, - description: null, - providers: ['claude'], - sourceKind: 'repo', - sourceLabel: 'repo', - rootPath: workspace, - directoryPath: workspace, - skillFilePath: workspace, - installed: true, - updatedAt: null - })), - sources: [], - scannedAt: clock - }) - ) - }) - }) - const record = store.getRecord(SESSION)! - const skills = () => namesOf(catalog.read(record) ?? null, 'skill') - const changed = vi.fn() - catalog.onChange(changed) - catalog.read(record) - await vi.waitFor(() => expect(pending).toHaveLength(1)) - // Overdue: a second scan starts beside the first; a third never does while both run. - clock += 30_000 - catalog.read(record) - await vi.waitFor(() => expect(pending).toHaveLength(2)) - clock += 30_000 - catalog.read(record) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(pending).toHaveLength(2) - // The second answers first; the first, older one landing after it changes nothing. - pending[1](['newer']) - await vi.waitFor(() => expect(skills()).toEqual(['newer'])) - pending[0](['older']) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(skills()).toEqual(['newer']) - expect(changed).toHaveBeenCalledTimes(1) - // A slow scan is not thrown away: the next one, however long it takes, is kept. - clock += CLAUDE_AT_REST_COMMANDS_TTL_MS - catalog.read(record) - await vi.waitFor(() => expect(pending).toHaveLength(3)) - clock += 35_000 - pending[2](['slow']) - await vi.waitFor(() => expect(skills()).toEqual(['slow'])) - }) - - it('waits out the window from when a scan lands, even one that failed', async () => { - let calls = 0 - let failNext = true - const catalog = new ClaudeAtRestCommandCatalog({ - resolveWorkspacePath: async () => workspace, - now: () => clock, - discover: async (args) => { - calls += 1 - // Each scan takes 15 s of the clock, longer than the window. - clock += 15_000 - if (failNext) { - failNext = false - throw new Error('unreadable') - } - const { discoverSkills } = await import('../../skills/discovery') - return discoverSkills({ ...args, homeDir: join(directory, 'home'), refresh: true }) - } - }) - const record = store.getRecord(SESSION)! - const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - try { - catalog.read(record) - await vi.waitFor(() => expect(warned).toHaveBeenCalledOnce()) - catalog.read(record) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(calls).toBe(1) - clock += CLAUDE_AT_REST_COMMANDS_TTL_MS - catalog.read(record) - await vi.waitFor(() => - expect(namesOf(catalog.read(record) ?? null, 'skill')).toEqual(['review-pr']) - ) - clock += CLAUDE_AT_REST_COMMANDS_TTL_MS - 1 - catalog.read(record) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(calls).toBe(2) - } finally { - warned.mockRestore() - } - }) - - it('after a relaunch, with nothing remembered from before it', async () => { - await host.flushAllStreamedEvents() - await openHost() - const menu = await menuOf(SESSION) - expect(namesOf(menu, 'skill')).toEqual(['review-pr']) - expect(namesOf(menu, 'command')).toContain('deploy') - }) - - it('with a command added on disk, pushed to a pane already showing the chat', async () => { - let commands: AgentSessionSlashCommand[] | null | undefined - await host.subscribe({ - id: 'pane', - sessionId: SESSION, - emit: (event) => { - if ('commands' in event) { - commands = event.commands - } - } - }) - await vi.waitFor(() => expect(namesOf(commands ?? null, 'command')).toContain('deploy')) - await writeCommand(join(workspace, '.claude', 'commands'), 'frontend/test.md') - clock += CLAUDE_AT_REST_COMMANDS_TTL_MS - // Anything that reads the surface after the window scans again; the pane is told the answer. - host.readCommands(SESSION) - await vi.waitFor(() => - expect(namesOf(commands ?? null, 'command')).toEqual( - expect.arrayContaining(['deploy', 'frontend:test']) - ) - ) - }) - - it('from the folders of the host that runs it, never another host’s', async () => { - const remoteWorkspace = join(directory, 'remote-workspace') - await writeSkill(join(remoteWorkspace, '.claude', 'skills'), 'remote-only') - const remote = catalogFor(remoteWorkspace) - const record = store.getRecord(SESSION)! - remote.read(record) - await vi.waitFor(() => - expect(namesOf(remote.read(record) ?? null, 'skill')).toEqual(['remote-only']) - ) - // A chat this host does not run is answered by nothing here, not by this host's folders. - const local = catalogFor(workspace) - const elsewhere = { - ...record, - location: { ...HOST_TEST_LOCATION, executionHostId: 'ssh:box' as const } - } - local.read(elsewhere) - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(local.read(elsewhere)).toBeUndefined() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 3f49fbdd0c23..0d192a1661fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -5,10 +5,7 @@ import { failedAcquisitionSettlement, preSpawnFailureInWords } from './structured-agent-session-failed-create-refusal' -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionProviderChildPhase -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' // The host supplies owner authority; this flow reserves, proves, and publishes the session. import type { @@ -52,7 +49,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' | 'none' @@ -74,20 +70,17 @@ export type AttachFlowInput = { onStartupAttempt?: ( attempt: StructuredAgentSessionStartupAttempt ) => StructuredAgentSessionStartupProgress - /** A completed acquisition's start listing. */ - onStartCatalogListing?: (listing: AgentModelCatalogLiveListing) => void startupOptions?: Readonly> /** The permission mode a start of this record launches with; absent or undefined for none. */ launchPermissionMode?: ( record: AgentSessionRecord ) => Promise /** Publishes the journal before clients can send against the new owner. `owner` says whether - * this attach spawned the child, kept a live one, or left the chat at rest. */ + * this attach spawned the child (then `starting`), kept a live one, or left the chat at rest. */ onAttached: ( attached: AttachedJournal, acquisitionGeneration: string | null, - owner: AttachedOwner, - providerChildPhase: StructuredAgentSessionProviderChildPhase + owner: AttachedOwner ) => Promise | void /** Host-owned provider sink, bound to the journal inside `onAttached`. */ eventSink?: StructuredAgentSessionEventSink @@ -137,7 +130,6 @@ export async function performAttach( let record: AgentSessionRecord let acquisitionGeneration: string | null = null let owner: AttachedOwner = 'kept' - let providerChildPhase: StructuredAgentSessionProviderChildPhase = 'ready' let reservedRecord: AgentSessionRecord | null = null let unsupportedReservationSettlementAttempted = false let replayed = false @@ -213,7 +205,6 @@ export async function performAttach( ) record = acquired.record acquisitionGeneration = acquired.acquisitionGeneration - providerChildPhase = acquired.providerChildPhase owner = 'acquired' } } catch (error) { @@ -275,7 +266,7 @@ export async function performAttach( providerHistoryWindow }) await importAdoptedTranscript(params, attached, record, preparedTranscript.items) - await input.onAttached(attached, acquisitionGeneration, owner, providerChildPhase) + await input.onAttached(attached, acquisitionGeneration, owner) await store.recordOperationOutcome({ callerKey: input.callerKey, operationId: params.envelope.clientOperationId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index bde49559a843..2a6bcb7570c8 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 @@ -200,8 +200,6 @@ async function runAttachUnderAbort( now: () => context.now(), recordPhase, acquireSignal, - onStartCatalogListing: (listing) => - context.deps.modelCatalog?.recordLiveListing(sessionId, listing), onStartupAttempt: (startup) => { attempt.startup = startup return startupAttempts.track(sessionId, startup) @@ -220,7 +218,7 @@ async function runAttachUnderAbort( // The cleanup released the acquisition, which for a re-attach is the live child itself. onAcquisitionReleased: (cause, verdict) => endStructuredAgentSessionReleasedChild(context, sessionId, cause, verdict), - onAttached: async (attached, acquisitionGeneration, owner, providerChildPhase) => { + onAttached: async (attached, acquisitionGeneration, owner) => { const fence = structuredAgentSessionConversationFence(context.deps.store, sessionId) const current = context.sessions.get(sessionId)?.child ?? null const startedFor = owner === 'acquired' ? options.startedFor : current?.startedFor @@ -254,15 +252,15 @@ async function runAttachUnderAbort( 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'), + // A spawned child starts unready; a re-attach keeps what its child already proved. + phase: owner === 'acquired' ? 'starting' : (current?.phase ?? 'ready'), ...(startedFor === undefined ? {} : { startedFor }), ...(permissionMode === undefined ? {} : { permissionMode }) } } } // Only a proven child has the protocol session recovery asks; a starting one recovers in - // its `started` step, before it is handed anything. + // its open step, before it is handed anything. if (attempt.candidate?.child.phase === 'ready') { await recoverStructuredRewind( context.deps, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index 2ea3a16024be..50a341d8b33f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -112,7 +112,7 @@ describe('Claude root-exit stop', () => { journal, params, child: { - generation: acquisition.acquisitionGeneration ?? null, + generation: acquisition.acquisitionGeneration, fence, phase: 'ready' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts deleted file mode 100644 index 53dcb9d420f7..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -// Stopping an agent and closing its conversation's handle are two things, and a reader, a session -// list and a send each see only the one that happened. Ticks are driven by hand. - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import { readStructuredSessionGateFacts } from '../../runtime/orchestration/structured-mailbox-pointer-host' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import { setStructuredAgentSessionHost } from './structured-agent-session-registry' -import { - collectSubscriber, - createRestTestRig, - foundRestTestChat, - IDLE_MS, - readerSaw, - REST_TEST_CALLER as CALLER, - REST_TEST_SESSION as SESSION, - restTestSend, - sweepOnce, - type RestTestRig -} from './structured-agent-session-rest-test-rig' -import { hostTestAttachParams } from './structured-agent-session-host-test-data' - -let rig: RestTestRig - -const COLD_START = { timeout: 10_000 } - -beforeEach(async () => { - rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) - setStructuredAgentSessionHost(rig.host) -}) - -afterEach(async () => { - setStructuredAgentSessionHost(null) - await rig.dispose() -}) - -function fence(): number { - return rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 -} - -function openSession(): StructuredAgentSessionHostSession | undefined { - return rig.host.collaboratorsForTests().sessions.get(SESSION) -} - -function statusRows(): AgentSessionStatusSummary[] { - return rig.statusEvents - .flatMap((event) => (event.type === 'status' ? [event.session] : [])) - .filter((summary) => summary.sessionId === SESSION) -} - -describe('a stop that fails', () => { - it('is retried on the next tick (P2-11 a)', async () => { - await foundRestTestChat(rig) - rig.adapter.closeSession.mockResolvedValueOnce(false) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - // The child stays on record with its close unproven, and the next tick joins it. - expect(openSession()?.child?.close).toMatchObject({ cause: 'evict' }) - await sweepOnce(rig.host) - - expect(rig.adapter.closeSession).toHaveBeenCalledTimes(2) - expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - expect(statusRows().at(-1)?.hostExecutionOwned).toBeUndefined() - }) - - it('re-derives a release its wind-down could not write before the handle closes (P2-11 b)', async () => { - await foundRestTestChat(rig) - rig.clock.now += IDLE_MS + 1 - // The child is proven gone and its work settled, then handing the lease back fails once. - const transition = vi - .spyOn(rig.store, 'transitionHandoff') - .mockRejectedValueOnce(new Error('store unavailable')) - - await sweepOnce(rig.host) - - // Reported, never retried as a stop: the handle's close writes the release from the proof. - expect(transition).toHaveBeenCalledTimes(2) - expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - expect(rig.adapter.closeSession).toHaveBeenCalledOnce() - expect(rig.host.hasSession(SESSION)).toBe(false) - }) -}) - -describe('closing the handle', () => { - it('keeps a tabbed chat listed and its reader open, and a send reopens it for that reader (P2-14)', async () => { - await foundRestTestChat(rig) - const reader = collectSubscriber() - await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - expect(rig.host.hasSession(SESSION)).toBe(false) - // The row stays in the agent-status store, idle and no longer running here, and a session - // list that connects now still gets it. - expect(rig.sink.forget).not.toHaveBeenCalled() - expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION }) - expect(statusRows().at(-1)?.hostExecutionOwned).toBeUndefined() - const late: { type: string; sessions?: AgentSessionStatusSummary[] }[] = [] - rig.host.subscribeStatus({ id: 'late', emit: (event) => late.push(event) }) - expect(late[0]?.sessions?.map((summary) => summary.sessionId)).toContain(SESSION) - expect(reader.events.some((event) => event.type === 'end')).toBe(false) - - const sent = await rig.host.send(CALLER, restTestSend('after the close', fence())) - expect(sent.ok).toBe(true) - // A cold start: reopen, reconcile and acquire. - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), COLD_START) - // The reader opened on the first handle is fed by the second. - await vi.waitFor(() => expect(readerSaw(reader.events).texts).toContain('after the close')) - }) - - it('drops the agent-status row of a chat whose tab is gone (P2-14, retired)', async () => { - await foundRestTestChat(rig) - await rig.store.setSessionTabVisibility(SESSION, false) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - expect(rig.host.hasSession(SESSION)).toBe(false) - expect(rig.sink.forget).toHaveBeenCalled() - }) - - it('forgets the row of a resting chat once its tab closes (P2-32)', async () => { - await foundRestTestChat(rig) - rig.clock.now += IDLE_MS + 1 - await sweepOnce(rig.host) - expect(rig.sink.forget).not.toHaveBeenCalled() - - await rig.host.setSessionTabVisibility(SESSION, false) - expect(rig.sink.forget).toHaveBeenCalled() - }) - - it('keeps the row when a chat with an open tab is evicted, and forgets it once the tab closes', async () => { - await foundRestTestChat(rig) - - await rig.host.close(SESSION, 'evict') - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) - // The stop says not-running; the row belongs to the tab, so nothing forgets it. - expect(rig.sink.forget).not.toHaveBeenCalled() - expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION }) - expect(rig.sink.publish.mock.calls.at(-1)?.[0].hostExecutionOwned).toBeUndefined() - - await rig.host.setSessionTabVisibility(SESSION, false) - expect(rig.sink.forget).toHaveBeenCalledOnce() - }) - - it('never hands a reader a handle it is closing (P2-25)', async () => { - await foundRestTestChat(rig) - const journal = openSession()?.journal - if (!journal) { - throw new Error('the conversation should be open') - } - const closing = Promise.withResolvers() - const gate = Promise.withResolvers() - const close = journal.close.bind(journal) - vi.spyOn(journal, 'close').mockImplementation(async () => { - await close() - closing.resolve() - await gate.promise - }) - rig.clock.now += IDLE_MS + 1 - const tick = sweepOnce(rig.host) - await closing.promise - - const history = rig.host.history({ sessionId: SESSION, direction: 'tail' }) - const facts = readStructuredSessionGateFacts(SESSION) - gate.resolve() - await tick - - const page = await history - expect(page.ok && page.page.items.length).toBeGreaterThan(0) - await expect(facts).resolves.not.toBeNull() - // And the handle a write finds is a live one. - const sent = await rig.host.send(CALLER, restTestSend('after the close', fence())) - expect(sent.ok).toBe(true) - // A cold start: reopen, reconcile and acquire. - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), COLD_START) - }) - - it('never re-enters the session lock: a send and a Stop right after still finish (P2-28)', async () => { - await foundRestTestChat(rig) - rig.clock.now += IDLE_MS + 1 - const tick = sweepOnce(rig.host) - const within = (work: Promise) => - Promise.race([ - work, - new Promise((_, reject) => setTimeout(() => reject(new Error('wedged')), 2_000)) - ]) - const sent = within(rig.host.send(CALLER, restTestSend('right after', fence()))) - - await within(tick) - expect(await sent).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) - await expect(within(sweepOnce(rig.host))).resolves.toBeUndefined() - }) -}) - -describe('the sweep and the lease (P2-20)', () => { - it('reads only open conversations and never probes or resolves a lease', async () => { - await foundRestTestChat(rig) - const probeOwner = vi.fn(async () => ({ outcome: 'pid-absent' as const })) - await rig.restart({ probeOwner }) - setStructuredAgentSessionHost(rig.host) - await rig.store.transitionHandoff(SESSION, (current) => ({ - ...current, - lease: { ...current.lease, handoffStage: 'recovering' } - })) - const resolveRecovery = vi.spyOn( - rig.host.collaboratorsForTests().runtimeState, - 'resolveRecovery' - ) - // Open, at rest, on a lease still recovering. - await rig.host.journalSnapshot(SESSION) - expect(rig.host.hasSession(SESSION)).toBe(true) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - expect(rig.host.hasSession(SESSION)).toBe(false) - expect(probeOwner).not.toHaveBeenCalled() - expect(resolveRecovery).not.toHaveBeenCalled() - }) -}) - -describe('a start that never finishes (P2-15)', () => { - it('is stopped by the sweep, and the message held for it carries its one row and rejection', async () => { - const stopReason = 'Codex never finished starting, so Orca stopped it.' - const spawn = rig.adapter.acquire.getMockImplementation()! - rig.adapter.acquire.mockImplementationOnce(rig.heldStart(spawn)) - // Held for the starting child, which never proves its start. - const reader = collectSubscriber() - const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) - expect(attached.ok).toBe(true) - await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) - const sent = await rig.host.send(CALLER, restTestSend('stuck behind the start', fence())) - expect(sent.ok).toBe(true) - await vi.waitFor(() => - expect(rig.host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') - ) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) - await vi.waitFor(() => - expect(readerSaw(reader.events).submissions).toContainEqual( - expect.objectContaining({ dispatchState: 'rejected', reason: stopReason }) - ) - ) - const errorRows = reader.events.flatMap((event) => - event.type === 'batch' - ? event.batch.items.filter( - (item) => item.body.kind === 'status' && item.body.tone === 'error' - ) - : [] - ) - expect(errorRows.map((item) => (item.body.kind === 'status' ? item.body.text : null))).toEqual([ - stopReason - ]) - - const again = await rig.host.send(CALLER, restTestSend('try again', fence())) - expect(again.ok).toBe(true) - expect(rig.adapter.dispatch).not.toHaveBeenCalled() - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(1), COLD_START) - await expect(sweepOnce(rig.host)).resolves.toBeUndefined() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index cd9ecc7e7418..d510c14e2b56 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -168,6 +168,9 @@ function openHost(overrides: Partial): void { logger: createStructuredAgentSessionLogger(), store, adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(), dispatch: vi.fn(), cancelTurn: vi.fn(), @@ -431,6 +434,9 @@ describe('a turn a read reached before the reconcile proved its owner dead', () const acquire = vi.fn() openHost({ adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, dispatch: vi.fn(), cancelTurn: vi.fn(), @@ -473,6 +479,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process, link: { linkId: `claude-${fence}-link`, @@ -486,6 +493,9 @@ describe('a turn a read reached before the reconcile proved its owner dead', () ) openHost({ adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, dispatch: vi.fn(), cancelTurn: vi.fn(), @@ -552,6 +562,7 @@ async function hostWithFailingFirstStart(failure: Error) { throw failure } return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process, link: { linkId: `claude-${fence}-link`, @@ -565,6 +576,9 @@ async function hostWithFailingFirstStart(failure: Error) { ) openHost({ adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, releaseAcquisition: async () => true, dispatch: vi.fn(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 0ce12f92dad5..91b24f82f0da 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 @@ -31,9 +31,9 @@ import type { } from './structured-agent-session-host-types' import { oldestQueuedSubmission, - startThatFailedWhileQueued, recordStructuredAgentSessionStartFailure, - refusedStructuredAgentSessionStart + refusedStructuredAgentSessionStart, + startThatFailedWhileQueued } from './structured-agent-session-start-failure-row' import { handOverSubmission } from './structured-agent-session-turns' import { structuredAgentSessionNextHandover } from './structured-agent-session-opening-send' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts deleted file mode 100644 index bbcadedabfef..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -// A record this build cannot drive (its agent's definition now names another transport or account -// variable, or the record pins another agent's variable) stays readable; only starting its agent is -// refused, at the one launch admission every agent passes through. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionAccountHome } from '../../../shared/agent-session-record' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { - attachFingerprintFields, - type AgentSessionAttachParams -} from './structured-agent-session-attach' -import { performAttach } from './structured-agent-session-attach-flow' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import type { StructuredAgentDefinition } from './structured-agent-definition' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { StructuredAgentRegistry } from './structured-agent-registry' -import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' -import { StructuredAgentSessionHost } from './structured-agent-session-host' - -const NOW = 1_800_000_000_000 -const SESSION = 'grok-session' -const GROK_HOME: AgentSessionAccountHome = { variable: 'GROK_HOME', path: '/home/dev/.grok' } -let root: string | null = null - -afterEach(async () => { - if (root) { - await rm(root, { recursive: true, force: true }) - } - root = null -}) - -const GROK: StructuredAgentDefinition = { - ...CODEX_STRUCTURED_AGENT, - agent: 'grok', - handleTransport: 'acp', - accountHomeVariable: 'GROK_HOME', - capabilities: { - ...CODEX_STRUCTURED_AGENT.capabilities, - rewind: false, - compact: false, - threadGoal: false - }, - restingOptions: { - acceptsKey: () => false, - fallbackModels: () => null, - effortDefaultsToModel: false - } -} - -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only the methods a declaration needs; this one declares none of them. -const NO_METHODS = {} as StructuredAgentSessionAdapter -const grokRegistered = (definition: StructuredAgentDefinition = GROK) => - new StructuredAgentRegistry([{ definition, adapter: NO_METHODS }]) - -function params( - operation: string, - expectedRuntimeFence: number | null, - accountHome = GROK_HOME -): AgentSessionAttachParams { - const attach: AgentSessionAttachParams = { - envelope: { - sessionId: SESSION, - clientOperationId: `${NOW}-${operation.padStart(32, '0')}`, - expectedRuntimeFence, - payloadFingerprint: '' - }, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'grok', - agent: 'grok', - accountHome, - runtimeKind: 'native' - } - return { - ...attach, - envelope: { - ...attach.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(attach) - }) - } - } -} - -function grokAdapter(): StructuredAgentSessionAdapter { - return { - supportsLocation: () => true, - acquire: vi - .fn() - .mockImplementation(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, - link: { - linkId: `grok-link-${fence}`, - handle: { transport: 'acp', agent: 'grok', nativeId: 'grok-native-1' }, - origin: fence === 1 ? 'created' : 'resumed', - mintedAtFence: fence, - observedAt: NOW - } - })), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } -} - -async function attach(input: { - agents: StructuredAgentRegistry - adapter: StructuredAgentSessionAdapter - params: AgentSessionAttachParams - spawnToken: string - store?: AgentSessionRecordStore -}) { - const store = input.store ?? (await openTestAgentSessionRecordStore(root!)) - const result = await performAttach({ - agents: input.agents, - logger: createStructuredAgentSessionLogger(), - store, - adapter: input.adapter, - openConversation: async (record) => { - const journal = await openTestAttachConversation(openTestJournalHostDatabase(root!))(record) - return journal - }, - authority: { - spawnToken: input.spawnToken, - claimKeyId: 'key-1', - handoffOperationId: input.params.envelope.clientOperationId, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: input.params, - now: () => NOW, - onAttached: async (attached) => { - await attached.journal.close() - } - }) - return { result, store } -} - -/** Persist and reopen a Grok chat whose original child is gone. */ -async function createdGrokChat() { - root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) - const created = await attach({ - agents: grokRegistered(), - adapter: grokAdapter(), - params: params('1', null), - spawnToken: 'spawn-a' - }) - expect(created.result).toMatchObject({ ok: true }) - const store = await openTestAgentSessionRecordStore(root) - await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW + 1 }) - return { store, fence: store.getRecord(SESSION)!.lease.runtimeFence } -} - -const UNSUPPORTED = { - ok: false, - refusal: { code: 'structured_agent_session_unsupported', details: { reason: 'hostUnsupported' } } -} - -it('refuses an unregistered agent before creating a durable record', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) - const adapter = grokAdapter() - const { result, store } = await attach({ - agents: new StructuredAgentRegistry([]), - adapter, - params: params('1', null), - spawnToken: 'spawn-unregistered' - }) - expect(result).toMatchObject(UNSUPPORTED) - expect(adapter.acquire).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)).toBeNull() -}) - -it('keeps saved history readable without a registration and resumes when it returns', async () => { - const created = await createdGrokChat() - await created.store.setSessionTabVisibility(SESSION, true) - const journal = await openTestAttachConversation(openTestJournalHostDatabase(root!))( - created.store.getRecord(SESSION)! - ) - await journal.appendItem( - { provider: 'orca', clientMessageId: 'saved-reply' }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Saved reply' }] }, - { fence: created.fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.close() - - const store = await openTestAgentSessionRecordStore(root!) - const agents = new StructuredAgentRegistry([]) - const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) - const acquire = vi.spyOn(router, 'acquire') - const host = new StructuredAgentSessionHost({ - store, - agents, - adapter: router, - journalDatabase: openTestJournalHostDatabase(root!), - claimKeyId: 'key-1', - probeOwner: async () => ({ outcome: 'pid-absent' }), - logger: createStructuredAgentSessionLogger(), - now: () => NOW + 2 - }) - try { - expect(router.supportsCreate(params('2', created.fence).location, 'grok')).toBe(false) - expect(store.listVisibleSessionIds()).toEqual([SESSION]) - await host.restoreReadableSessions([SESSION]) - expect(host.hasSession(SESSION)).toBe(true) - await expect(host.revealSession(SESSION)).resolves.toMatchObject({ - sessionId: SESSION, - agent: 'grok', - readable: true - }) - const history = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(history).toMatchObject({ - ok: true, - page: { - items: expect.arrayContaining([ - expect.objectContaining({ - body: { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'Saved reply' }] - } - }) - ]) - } - }) - const before = store.getRecord(SESSION)!.lease.runtimeFence - await expect( - host.attach({ callerKey: 'client-1' }, params('2', before)) - ).resolves.toMatchObject(UNSUPPORTED) - expect(acquire).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)!.lease.runtimeFence).toBe(before) - } finally { - await host.flushAllStreamedEvents() - } - - const adapter = grokAdapter() - const { result } = await attach({ - agents: grokRegistered(), - adapter, - params: params('3', store.getRecord(SESSION)!.lease.runtimeFence), - spawnToken: 'spawn-restored', - store - }) - expect(result).toMatchObject({ ok: true }) - expect(adapter.acquire).toHaveBeenCalledOnce() - expect(store.listVisibleSessionIds()).toEqual([SESSION]) -}) - -it.each([ - ['its transport', { ...GROK, handleTransport: 'grok-native' }], - ['its account variable', { ...GROK, accountHomeVariable: 'GROK_CONFIG_DIR' }] -])( - 'keeps a chat readable after its agent changes %s, and refuses to start it', - async (_change, changed) => { - const { store, fence } = await createdGrokChat() - const adapter = grokAdapter() - - const { result } = await attach({ - agents: grokRegistered(changed), - adapter, - params: params('2', fence), - spawnToken: 'spawn-b', - store - }) - - expect(result).toMatchObject(UNSUPPORTED) - expect(adapter.acquire).not.toHaveBeenCalled() - // Still listed and readable: its tab publishes and its history opens. - expect(store.getRecord(SESSION)).toMatchObject({ provider: 'grok', accountHome: GROK_HOME }) - expect(store.getRecord(SESSION)?.providerHandleChain[0]?.handle.transport).toBe('acp') - } -) - -it("refuses to start a chat that pins another agent's account variable", async () => { - root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) - const adapter = grokAdapter() - - const { result } = await attach({ - agents: grokRegistered(), - adapter, - params: params('1', null, { variable: 'CODEX_HOME', path: '/home/dev/.codex' }), - spawnToken: 'spawn-a' - }) - - expect(result).toMatchObject(UNSUPPORTED) - expect(adapter.acquire).not.toHaveBeenCalled() -}) - -it('starts the same chat again once its agent is declared as it was', async () => { - const { store, fence } = await createdGrokChat() - const adapter = grokAdapter() - - const { result } = await attach({ - agents: grokRegistered(), - adapter, - params: params('2', fence), - spawnToken: 'spawn-b', - store - }) - - expect(result).toMatchObject({ ok: true }) - expect(adapter.acquire).toHaveBeenCalledOnce() -}) - -it('refuses an attach whose agent is not the session it names', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) - const adapter = grokAdapter() - const mismatched = { ...params('1', null), agent: 'codex' } - - const { result, store } = await attach({ - agents: grokRegistered(), - adapter, - params: { - ...mismatched, - envelope: { - ...mismatched.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(mismatched) - }) - } - }, - spawnToken: 'spawn-a' - }) - - expect(result).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid', details: { reason: 'requestMalformed' } } - }) - expect(adapter.acquire).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)).toBeNull() -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 141afd9406eb..513e64804247 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -10,10 +10,7 @@ import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' -import { - settleStructuredAgentSessionOptionsReported, - settleStructuredAgentSessionProviderStarted -} from './structured-agent-session-provider-started' +import { settleStructuredAgentSessionOptionsReported } from './structured-agent-session-provider-started' import { endExitedStructuredAgentSessionChildUnderSerialize, settleStructuredAgentSessionChildExit, @@ -132,9 +129,6 @@ export class StructuredAgentSessionEventRecovery { * nothing restarts the child. The next send does, through the delivery loop, which also owns any * message still queued. */ async handle(event: StructuredAgentSessionLifecycleEvent): Promise { - if (event.type === 'started') { - return settleStructuredAgentSessionProviderStarted(this.context, event) - } if (event.type === 'options-reported') { return settleStructuredAgentSessionOptionsReported(this.context, event) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index a469af94120e..00ea74c46541 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -43,6 +43,7 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-failed-create-verdict-')) resetHostTestOperationIds() acquire = vi.fn(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -58,6 +59,9 @@ beforeEach(async () => { logger: createStructuredAgentSessionLogger(), store, adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, releaseAcquisition: vi.fn(async () => true), dispatch: vi.fn(async () => ({ state: 'admitted' as const })), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts deleted file mode 100644 index e510916b0973..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -// A child that dies between spawn and journal attach can still write through the host's event -// sink, which attach unbound and never re-bound. That queue must die with the failed create, or -// the next attach's drain barrier and shutdown's flush wait on it forever. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { - AgentSessionPreSpawnError, - type StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } -const EXIT_REASON = 'claude stream-json exited (code 1): claude: not signed in' - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-failed-create-sink-')) - resetHostTestOperationIds() - acquire = vi.fn(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - // A resume continues the chain the first start created. - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW - }, - acquisitionGeneration: `generation-${fence}` - })) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: { - acquire, - releaseAcquisition: vi.fn(async () => true), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) -}) - -afterEach(async () => { - await rm(root, { recursive: true, force: true }) -}) - -describe('a create that fails after its child wrote through the unbound sink', () => { - it.each([ - // The common failed start: answered as a refusal. - ['refused', new Error(EXIT_REASON)], - // A failure the attach cannot classify still throws, and must release the sink too. - ['thrown', new AgentSessionPreSpawnError(new Error(EXIT_REASON))] - ])( - 'releases the sink when %s, so a new create and shutdown both proceed', - async (_how, cause) => { - acquire.mockImplementationOnce(async ({ events }) => { - // The published child's exit reached the translator before any journal was attached. - events?.setActivity?.(null) - throw cause - }) - - const failed = host.attach(CALLER, hostTestAttachParams(null)) - await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") - : expect(failed).resolves.toMatchObject({ - ok: false, - refusal: { message: "Codex couldn't restart. Send your message to try again." } - })) - - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: true - }) - await expect(host.flushAllStreamedEvents()).resolves.toBeUndefined() - expect(acquire).toHaveBeenCalledTimes(2) - } - ) - - it.each([ - ['refused', new Error(EXIT_REASON)], - ['thrown', new AgentSessionPreSpawnError(new Error(EXIT_REASON))] - ])('releases the sink when a resume of a still-indexed session is %s', async (_how, cause) => { - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: true - }) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: `generation-${exitedFence}` - }) - const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - acquire.mockImplementationOnce(async ({ events }) => { - events?.setActivity?.(null) - throw cause - }) - - // The session stays indexed across this failure: it is a resume, not a create. - const failed = host.attach(CALLER, hostTestAttachParams(releasedFence)) - await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") - : expect(failed).resolves.toMatchObject({ ok: false })) - - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await expect(host.attach(CALLER, hostTestAttachParams(fence))).resolves.toMatchObject({ - ok: true - }) - expect(acquire).toHaveBeenCalledTimes(3) - // Only the adopted child's sink still takes writes: the failed attempt's closed with it, and - // the exited generation's closed when the resume replaced it. - const [exited, failedAttempt, resumed] = acquire.mock.calls.map(([input]) => input.events) - expect(failedAttempt?.tryPublish?.()).toEqual({ accepted: false, reason: 'closed' }) - expect(exited?.tryPublish?.()).toEqual({ accepted: false, reason: 'closed' }) - expect(resumed?.tryPublish?.()).toEqual({ accepted: true }) - await expect(host.flushAllStreamedEvents()).resolves.toBeUndefined() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts deleted file mode 100644 index f308b7903983..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import { encodeAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { - AgentSessionDispatchOutcome, - StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } - -function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -const attachParams = (): AgentSessionAttachParams => hostTestAttachParams(null) - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let answerPrompt: Mock -let ordinal = 0 - -function adapter(): StructuredAgentSessionAdapter { - const dispatch = vi.fn(async (): Promise => { - ordinal += 1 - return { - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal } - } - }) - return { - acquire, - releaseAcquisition: vi.fn(async () => true), - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt, - setOption: vi.fn(async () => undefined) - } -} - -async function seedGroupedQuestion(): Promise<{ itemId: string; revision: number }> { - const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 100 } - const events = acquire.mock.calls.at(-1)?.[0].events - if (!events) { - throw new Error('seedGroupedQuestion requires an acquired session') - } - events.appendItem( - identity, - { - kind: 'question', - question: '2 grouped questions from Claude', - options: [], - questions: [ - { - id: 'q1', - question: 'Targets', - multiSelect: true, - options: [ - { id: 'target-web', label: 'Web' }, - { id: 'target-mobile', label: 'Mobile' } - ] - }, - { - id: 'q2', - question: 'Host', - multiSelect: false, - options: [], - freeTextQuestionId: 'q2' - } - ], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await host.flushStreamedEvents(SESSION) - const itemId = agentJournalItemKey(identity) - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - const appended = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null - if (!appended) { - throw new Error('provider question was not written to the journal') - } - return { itemId, revision: appended.revision } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-wire-grouped-')) - resetHostTestOperationIds() - ordinal = 0 - acquire = vi.fn(async ({ fence }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW - } - })) - answerPrompt = vi.fn(async ({ commit }) => commit()) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -describe('grouped question admission', () => { - it('reads the packed answer an older client sends into structured answers', async () => { - const attached = await host.attach(CALLER, attachParams()) - expect(attached.ok).toBe(true) - const prompt = await seedGroupedQuestion() - const answers = [ - { questionId: 'q1', optionIds: ['target-web', 'target-mobile'] }, - { questionId: 'q2', optionIds: [], other: 'SSH host' } - ] - const optionId = encodeAgentSessionQuestionAnswers(answers) - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId } - const result = await host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:question', fields), - kind: 'question', - ...fields - }) - expect(result).toMatchObject({ - ok: true, - value: { resolution: { state: 'resolved', selectedOptionId: optionId, answers } } - }) - expect(answerPrompt).toHaveBeenCalledWith( - expect.objectContaining({ itemId: prompt.itemId, response: { kind: 'answers', answers } }) - ) - }) - - it('takes structured answers past the old option-id bound and keeps the packed form for older readers', async () => { - const attached = await host.attach(CALLER, attachParams()) - expect(attached.ok).toBe(true) - const prompt = await seedGroupedQuestion() - const typed = 'Proceed with the replacement, but wait for the capture. '.repeat(40) - const answers = [ - { questionId: 'q1', optionIds: ['target-web'] }, - { questionId: 'q2', optionIds: [], other: typed } - ] - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, answers } - const result = await host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:question', fields), - kind: 'question', - ...fields - }) - - expect(typed.length).toBeGreaterThan(1024) - expect(result).toMatchObject({ - ok: true, - value: { - resolution: { - state: 'resolved', - selectedOptionId: encodeAgentSessionQuestionAnswers(answers), - answers - } - } - }) - expect(answerPrompt).toHaveBeenCalledWith( - expect.objectContaining({ response: { kind: 'answers', answers } }) - ) - }) - - it('refuses answers that do not match the questions without reaching the provider', async () => { - const attached = await host.attach(CALLER, attachParams()) - expect(attached.ok).toBe(true) - const prompt = await seedGroupedQuestion() - const answers = [{ questionId: 'q1', optionIds: ['target-web'] }] - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, answers } - const result = await host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:question', fields), - kind: 'question', - ...fields - }) - - expect(result).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid' } - }) - expect(answerPrompt).not.toHaveBeenCalled() - }) -}) - -describe('a grouped question answered again', () => { - const answers = [ - { questionId: 'q1', optionIds: ['target-web'] }, - { questionId: 'q2', optionIds: [], other: 'SSH host' } - ] - - async function answer( - prompt: { itemId: string; revision: number }, - given: typeof answers - ): ReturnType { - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, answers: given } - return host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:question', fields), - kind: 'question', - ...fields - }) - } - - it('answers a re-click with the same answers from the resolution it holds', async () => { - expect((await host.attach(CALLER, attachParams())).ok).toBe(true) - const prompt = await seedGroupedQuestion() - const first = await answer(prompt, answers) - - // The reply to the first was lost, so the re-click still names the revision it saw. - const again = await answer(prompt, answers) - - expect(first.ok).toBe(true) - expect(again).toEqual({ ...first, replayed: false }) - expect(answerPrompt).toHaveBeenCalledTimes(1) - }) - - it('refuses different answers to a resolved question as moved on', async () => { - expect((await host.attach(CALLER, attachParams())).ok).toBe(true) - const prompt = await seedGroupedQuestion() - await answer(prompt, answers) - - const different = await answer(prompt, [ - { questionId: 'q1', optionIds: ['target-mobile'] }, - { questionId: 'q2', optionIds: [], other: 'SSH host' } - ]) - - expect(different).toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_item_revision_stale', - details: { reason: 'promptMoved' }, - resolution: { answers } - } - }) - expect(answerPrompt).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts index ebcd15553a78..969a3346c1b2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts @@ -1,64 +1,52 @@ -// A fake adapter's children prove their start once published, except those a test holds `starting`. +// Fake protocol opens use the same awaited host path as production adapters. -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionLifecycleEvent -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' type Acquire = StructuredAgentSessionAdapter['acquire'] +type Open = StructuredAgentSessionAdapter['open'] export type StructuredAgentSessionHeldStarts = { - /** Wraps an acquire so the child it answers stays `starting`. */ held(acquire: Acquire): Acquire - /** Wraps the adapter's acquire: every child not held proves its start once published. */ - wrap(adapter: A): A - /** Answers the pending protocol open. */ + wrap(adapter: A): A & StructuredAgentSessionAdapter prove(child: { sessionId: string; fence: number; acquisitionGeneration: string }): Promise clear(): void } export function structuredAgentSessionHeldStarts( - _host: () => { handleAdapterEvent(event: StructuredAgentSessionLifecycleEvent): Promise } + _host: () => unknown ): StructuredAgentSessionHeldStarts { - const heldGenerations = new Map>() - let generations = 0 + const held = new Map void>() + const waiting = new Map>() const prove: StructuredAgentSessionHeldStarts['prove'] = async (child) => { - heldGenerations.get(child.acquisitionGeneration)?.resolve() + held.get(child.acquisitionGeneration)?.() await Promise.resolve() } return { held: (acquire) => async (input) => { const acquired = await acquire(input) - const acquisitionGeneration = acquired.acquisitionGeneration ?? `held-start-${++generations}` - heldGenerations.set(acquisitionGeneration, Promise.withResolvers()) - return { ...acquired, acquisitionGeneration, providerChildPhase: 'starting' as const } + const generation = acquired.acquisitionGeneration + waiting.set(generation, new Promise((resolve) => held.set(generation, resolve))) + return acquired }, wrap: (adapter) => { - const acquire: Acquire = async (input) => { - const acquired = await adapter.acquire(input) - const acquisitionGeneration = - acquired.acquisitionGeneration ?? `instant-start-${++generations}` - return { ...acquired, acquisitionGeneration, providerChildPhase: 'starting' as const } - } - const open: NonNullable = async (input) => { - if (input.acquisitionGeneration) { - await heldGenerations.get(input.acquisitionGeneration)?.promise - heldGenerations.delete(input.acquisitionGeneration) + const open: Open = async (input) => { + const generation = input.acquisitionGeneration + if (generation) { + await waiting.get(generation) + waiting.delete(generation) + held.delete(generation) } - return adapter.open - ? adapter.open(input) - : { - reportedOptions: { model: '' } - } + return adapter.open(input) } - return { ...adapter, acquire, open } + return { ...adapter, open } }, prove, clear: () => { - for (const held of heldGenerations.values()) { - held.resolve() + for (const resolve of held.values()) { + resolve() } - heldGenerations.clear() + held.clear() + waiting.clear() } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts deleted file mode 100644 index 8669a3c92d24..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { probeAgentSessionProcessIdentity } from '../../runtime/agent-session-process-identity-probe' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { - closeTestJournalHostDatabase, - openTestJournalHostDatabase -} from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams -} from './structured-agent-session-host-test-data' -import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' - -const RENEW_INTERVAL_MS = 10_000 -const roots: string[] = [] - -afterEach(async () => { - vi.useRealTimers() - for (const root of roots) { - closeTestJournalHostDatabase(root) - } - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -/** A real host whose adapter spawned a child it could not read a start time for. */ -async function hostWithStartTimeLessChild(platform: NodeJS.Platform) { - const root = await mkdtemp(join(tmpdir(), 'orca-host-held-renewal-')) - roots.push(root) - const store = await openTestAgentSessionRecordStore(root) - const clock = { now: NOW } - const process = { live: true } - const holdsLiveProviderProcess = vi.fn( - (_sessionId: string, generation: string) => process.live && generation === 'gen-1' - ) - const adapter: StructuredAgentSessionAdapter = { - acquire: async ({ fence }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: null, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - }, - acquisitionGeneration: 'gen-1' - }), - holdsLiveProviderProcess, - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } - // The real PID probe with the pid present: no start time and no token echo to match. - const probeOwner = vi.fn((record: AgentSessionRecord) => - probeAgentSessionProcessIdentity({ - identity: record.lease.ownerProcess!, - deps: { platform, isPidPresent: () => true } - }) - ) - const host = new StructuredAgentSessionHost({ - logger: recordingProductionStructuredAgentSessionLogger().logger, - store, - adapter, - agents: claudeAndCodexDeclared(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - probeOwner, - now: () => clock.now - }) - return { host, store, clock, process, probeOwner, holdsLiveProviderProcess } -} - -describe.each(['darwin', 'win32'] as const)('host lease renewal on %s', (platform) => { - it('renews a held child without a PID probe until the adapter sees its root exit', async () => { - vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) - const { host, store, clock, process, probeOwner, holdsLiveProviderProcess } = - await hostWithStartTimeLessChild(platform) - const attached = await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null)) - expect(attached.ok).toBe(true) - const fence = store.getRecord(SESSION)!.lease.runtimeFence - - clock.now = NOW + RENEW_INTERVAL_MS - await vi.advanceTimersByTimeAsync(RENEW_INTERVAL_MS) - await vi.waitFor(() => - expect(store.getRecord(SESSION)?.lease.lastRenewedAt).toBe(NOW + RENEW_INTERVAL_MS) - ) - expect(probeOwner).not.toHaveBeenCalled() - expect(holdsLiveProviderProcess).toHaveBeenCalledWith(SESSION, 'gen-1') - - // The adapter saw the root exit; the host has not settled it, so its child is still on record. - process.live = false - clock.now = NOW + 2 * RENEW_INTERVAL_MS - await vi.advanceTimersByTimeAsync(RENEW_INTERVAL_MS) - await vi.waitFor(() => expect(probeOwner).toHaveBeenCalledOnce()) - - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(fence) - expect(store.getRecord(SESSION)?.lease.lastRenewedAt).toBe(NOW + RENEW_INTERVAL_MS) - await host.flushAllStreamedEvents() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index c19d875715fe..c42dc0c90091 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 @@ -140,7 +140,7 @@ export function cancelStructuredAgentSessionTurn( const { prompt } = params const stoppedPrePrompt = !prompt && params.turnId === undefined - ? abortAcquireForStop(context, caller, params.envelope, plan) + ? abortProviderWaitForStop(context, caller, params.envelope, plan) : false // A card's Cancel stops whatever the chat has in flight, as the Stop button does; it reaches the // Stop only for a card the live turn raised (`cancelStructuredAgentSessionPrompt`). @@ -160,7 +160,7 @@ export function cancelStructuredAgentSessionTurn( * queue is waiting on stops now, as a close's does, and the Stop's own step then finds no child. * Only a Stop admission would run now; one naming a turn is about a child already gone, so it * leaves a newer start alone. */ -function abortAcquireForStop( +function abortProviderWaitForStop( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, envelope: AgentSessionMutationEnvelope, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts deleted file mode 100644 index f8f9745f8905..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -// The helper is what the restart specs remove their temp directory behind, so what it waits for is -// load-bearing: a store commit that lands afterwards re-creates the directory it just removed. - -import { existsSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } - -describe('abandoning a structured agent-session host', () => { - it('waits for the restart the delivery loop woke for an accepted send', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-abandon-')) - const store = await openTestAgentSessionRecordStore(root) - // Holds the restart inside its provider acquisition, so the point teardown must not run past - // is exact rather than a timing window. - let gate: Promise | null = null - let openGate = (): void => {} - let reportEntered = (): void => {} - const entered = new Promise((resolve) => { - reportEntered = resolve - }) - const adapter: StructuredAgentSessionAdapter = { - acquire: async ({ fence }) => { - if (gate) { - reportEntered() - await gate - } - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW - 1_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - } - }, - dispatch: async () => ({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => undefined, - releaseAcquisition: async () => true, - setOption: async () => undefined - } - const host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter, - probeOwner: async () => ({ - outcome: 'identity-matched', - matchedOn: ['process-start-time'] - }), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - // The conversation stays and its provider child does not, so the next send makes the delivery - // loop start one — the shape the refusal-oracle spec ends on. - await host.close(SESSION, 'evict') - gate = new Promise((resolve) => { - openGate = resolve - }) - - const body = hostTestMessage('delivery loop') - expect( - await host.send(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: `${NOW}-000000000000000000000000000003e9`, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body - }) - ).toMatchObject({ ok: true }) - await entered - - let abandoned = false - const abandoning = abandonStructuredAgentSessionHost(host).then(() => { - abandoned = true - }) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(abandoned).toBe(false) - - openGate() - await abandoning - - // Nothing is left to put the directory back after this returns. - await rm(root, { recursive: true }) - await new Promise((resolve) => setTimeout(resolve, 200)) - expect(existsSync(root)).toBe(false) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index d4fb189ede09..1f02b435bbf1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -87,6 +87,9 @@ function adapter(): StructuredAgentSessionAdapter { return startsWhenPublished( { acquire, + open: async () => ({ + reportedOptions: { model: '' } + }), releaseAcquisition, dispatch: async (input) => { await input.beforeDispatch?.() @@ -139,6 +142,7 @@ beforeEach(async () => { resetHostTestOperationIds() ordinal = 0 acquire = vi.fn(async ({ fence }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 684d9b776f67..6876ade6ddd5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -69,7 +69,7 @@ export type StructuredAgentSessionChildClose = { /** The provider process behind a conversation. Written only in * `structured-agent-session-provider-child`. */ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProviderChildIdentity & { - /** A starting acquisition stays `starting` until the host settles its `started` event; only then is + /** Every acquired child is `starting` until the host settles its open result; only then is * it handed input and are its reported options fact. */ phase: StructuredAgentSessionProviderChildPhase /** The queued message whose delivery started this child, fixed when the start is made; absent diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts index a1a99ce71836..b7c6834e1274 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts @@ -1,19 +1,17 @@ -// Fake children answer the host-awaited protocol open immediately. +// Fake providers publish their generation and return protocol facts from host-awaited open. -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionLifecycleEvent -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' export function startsWhenPublished( adapter: A, - _host: () => { handleAdapterEvent(event: StructuredAgentSessionLifecycleEvent): Promise } -): A { - let generations = 0 + _host: () => unknown +): A & StructuredAgentSessionAdapter { const acquire: StructuredAgentSessionAdapter['acquire'] = async (input) => { const acquired = await adapter.acquire(input) - const acquisitionGeneration = acquired.acquisitionGeneration ?? `instant-start-${++generations}` - return { ...acquired, acquisitionGeneration, providerChildPhase: 'starting' as const } + if (!acquired.acquisitionGeneration) { + throw new Error('test provider did not publish its generation') + } + return acquired } return { ...adapter, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 209260f1406c..e9584c8fe2e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -104,8 +104,11 @@ beforeEach(async () => { store, adapter: startsWhenPublished( { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(async ({ fence }) => ({ - providerChildPhase: 'starting' as const, + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts index cd26b96e5cd1..11bfd5dfdedc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts @@ -23,9 +23,9 @@ let host: StructuredAgentSessionHost function modelAdapterLiveness(registerOnAcquire: boolean): void { const { acquire, dispatch } = hostTestState() const live = new Set() - const spawn = acquire.getMockImplementation()! + const acquireChild = acquire.getMockImplementation()! acquire.mockImplementation(async (input) => { - const acquisition = await spawn(input) + const acquisition = await acquireChild(input) if (registerOnAcquire) { live.add(input.identity.sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index e7a814ec0481..a5b908c4a670 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -51,6 +51,9 @@ function openHost(): void { store, adapter: startsWhenPublished( { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, closeSession: vi.fn(async () => true), releaseAcquisition: vi.fn(async () => true), @@ -129,7 +132,7 @@ beforeEach(async () => { stopOwnerProcess = vi.fn() dispatch = vi.fn(async () => ({ state: 'admitted' as const })) acquire = vi.fn(async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, link: { linkId: `link-${fence}`, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts deleted file mode 100644 index 9b65150cc704..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -// The chat's own Stop and every session list read whether the main agent is working through one -// rule, but over two copies of the host's journal: the chat reduces its stream, and a list reads the -// status feed. Both leave the host from one publication edge, so once each has landed they must say -// the same thing. Against the real host, store, journal, stream and status feed. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemBody -} from '../../../shared/agent-session-journal-types' -import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' -import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' -import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' -import { - EMPTY_STRUCTURED_AGENT_SESSION, - reduceStructuredAgentSession, - type StructuredAgentSessionState -} from '../../../shared/structured-agent-session-reducer' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } -const PROVIDER_ROW = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1' } - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let dispatch: Mock -let events: StructuredAgentSessionEventSink | undefined -/** The session's child records, as the host's store serves them to every surface. */ -let records: AgentChildWorkView[] -let chat: StructuredAgentSessionState -let listed: AgentSessionStatusSummary | undefined - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-main-agent-working-')) - resetHostTestOperationIds() - events = undefined - records = [] - chat = EMPTY_STRUCTURED_AGENT_SESSION - listed = undefined - // Written, and the provider has neither opened a turn for it nor answered it. - dispatch = vi.fn(async () => ({ state: 'admitted' as const })) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: startsWhenPublished( - { - acquire: async (input) => { - events = input.events - return { - providerChildPhase: 'starting' as const, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: input.spawnToken - }, - acquisitionGeneration: 'generation-1', - link: { - linkId: `link-${input.fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created' as const, - mintedAtFence: input.fence, - observedAt: NOW - } - } - }, - dispatch, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - () => host - ), - statusSink: { publish: () => {}, forget: () => {}, readChildWork: () => records }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-1', - now: () => NOW - }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - host.subscribe({ - id: 'chat-1', - sessionId: SESSION, - emit: (event) => { - chat = reduceStructuredAgentSession(chat, { type: 'event', event: structuredClone(event) }) - } - }) - host.subscribeStatus({ - id: 'list-1', - emit: (event) => { - if (event.type === 'status' && event.session.sessionId === SESSION) { - listed = structuredClone(event.session) - } else if (event.type === 'snapshot') { - listed = event.sessions.find((session) => session.sessionId === SESSION) ?? listed - } - } - }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -async function send(text: string): Promise { - const body = hostTestMessage(text) - const clientOperationId = hostTestOperationId() - expect( - await host.send(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId, - expectedRuntimeFence: 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body - }) - ).toMatchObject({ ok: true }) - return clientOperationId -} - -async function provider(ordinal: number, body: AgentJournalItemBody): Promise { - events!.appendItem({ ...PROVIDER_ROW, ordinal }, body, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - events!.publish() - await host.flushStreamedEvents(SESSION) -} - -/** What the chat's Stop reads: the transport state's rule over the chat's reduced stream. */ -function chatReadsWorking(): boolean { - return isStructuredAgentSessionMainAgentWorking( - activeStructuredAgentSessionTurnId(chat.items), - chat.submissions, - chat.fence - ) -} - -/** What a session list's row reads for the main agent, and the whole row, from the status feed. */ -function listReads(): { mainAgent: string; row: string } { - const summary = listed - if (!summary?.status) { - return { mainAgent: 'none', row: 'none' } - } - const status = structuredAgentSessionAgentStatus({ - ...summary, - status: summary.status, - childWork: summary.children - }) - return { mainAgent: status.mainAgent.state, row: status.state } -} - -describe('the main agent read by the chat and by a session list', () => { - it('agrees while a rate-limited request retries with no turn open', async () => { - const id = await send('hello') - await vi.waitFor(async () => - expect((await host.journalSnapshot(SESSION)).submissions[0]?.handedOverAt).toBeDefined() - ) - // What Claude writes for an HTTP 429 retry: a status row, no echo, no turn. - await provider(1, { kind: 'status', text: 'rate_limit', tone: 'error' }) - await provider(2, { kind: 'status', text: 'rate_limit', tone: 'error' }) - - expect(chat.submissions.map((submission) => submission.clientMessageId)).toEqual([id]) - expect(activeStructuredAgentSessionTurnId(chat.items)).toBeNull() - expect(chatReadsWorking()).toBe(true) - expect(listReads().mainAgent).toBe('working') - }) - - it('agrees once the handed-over message is answered and only a subagent still runs', async () => { - dispatch.mockResolvedValueOnce({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } - }) - await send('fan out') - await vi.waitFor(async () => - expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('accepted') - ) - await provider(1, { kind: 'turn', turnId: 'turn-1', state: 'running' }) - expect(chatReadsWorking()).toBe(true) - expect(listReads().mainAgent).toBe('working') - - await provider(1, { kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'success' }) - records = [ - { - id: 'child-1', - providerId: 'task-1', - kind: 'agent', - name: 'deep_review', - state: 'working', - membership: 'live', - firstObservedAt: NOW, - observedAt: NOW, - stoppable: false, - invocation: { invocationId: 'task-1', generation: 1 } - } - ] - host.publishChildWorkEvidence(SESSION, []) - await host.flushStreamedEvents(SESSION) - - expect(chat.backgroundTasks?.children).toHaveLength(1) - // The row reads Working for the subagent; the main agent, and so Stop, does not. - expect(listReads()).toEqual({ mainAgent: 'done', row: 'working' }) - expect(chatReadsWorking()).toBe(false) - }) - - it('agrees when the child that was handed the message exits', async () => { - await send('hello') - await vi.waitFor(async () => - expect((await host.journalSnapshot(SESSION)).submissions[0]?.handedOverAt).toBeDefined() - ) - expect(chatReadsWorking()).toBe(true) - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - fence: store.getRecord(SESSION)!.lease.runtimeFence, - acquisitionGeneration: 'generation-1', - reason: 'codex app-server crashed', - cause: 'unexpected-exit' - }) - await host.flushStreamedEvents(SESSION) - - expect(chat.fence).toBe(store.getRecord(SESSION)!.lease.runtimeFence) - expect(chatReadsWorking()).toBe(false) - expect(listReads().mainAgent).not.toBe('working') - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts deleted file mode 100644 index 7eccfe5f22ba..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { encodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' - -export async function readNativeSessionOptions(input: { - adapter: Pick - sessionId: string - fence: number - priorOptions?: Readonly> -}): Promise> | undefined> { - const { adapter, sessionId, fence, priorOptions } = input - const reported = await adapter.readOptions?.({ sessionId, fence }) - if (!reported) { - return undefined - } - return nativeSessionOptionsFromReport({ - reported: reported.current, - ...(priorOptions ? { priorOptions } : {}) - }) -} - -/** Legacy acquisition facts replace execution choices; unrelated saved launch fields survive. */ -export function nativeSessionOptionsFromReport(input: { - reported: AgentSessionOptionsResult['current'] - priorOptions?: Readonly> - /** Values the child showed it cannot run: its report of the same value is not kept. */ - retired?: Readonly> -}): Readonly> { - const { reported, priorOptions } = input - const restored = priorOptions ? { ...priorOptions } : {} - delete restored.model - delete restored.effort - delete restored.fastMode - delete restored.serviceTier - // A reported tier is the speed itself; the Fast it implies for older clients is not kept. - const fastMode = - reported.fastMode === undefined || reported.serviceTier !== undefined - ? undefined - : encodeStructuredAgentSessionOptionValue('fastMode', reported.fastMode) - const options: Record = { - ...restored, - ...(reported.model ? { model: reported.model } : {}), - ...(reported.effort ? { effort: reported.effort } : {}), - ...(fastMode !== undefined && fastMode !== null ? { fastMode } : {}), - ...(reported.serviceTier ? { serviceTier: reported.serviceTier } : {}) - } - for (const [key, value] of Object.entries(input.retired ?? {})) { - if (options[key] === value) { - delete options[key] - } - } - return options -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts deleted file mode 100644 index df310b364a86..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { - AgentSessionMutationEnvelope, - AgentSessionStatusEvent -} from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } -const DEFAULT_MODEL = 'gpt-default' - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let router: StructuredAgentSessionAdapter -let acquire: Mock -let closeNativeSession: Mock> -let activeModel: string -let activeEffort: string | null -let optionFailure: Error | null -/** What the adapter's closeSession reports about the child's exit. */ -let closeSessionExit = true -const dispatchedModels: string[] = [] - -function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -function adapter(): StructuredAgentSessionAdapter { - acquire = vi.fn(async ({ fence, spawnToken, options, identity }) => { - activeModel = options?.model ?? DEFAULT_MODEL - activeEffort = options?.effort ?? null - const acquisitionGeneration = `generation-${acquire.mock.calls.length}` - // The handshake answers at once, reporting the options the child runs with. - void host.handleAdapterEvent({ - type: 'started', - sessionId: identity.sessionId, - fence, - acquisitionGeneration, - reportedOptions: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) } - }) - return { - providerChildPhase: 'starting' as const, - acquisitionGeneration, - process: { - hostId: 'local', - pid: 4200 + acquire.mock.calls.length, - processStartTimeMs: NOW, - spawnToken - }, - link: { - linkId: `native-link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: acquire.mock.calls.length === 1 ? 'created' : 'resumed', - mintedAtFence: fence, - observedAt: NOW - } - } - }) - closeNativeSession = vi.fn(async () => { - activeModel = DEFAULT_MODEL - return closeSessionExit - }) - return { - supportsLocation: () => true, - acquire, - dispatch: vi.fn(async ({ executionOptions }) => { - dispatchedModels.push(executionOptions?.model ?? activeModel) - return { - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - } - }), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async ({ key, value }) => { - if (optionFailure) { - const error = optionFailure - optionFailure = null - throw error - } - if (key === 'model') { - activeModel = value - } else if (key === 'effort') { - activeEffort = value - } - return { - model: activeModel, - ...(activeEffort ? { effort: activeEffort } : {}) - } - }), - readOptions: vi.fn(async () => ({ - current: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) }, - models: [] - })), - closeSession: closeNativeSession - } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-option-settlement-')) - resetHostTestOperationIds() - activeModel = DEFAULT_MODEL - activeEffort = null - optionFailure = null - closeSessionExit = true - dispatchedModels.length = 0 - const accountHome = join(root, 'codex-home') - store = await openTestAgentSessionRecordStore(root) - router = adapter() - host = new StructuredAgentSessionHost({ - agents: claudeAndCodexDeclared(), - logger: createStructuredAgentSessionLogger(), - store, - adapter: router, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-native', - now: () => NOW - }) - const attached = await host.attach( - CALLER, - hostTestAttachParams(null, { accountHome: { variable: 'CODEX_HOME', path: accountHome } }) - ) - expect(attached).toMatchObject({ ok: true }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -describe('structured session options and close', () => { - it('keeps provider readback out of the saved next-message choice', async () => { - const body = { - kind: 'message' as const, - role: 'user' as const, - blocks: [{ type: 'text' as const, text: 'first task' }] - } - await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - // Accepted, then handed over by the delivery loop; the status is read once it answered. - await vi.waitFor(() => expect(dispatchedModels).toEqual([DEFAULT_MODEL])) - await vi.waitFor(async () => - expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('accepted') - ) - const events: AgentSessionStatusEvent[] = [] - host.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) - expect(events).toEqual([ - { - type: 'snapshot', - sessions: [expect.objectContaining({ status: 'idle', optionSelection: {} })] - } - ]) - expect(events[0]).not.toHaveProperty('sessions.0.model') - expect(store.getRecord(SESSION)?.options).toBeUndefined() - }) - - // Closing a chat used to leave its provider child resident for the whole app session: the host's - // session map had no delete and the only teardown was app quit. - it('stops the provider child and forgets the session when the chat closes', async () => { - expect(host.hasSession(SESSION)).toBe(true) - - await host.close(SESSION, 'evict') - - expect(closeNativeSession).toHaveBeenCalledWith(SESSION) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null, - deathEvidence: { kind: 'exit-observed' } - }) - expect(host.hasSession(SESSION)).toBe(false) - - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(closeNativeSession).toHaveBeenCalledOnce() - }) - - it('is a no-op for a session it does not hold', async () => { - await host.close(SESSION, 'evict') - closeNativeSession.mockClear() - - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(closeNativeSession).not.toHaveBeenCalled() - }) -}) - -it('applies running Codex model and effort picks to the next message', async () => { - await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) - for (const [key, value] of [ - ['model', 'gpt-picked'], - ['effort', 'high'] - ]) { - expect( - await host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', { key, value }), - key, - value - }) - ).toMatchObject({ ok: true }) - } - const body = { - kind: 'message' as const, - role: 'user' as const, - blocks: [{ type: 'text' as const, text: 'with the new choices' }] - } - expect( - await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(dispatchedModels).toEqual(['gpt-picked'])) - expect(vi.mocked(router.dispatch).mock.calls.at(-1)?.[0].executionOptions?.effort).toBe('high') - expect(router.setOption).not.toHaveBeenCalled() -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts deleted file mode 100644 index 2d82658a1bd1..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE -} from '../../../shared/claude-profile-routing' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { mapRuntimeError } from '../../runtime/rpc/errors' -import { - AgentSessionPreSpawnError, - type AgentSessionPreSpawnReason, - type StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { - attachFingerprintFields, - type AgentSessionAttachParams -} from './structured-agent-session-attach' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import { performAttach } from './structured-agent-session-attach-flow' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { StructuredAgentArgumentsError } from '../structured-agent-arguments-error' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-alpha' -const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` -let root: string | null = null - -afterEach(async () => { - vi.restoreAllMocks() - if (root) { - await rm(root, { recursive: true, force: true }) - } - root = null -}) - -function createParams(): AgentSessionAttachParams { - const params: AgentSessionAttachParams = { - envelope: { - sessionId: SESSION, - clientOperationId: OPERATION, - expectedRuntimeFence: null, - payloadFingerprint: '' - }, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - runtimeKind: 'native' - } - return { - ...params, - envelope: { - ...params.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(params) - }) - } - } -} - -/** The thrown first answer as the wire sends it, and the ledger's replay of the same operation. */ -async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { - root = await mkdtemp(join(tmpdir(), 'orca-pre-spawn-first-answer-')) - const store = await openTestAgentSessionRecordStore(root) - const unused = async (): Promise => { - throw new Error('not reached before a spawn') - } - const adapter: StructuredAgentSessionAdapter = { - acquire: async () => { - throw thrown - }, - dispatch: unused, - cancelTurn: unused, - answerPrompt: unused, - setOption: unused - } - const input = { - agents: NO_STRUCTURED_AGENTS, - store, - adapter, - logger: createStructuredAgentSessionLogger(), - journalDatabase: openTestJournalHostDatabase(root), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: OPERATION, - probe: { outcome: 'reservation-unused' as const } - }, - callerKey: 'client-1', - params: createParams(), - now: () => NOW, - onAttached: () => {} - } - const first = await performAttach(input).then( - (result) => result, - (error: unknown) => error - ) - if (first instanceof Error) { - expect(first).toBeInstanceOf(AgentSessionPreSpawnError) - } - return { - first: - first instanceof Error ? mapRuntimeError('req-1', { runtimeId: 'runtime-1' }, first) : first, - replay: await performAttach(input) - } -} - -describe('a create that fails before any process spawns', () => { - it('answers a safe saved Arguments problem on the first call and replay', async () => { - const { first, replay } = await firstAnswerAndReplay( - new AgentSessionPreSpawnError( - new StructuredAgentArgumentsError('Claude', '--model=private', 'multipleValues') - ) - ) - const sentence = - "Claude couldn't start. Saved Arguments give --model more than one value. Edit them in Settings > Agents > Arguments. Send your message to try again." - for (const result of [first, replay]) { - expect(result).toMatchObject({ - ok: false, - refusal: { - message: sentence, - details: { - reason: 'attachFailed', - argumentProblem: { agent: 'Claude', option: '--model', problem: 'multipleValues' } - } - } - }) - } - expect(JSON.stringify([first, replay])).not.toContain('private') - }) - - it.each<[string, string, AgentSessionPreSpawnReason | undefined, string]>([ - [ - 'the managed account env override', - 'This Claude launch defines explicit Anthropic auth environment variables.', - 'managedAccountEnvOverride', - 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' - ], - [ - 'an account switch in progress', - 'A Claude account switch is in progress. Try again after it finishes.', - 'accountSwitchInProgress', - 'A Claude account switch is in progress. Try again after it finishes.' - ], - [ - 'a Claude account added in WSL', - 'structured Claude is not offered under the active managed Claude account', - 'managedAccountUnsupported', - 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings.' - ], - [ - 'a floating chat whose launch folder is gone', - 'the folder this chat ran in no longer exists: /gone/floating', - 'launchFolderMissing', - 'The folder this chat ran in no longer exists. Restore it to continue this chat.' - ], - [ - 'a resume whose transcript is in another Claude account', - 'claude transcript is not in the selected account', - 'historyInOtherAccount', - "This chat's history is in another Claude account. Switch back to that account to continue it." - ], - [ - 'a selected Claude account whose folder is gone', - CLAUDE_PROFILE_MISSING_MESSAGE, - 'claudeAccountFolderMissing', - CLAUDE_PROFILE_MISSING_MESSAGE - ], - [ - 'a selected Claude account that could not be set up', - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, - 'claudeAccountSetupFailed', - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE - ], - [ - 'a Command setting that names no runnable program', - 'the claude Command setting is not a runnable program', - 'agentCommandNotRunnable', - "Claude's Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it." - ], - [ - "Orca's own reason", - 'claude sessions pin CLAUDE_CONFIG_DIR, not CODEX_HOME', - undefined, - "Claude couldn't start. Send your message to try again." - ] - ])('answers %s first in the words its replay reads', async (_, raw, reason, sentence) => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - - const { first, replay } = await firstAnswerAndReplay( - new AgentSessionPreSpawnError(new Error(raw), reason ? { reason } : {}) - ) - - // Thrown as before: the wire code is unchanged, only the words are the replay's. - expect(first).toMatchObject({ ok: false, error: { code: 'runtime_error', message: sentence } }) - expect(replay).toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: sentence, - ...(reason ? { details: { reason } } : {}) - } - }) - if (!reason) { - expect(replay).not.toHaveProperty('refusal.details') - } - if (raw !== sentence) { - expect(JSON.stringify([first, replay])).not.toContain(raw) - } - // What failed is kept for the log. - expect(warn).toHaveBeenCalledWith( - '[agent-session] provider-start: starting the provider failed', - expect.objectContaining({ - scope: 'provider-start', - error: expect.objectContaining({ message: raw }) - }) - ) - }) - - it('keeps a message that is itself a code, and the wire code it maps to', async () => { - const { first } = await firstAnswerAndReplay( - new AgentSessionPreSpawnError(new Error('runtime_unavailable')) - ) - - expect(first).toMatchObject({ - ok: false, - error: { code: 'runtime_unavailable', message: 'runtime_unavailable' } - }) - }) - - it('keeps the situation of a pre-spawn error it wraps', () => { - const typed = new AgentSessionPreSpawnError(new Error('switching'), { - reason: 'accountSwitchInProgress' - }) - - expect(new AgentSessionPreSpawnError(typed).reason).toBe('accountSwitchInProgress') - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index 543abc1e2537..3e76030579a5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -68,6 +68,21 @@ function attachParams( } } +function adapterForAcquire( + acquire: StructuredAgentSessionAdapter['acquire'] +): StructuredAgentSessionAdapter { + return { + open: async () => ({ + reportedOptions: { model: '' } + }), + acquire, + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } +} + describe('processless structured session reservation', () => { it('refuses an adapter that declares no create support before reserving a lease', async () => { root = await mkdtemp(join(tmpdir(), 'orca-unsupported-attach-')) @@ -75,13 +90,9 @@ describe('processless structured session reservation', () => { const reserveOwner = vi.spyOn(store, 'reserveOwner') const acquire = vi.fn() const adapter = { - supportsCreate: vi.fn(() => false), - acquire, - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } as unknown as StructuredAgentSessionAdapter + ...adapterForAcquire(acquire), + supportsCreate: vi.fn(() => false) + } await expect( performAttach({ @@ -117,10 +128,11 @@ describe('processless structured session reservation', () => { .mockReturnValueOnce(true) .mockReturnValueOnce(true) .mockReturnValueOnce(false) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attach reaches only `supportsCreate` and `acquire` on this fake adapter. const adapter = { + ...adapterForAcquire(vi.fn()), supportsCreate, acquire: vi.fn(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, link: { linkId: 'link-1', @@ -130,7 +142,7 @@ describe('processless structured session reservation', () => { observedAt: NOW } })) - } as unknown as StructuredAgentSessionAdapter + } const input = { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), @@ -169,7 +181,7 @@ describe('processless structured session reservation', () => { .mockReturnValueOnce(true) .mockReturnValueOnce(true) const acquire = vi.fn() - const adapter = { supportsCreate, acquire } as unknown as StructuredAgentSessionAdapter + const adapter = { ...adapterForAcquire(acquire), supportsCreate } const input = { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), @@ -216,11 +228,11 @@ describe('processless structured session reservation', () => { it('settles a pre-spawn failure and its processless evidence in one durable transaction', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-reservation-')) const store = await openTestAgentSessionRecordStore(root) - const adapter = { - acquire: vi.fn(async () => { + const adapter = adapterForAcquire( + vi.fn(async () => { throw new AgentSessionPreSpawnError(new Error('workspace no longer exists')) }) - } as unknown as StructuredAgentSessionAdapter + ) const settlement = vi.spyOn(store, 'settleFailedAcquisition') await expect( @@ -270,12 +282,13 @@ describe('processless structured session reservation', () => { it('does not rerun a settled pre-spawn failure and admits a fresh operation', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-retry-')) const store = await openTestAgentSessionRecordStore(root) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attach reaches only the members this fake adapter defines. const adapter = { + ...adapterForAcquire(vi.fn()), acquire: vi .fn() .mockRejectedValueOnce(new AgentSessionPreSpawnError(new Error('launch not ready'))) .mockImplementationOnce(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -291,7 +304,7 @@ describe('processless structured session reservation', () => { } })), releaseAcquisition: vi.fn(async () => true) - } as unknown as StructuredAgentSessionAdapter + } const input = { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts deleted file mode 100644 index 5d31de269803..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ /dev/null @@ -1,908 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// The provider child is its own record on the conversation: stopping it, losing it or failing to -// start it ends the child, never the conversation. Against the real host, store and journal, with a -// live subscriber opened before each action. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionStatusSummary, - AgentSessionSubscribeEvent -} from '../../../shared/agent-session-wire' -import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' -import { - agentSessionFailureFact, - type AgentSessionFailureFact, - type SubmissionRejectionFact -} from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { ensureStructuredAgentSessionAgent } from './structured-agent-session-agent-start' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' -import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { structuredQueuePauses } from './structured-agent-session-queued-pause' -import { - HOST_TEST_LOCATION, - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { structuredAgentSessionHeldStarts } from './structured-agent-session-held-start.test-support' - -const CALLER = { callerKey: 'client-1' } -const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { - surface: 'rejection' -}) - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let dispatch: Mock -let adapterExtras: Partial - -function eventually(assertion: () => void | Promise): Promise { - return vi.waitFor(assertion, { timeout: 10_000 }) -} - -function generation(): string { - return `generation-${acquire.mock.calls.length}` -} - -const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - acquisitionGeneration: generation(), - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length - ? ('resumed' as const) - : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } -}) - -const starts = structuredAgentSessionHeldStarts(() => host) - -/** A child whose handshake is still running: `starting` until the test's `prove()`. */ -const spawnStartingChild = starts.held(spawnChild) - -function startHost(): void { - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: starts.wrap({ - dispatch, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn: vi.fn(async () => ({ cancelled: false })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined), - ...adapterExtras, - acquire - }), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - now: () => NOW - }) -} - -async function restartHost(): Promise { - await host.flushAllStreamedEvents() - startHost() -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-child-record-')) - resetHostTestOperationIds() - adapterExtras = {} - starts.clear() - acquire = vi.fn(spawnChild) - dispatch = vi.fn(async (input) => ({ - state: 'accepted' as const, - providerIdentity: { - provider: 'codex' as const, - threadId: THREAD, - turnId: `turn-${input.clientMessageId}`, - ordinal: dispatch.mock.calls.length - } - })) - store = await openTestAgentSessionRecordStore(root) - startHost() - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - await host.close(SESSION, 'evict') -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function sendParams(text: string) { - const body = hostTestMessage(text) - return { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body - } -} - -async function accept(text: string, options: { person?: true } = {}): Promise { - const params = sendParams(text) - const sent = await host.send(CALLER, { ...params, ...(options.person ? { userSend: true } : {}) }) - expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - return params.envelope.clientOperationId -} - -function stop() { - const turnId = 'turn-none' - return host.cancel(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.cancel', - sessionId: SESSION, - fields: { turnId } - }) - }, - turnId - }) -} - -async function submission(id: string): Promise { - return (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.clientMessageId === id - ) -} - -async function statusRows(): Promise< - { - itemId: string - text: string - tone?: string - failure?: AgentSessionFailureFact - }[] -> { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => - item.body.kind === 'status' - ? [ - { - itemId: item.itemId, - text: item.body.text, - ...(item.body.tone ? { tone: item.body.tone } : {}), - ...(item.body.failure ? { failure: item.body.failure } : {}) - } - ] - : [] - ) -} - -/** The child the conversation has now, as its lifecycle events name it. */ -function currentChild() { - const child = conversation()?.child - if (!child?.generation) { - throw new Error('no child indexed') - } - return { sessionId: SESSION, fence: child.fence, acquisitionGeneration: child.generation } -} - -/** The current child proves its start, as a publish-first provider's `started` event does. */ -function prove(): Promise { - const { acquisitionGeneration, ...child } = currentChild() - return host.handleAdapterEvent({ - type: 'started', - ...child, - acquisitionGeneration, - reportedOptions: { model: 'default' } - }) -} - -function exit(child: ReturnType, reason: string) { - 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' - }) -} - -function rejectedIn(events: AgentSessionSubscribeEvent[], id: string): boolean { - return events.some( - (event) => - event.type === 'batch' && - event.batch.submissions.some( - (entry) => entry.clientMessageId === id && entry.dispatchState === 'rejected' - ) - ) -} - -function conversation() { - return host['sessions'].get(SESSION) -} - -async function subscribe(): Promise { - const events: AgentSessionSubscribeEvent[] = [] - await host.subscribe({ - id: 'sub-1', - sessionId: SESSION, - emit: (event) => events.push(structuredClone(event)) - }) - return events -} - -/** The chat's status row as a session list sees it, frame by frame. */ -function watchStatus(): AgentSessionStatusSummary[] { - const frames: AgentSessionStatusSummary[] = [] - host.subscribeStatus({ - id: 'list-1', - emit: (event) => { - if (event.type === 'status' && event.session.sessionId === SESSION) { - frames.push(event.session) - } - } - }) - return frames -} - -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((next) => { - resolve = next - }) - return { promise, resolve } -} - -describe('Stop on a child still proving its start', () => { - it('ends the child and keeps the conversation and its readers (R1)', async () => { - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) - // Held for the starting child, which never proves its start. - const first = await accept('hello') - const journal = conversation()?.journal - const events = await subscribe() - const frames = watchStatus() - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - expect((await submission(first))?.handedOverAt).toBeUndefined() - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - - // The same conversation: no reopen, and the chat told it is idle again. - expect(conversation()?.journal).toBe(journal) - expect(conversation()?.child).toBeNull() - expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-stop', rootGone: true }) - expect(frames.at(-1)).not.toHaveProperty('hostExecutionPhase') - expect(frames.at(-1)).not.toHaveProperty('hostExecutionOwned') - expect(await submission(first)).toMatchObject({ - dispatchState: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED - }) - // A Stop is not a failure: no row, and the loop is gone. - expect(await statusRows()).toEqual([]) - await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) - - const next = await accept('after stop') - await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) - expect(conversation()?.journal).toBe(journal) - expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([next]) - // The reader opened before the Stop saw the next message delivered on the same stream. - expect( - events.some( - (event) => - event.type === 'batch' && - event.batch.submissions.some( - (entry) => entry.clientMessageId === next && entry.dispatchState === 'accepted' - ) - ) - ).toBe(true) - }) -}) - -describe('settling an earlier child before the next one takes its message', () => { - it("settles the earlier child's turn from its death evidence and leaves the queued message to the new child (R1)", async () => { - // The earlier child exited mid-turn; the released lease keeps only its death evidence. - await store.transitionHandoff(SESSION, (record) => ({ - ...record, - lease: { - ...record.lease, - deathEvidence: { kind: 'exit-observed', detail: 'provider exited', observedAt: NOW - 1_000 } - } - })) - const releasedFence = store.getRecord(SESSION)!.lease.runtimeFence - const journal = await openAgentSessionJournal({ - identity: { - sessionId: SESSION, - workspaceId: HOST_TEST_LOCATION.workspaceId, - hostId: HOST_TEST_LOCATION.executionHostId, - agent: 'codex', - providerHandle: codexProviderHandle(THREAD) - }, - database: openTestJournalHostDatabase(root) - }) - await journal.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'earlier-turn', ordinal: 0 }, - { kind: 'turn', turnId: 'earlier-turn', state: 'running', startedAt: NOW - 5_000 }, - { fence: releasedFence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.close() - const id = await accept('for the next child') - - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - // The exit was observed, so its receipt ends the turn, and the chat says why it stopped. - const items = conversation()!.journal.snapshot().items - expect(items.map((item) => readAgentJournalTurn(item.body)).filter(Boolean)).toContainEqual( - expect.objectContaining({ - turnId: 'earlier-turn', - state: 'interrupted', - completedAt: NOW - 1_000 - }) - ) - expect( - items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) - ).toContain( - 'Codex stopped while this response was in progress. You can continue in this conversation.' - ) - // Handed over at the new child's fence, which the attach reserved after settling. - const newFence = store.getRecord(SESSION)!.lease.runtimeFence - expect(newFence).toBeGreaterThan(releasedFence) - expect((await submission(id))?.fence).toBe(newFence) - expect(conversation()?.child).toMatchObject({ generation: generation(), fence: newFence }) - }) -}) - -const START_EXIT = 'claude stream-json exited (code 1)' -const START_TEXT = 'Codex stopped before it finished starting. Send your message to try again.' -const START_FAILURE: SubmissionRejectionFact = { - kind: 'providerStartFailed', - detail: { text: START_EXIT, audience: 'log' } -} - -describe('a published child that dies while it proves its start', () => { - const EXIT = START_EXIT - const TEXT = START_TEXT - - it('leaves one error row keyed by the start, and every message held for it rejected with it (R2)', async () => { - await restartHost() - acquire.mockImplementation(spawnStartingChild) - // Both are held for the starting child, which never proves its start. - const first = await accept('first') - const events = await subscribe() - const second = await accept('second') - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - const child = currentChild() - - await exit(child, EXIT) - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) - expect(await statusRows()).toEqual([ - { - itemId: `orca:${encodeURIComponent(`start-failure:${child.acquisitionGeneration}`)}`, - text: TEXT, - tone: 'error', - failure: START_FAILURE - } - ]) - for (const id of [first, second]) { - expect(await submission(id)).toMatchObject({ - dispatchState: 'rejected', - reason: TEXT, - rejection: START_FAILURE - }) - } - expect(rejectedIn(events, second)).toBe(true) - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).not.toHaveBeenCalled() - }) -}) - -/** A start by an operation that needs the agent, such as a goal change, outside the loop. */ -function startForOperation() { - return host['serialize'](SESSION, () => - ensureStructuredAgentSessionAgent(host['attachContext'](), SESSION) - ) -} - -describe('a start another operation made that dies while a sent message waits on it', () => { - const EXIT = START_EXIT - const TEXT = START_TEXT - - it("is the message's own failed start: one error row, the message rejected, no second start (R2)", async () => { - await restartHost() - acquire.mockImplementation(spawnStartingChild) - // An operation that needs the agent starts a child that has not proven its start. - await startForOperation() - const operationChild = currentChild() - const events = await subscribe() - const params = sendParams('hello') - - // Accepted first; that child's exit is settled before the loop's first step. - const sent = host.send(CALLER, params) - const exited = exit(operationChild, EXIT) - expect(await sent).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await exited - const id = params.envelope.clientOperationId - - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) - await settleLoop() - expect((await submission(id))?.reason).toBe(TEXT) - expect(await statusRows()).toEqual([ - { - itemId: `orca:${encodeURIComponent(`start-failure:${operationChild.acquisitionGeneration}`)}`, - text: TEXT, - tone: 'error', - failure: START_FAILURE - } - ]) - expect(rejectedIn(events, id)).toBe(true) - // The setup's child and the operation's: nothing started again into the same failure. - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).not.toHaveBeenCalled() - }) - - it('leaves a message sent after that start failed to a fresh start (R2)', async () => { - await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) - await startForOperation() - await exit(currentChild(), EXIT) - expect(await statusRows()).toHaveLength(1) - - const id = await accept('after the failure') - - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - expect(acquire).toHaveBeenCalledTimes(3) - }) - - it('starts again for a message whose proven child crashed: only a failed start settles it (R2)', async () => { - await restartHost() - await startForOperation() - const params = sendParams('hello') - - const sent = host.send(CALLER, params) - const exited = exit(currentChild(), 'codex app-server crashed') - await sent - await exited - const id = params.envelope.clientOperationId - - await eventually(async () => expect((await submission(id))?.dispatchState).not.toBe('pending')) - expect((await submission(id))?.dispatchState).toBe('accepted') - expect(acquire).toHaveBeenCalledTimes(3) - }) - - it('hands over to a child started since the failed one, not to the failure (R2)', async () => { - await restartHost() - acquire.mockImplementation(spawnStartingChild) - await startForOperation() - const params = sendParams('hello') - - // A second operation's start lands after the first child's exit, before the loop's first step. - const sent = host.send(CALLER, params) - const exited = exit(currentChild(), EXIT) - const held = startForOperation() - await sent - await exited - await held - const id = params.envelope.clientOperationId - await prove() - - await eventually(async () => expect((await submission(id))?.dispatchState).not.toBe('pending')) - expect((await submission(id))?.dispatchState).toBe('accepted') - expect(acquire).toHaveBeenCalledTimes(3) - }) -}) - -describe('a child that ends before its message is handed over', () => { - it('starts one child for the message, then rejects it and stops (R2)', async () => { - await restartHost() - // The child the loop starts proves its start, then exits as its start step returns: that exit, - // asked for on the lane then, lands before the handover step. - acquire.mockImplementation(async (input) => { - const child = await spawnStartingChild(input) - const { acquisitionGeneration } = child - if (acquisitionGeneration === undefined) { - throw new Error('spawnChild always names a generation') - } - void starts.prove({ sessionId: SESSION, fence: input.fence, acquisitionGeneration }) - void host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - fence: input.fence, - acquisitionGeneration, - reason: 'codex app-server crashed', - failure: { - kind: 'providerExited', - detail: { text: 'codex app-server crashed', audience: 'log' } - }, - cause: 'unexpected-exit' - }) - return child - }) - const id = await accept('hello') - const events = await subscribe() - - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) - // The exit's stderr rides beside the sentence, never in it. - const failure = { - kind: 'providerStartFailed', - detail: { text: 'codex app-server crashed', audience: 'log' } - } - expect(await submission(id)).toMatchObject({ - reason: 'Codex stopped before it finished starting. Send your message to try again.', - rejection: { ...failure, kind: 'providerStartFailed' } - }) - expect(await statusRows()).toEqual([ - { itemId: expect.any(String), text: (await submission(id))?.reason, tone: 'error', failure } - ]) - expect(rejectedIn(events, id)).toBe(true) - await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).not.toHaveBeenCalled() - }) -}) - -describe('another child indexed after a Stop ended the starting one', () => { - it('hands the next message to the child now there once it proves its start (R2)', async () => { - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementation(spawnStartingChild) - // Held for the starting child, which never proves its start. - const first = await accept('first') - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - const stopped = currentChild() - expect(await stop()).toMatchObject({ ok: true }) - // An operation that needs the agent starts its own child before the second message is sent. - await host['serialize'](SESSION, () => - ensureStructuredAgentSessionAgent(host['attachContext'](), SESSION) - ) - const replacement = currentChild() - expect(replacement.acquisitionGeneration).not.toBe(stopped.acquisitionGeneration) - const secondId = await accept('second') - expect((await submission(secondId))?.handedOverAt).toBeUndefined() - expect(dispatch).not.toHaveBeenCalled() - await host.flushStreamedEvents(SESSION) - expect((await submission(secondId))?.handedOverAt).toBeUndefined() - expect(dispatch).not.toHaveBeenCalled() - - await prove() - - await eventually(async () => - expect((await submission(secondId))?.dispatchState).toBe('accepted') - ) - expect(conversation()?.child).toMatchObject({ - generation: replacement.acquisitionGeneration, - phase: 'ready' - }) - expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([secondId]) - expect(acquire).toHaveBeenCalledTimes(3) - expect(await submission(first)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) - }) -}) - -// A quit settles nothing still queued: the next launch's open keeps a person's message as a held -// card and rejects the rest, as a crash's would. -describe('a quit with a message still queued', () => { - const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { - surface: 'rejection' - }) - - /** Read by the next launch, through the same open any reader takes. */ - async function afterRelaunch(id: string): Promise { - startHost() - await host.revealSession(SESSION) - return await submission(id) - } - - async function keptCards(): Promise { - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - return page.ok ? (page.page.queuedMessages ?? []).map((card) => card.messageId) : [] - } - - it('keeps a person’s message no child ever had as a held card for the next launch (R2)', async () => { - // Quit has begun — its first step stops the delivery loops — when this message is accepted. - host['conversationDelivery'].loop.dispose() - const id = await accept('hello', { person: true }) - expect(conversation()?.child).toBeNull() - await host.flushAllStreamedEvents() - - expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) - expect(await keptCards()).toEqual([id]) - expect(dispatch).not.toHaveBeenCalled() - }) - - it("rejects a message from Orca itself at the next launch, as a crash's would", async () => { - host['conversationDelivery'].loop.dispose() - const id = await accept('from orca') - await host.flushAllStreamedEvents() - - expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) - expect(await keptCards()).toEqual([]) - }) - - it('stops a start already in flight before it launches a child (R2)', async () => { - const starting = deferred() - const closeSession = vi.fn(async () => true) - adapterExtras = { closeSession } - await restartHost() - // The loop's start step is under way, but has not reached its attach yet. - const resolveRecovery = host['runtimeState'].resolveRecovery.bind(host['runtimeState']) - const recovering = vi.spyOn(host['runtimeState'], 'resolveRecovery') - recovering.mockImplementationOnce(async (sessionId) => { - await starting.promise - return resolveRecovery(sessionId) - }) - const id = await accept('hello', { person: true }) - await eventually(() => expect(recovering).toHaveBeenCalled()) - const acquiresBefore = acquire.mock.calls.length - - const quit = host.flushAllStreamedEvents() - starting.resolve() - await quit - - // Quit aborts the start, so nothing is launched behind it and nothing is left to stop. - expect(acquire).toHaveBeenCalledTimes(acquiresBefore) - expect(closeSession).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null, - deathEvidence: { detail: 'reservation failed before spawn' } - }) - expect(dispatch).not.toHaveBeenCalled() - expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) - expect(await keptCards()).toEqual([id]) - }) -}) - -describe('a send whose start failed, sent again with the same operation id', () => { - it('replays the recorded rejection and starts no second agent (R2)', async () => { - acquire.mockRejectedValueOnce(new Error('spawn claude ENOENT')) - const fenceBefore = store.getRecord(SESSION)!.lease.runtimeFence - const params = sendParams('hello') - // A failed start is a rejected message, never a refused send. - expect(await host.send(CALLER, params)).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - const id = params.envelope.clientOperationId - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) - // The start moved the fence while the message was out. - expect(store.getRecord(SESSION)!.lease.runtimeFence).toBeGreaterThan(fenceBefore) - const starts = acquire.mock.calls.length - - const replay = await host.send(CALLER, params) - - expect(replay).toMatchObject({ - ok: true, - replayed: true, - value: { submission: { clientMessageId: id, dispatchState: 'rejected' } } - }) - await settleLoop() - expect(acquire).toHaveBeenCalledTimes(starts) - expect(dispatch).not.toHaveBeenCalled() - }) -}) - -async function settleLoop(): Promise { - await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) -} - -describe('how a stopped child ends the start its loop was waiting on', () => { - /** A starting child the first message is held for, then the stop below; the message sent after - * the stop is the next delivery's. */ - async function stoppedWhileStarting(stop: () => Promise) { - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) - await accept('first') - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - await stop() - return accept('second') - } - - it("goes on after a user's Stop and delivers what was sent since (R2)", async () => { - const second = await stoppedWhileStarting(async () => { - expect(await stop()).toMatchObject({ ok: true }) - }) - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-stop', reason: null }) - expect(await statusRows()).toEqual([]) - }) - - /** The close's stop alone: a close that aborts after it leaves the conversation indexed. */ - function closeStopOnly() { - return host['serialize'](SESSION, () => - stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { - cause: 'user-close' - }) - ) - } - - /** A message queued for a starting child when the close's stop cut it: asked for during the - * start step, the stop runs before the handover. `beforeStart` runs after the stop and before - * the loop looks again. */ - async function closedWhileStarting(beforeStart: () => void = () => undefined) { - const start = deferred() - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementationOnce(async (input) => { - await start.promise - return spawnStartingChild(input) - }) - const first = await accept('first') - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) - let starts = 0 - const closed = host['serialize'](SESSION, async () => { - await stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { - cause: 'user-close' - }) - starts = acquire.mock.calls.length - beforeStart() - }) - start.resolve() - await closed - await settleLoop() - return { first, starts } - } - - it('closes what was queued when the user closed the chat, and starts no child for it', async () => { - const { first, starts } = await closedWhileStarting() - - expect(await submission(first)).toMatchObject({ - dispatchState: 'rejected', - ...CHAT_CLOSED - }) - expect(acquire).toHaveBeenCalledTimes(starts) - expect(dispatch).not.toHaveBeenCalled() - expect(await statusRows()).toEqual([]) - }) - - it('keeps a person’s message the user closed as a waiting card, and starts no child for it', async () => { - const start = deferred() - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - // The start step holds the queue: the message is still queued when the close's stop runs. - acquire.mockImplementationOnce(async (input) => { - await start.promise - return spawnStartingChild(input) - }) - const first = await accept('first', { person: true }) - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) - let starts = 0 - const closed = closeStopOnly().then(() => { - starts = acquire.mock.calls.length - }) - start.resolve() - await closed - await settleLoop() - - expect(await submission(first)).toMatchObject({ dispatchState: 'rejected', ...CHAT_CLOSED }) - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(page.ok && page.page.queuedMessages?.map((card) => card.messageId)).toEqual([first]) - expect(acquire).toHaveBeenCalledTimes(starts) - expect(dispatch).not.toHaveBeenCalled() - // The close stopped the chat running: the card waits for its next turn, by a mark the - // re-check wrote once, when it kept the card. - const journal = conversation()!.journal - expect(structuredQueuePauses(journal).map((pause) => pause.reason)).toEqual(['restarted']) - const marks = vi.spyOn(journal, 'appendQueueReopen') - await accept('second', { person: true }) - await settleLoop() - // A later send's re-check settles nothing, so it marks nothing past that send. - expect(marks).not.toHaveBeenCalled() - }) - - it('starts no child when closing what was queued fails, and closes it on the next wake', async () => { - const { first, starts } = await closedWhileStarting(() => { - const journal = conversation()!.journal - const resolve = journal.resolveDispatch.bind(journal) - vi.spyOn(journal, 'resolveDispatch').mockImplementation(async (...args) => { - if (args[0].state === 'rejected' && args[0].rejection?.kind === 'chatClosed') { - vi.mocked(journal.resolveDispatch).mockImplementation(resolve) - throw new Error('disk full') - } - return resolve(...args) - }) - }) - - expect((await submission(first))?.dispatchState).toBe('pending') - expect(acquire).toHaveBeenCalledTimes(starts) - expect(dispatch).not.toHaveBeenCalled() - - const second = await accept('second') - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - expect(await submission(first)).toMatchObject({ - dispatchState: 'rejected', - ...CHAT_CLOSED - }) - expect(dispatch).toHaveBeenCalledTimes(1) - }) - - it('goes on with a message sent in a later epoch, whose sequence restarts at or below the close', async () => { - await closedWhileStarting() - const session = conversation()! - await session.journal.rollEpoch( - 'corruption', - structuredAgentSessionConversationFence(store, SESSION) - ) - - const second = await accept('second') - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - expect(session.lastEndedChild).toMatchObject({ cause: 'user-close' }) - expect((await submission(second))!.acceptedSequence).toBeLessThanOrEqual( - session.lastEndedChild!.endedAt.sequence - ) - }) - - it('goes on with a message sent after the close, never failing it', async () => { - const second = await stoppedWhileStarting(closeStopOnly) - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-close' }) - expect(await statusRows()).toEqual([]) - }) - - it("fails the start after a host stop, as a start Orca stopped rather than the provider's (R2)", async () => { - const reason = 'the start watchdog fired' - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) - const first = await accept('first') - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - await host['serialize'](SESSION, () => - stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { - cause: 'host-stop', - reason - }) - ) - - // What was held for the stopped start fails with it, said once; a later send starts afresh. - await eventually(async () => expect((await submission(first))?.dispatchState).toBe('rejected')) - // The sentence is the constructor's, not the reason the stop was given. - const text = 'Codex never finished starting, so Orca stopped it.' - expect(await submission(first)).toMatchObject({ - reason: text, - rejection: { kind: 'hostStopped' } - }) - expect(await statusRows()).toEqual([ - { itemId: expect.any(String), text, tone: 'error', failure: { kind: 'hostStopped' } } - ]) - const second = await accept('second') - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts index 981636f2caad..30d3dd497fec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts @@ -5,7 +5,7 @@ import type { AgentSessionProviderHandleLink } from '../../../shared/agent-sessi export type StructuredAgentSessionProviderOpenInput = { sessionId: string fence: number - acquisitionGeneration: string | null + acquisitionGeneration: string signal: AbortSignal } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts index fbc8ba797193..501260915917 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -24,7 +24,6 @@ function awaitOpen() { } state.host.deps.adapter.acquire = async (input) => ({ ...(await acquire(input)), - providerChildPhase: 'starting', acquisitionGeneration: 'awaited-open' }) const pending = Promise.withResolvers() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts index 9e9020fdb1fc..537957a743f2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts @@ -17,18 +17,21 @@ export async function openStructuredAgentSessionProviderChild( sessionId: string, child: StructuredAgentSessionProviderChildIdentity ): Promise { - const open = context.deps.adapter.open - if (!open || !context.runtimeState.startupAttempts.onClock(sessionId, child)) { + if (!context.runtimeState.startupAttempts.onClock(sessionId, child)) { return } const attempt = context.runtimeState.acquireAborts.begin(sessionId) try { attempt.signal.throwIfAborted() + const acquisitionGeneration = child.generation + if (acquisitionGeneration === null) { + throw new Error('An unopened provider child has no acquisition generation') + } const result = await waitForPromiseWithSignal( - open.call(context.deps.adapter, { + context.deps.adapter.open({ sessionId, fence: child.fence, - acquisitionGeneration: child.generation, + acquisitionGeneration, signal: attempt.signal }), attempt.signal @@ -38,13 +41,7 @@ export async function openStructuredAgentSessionProviderChild( } await settleStructuredAgentSessionProviderStarted( context, - { - ...result, - type: 'started', - sessionId, - fence: child.fence, - acquisitionGeneration: child.generation ?? '' - }, + { ...result, sessionId, fence: child.fence, acquisitionGeneration }, attempt.signal ) } catch (error) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts deleted file mode 100644 index 09421a97cc56..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' -import { - HOST_TEST_NOW, - HOST_TEST_SESSION, - hostTestAttachParams, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CLAUDE_SESSION = 'claude-session' -const hosts: StructuredAgentSessionHost[] = [] -let root = '' - -function claudeAdapter(): StructuredAgentSessionAdapter { - return { - supportsCreate: (_location, agent) => agent === 'claude', - acquire: async ({ fence, spawnToken }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - link: { - linkId: `link-${fence}`, - handle: claudeProviderHandle(CLAUDE_SESSION, null), - origin: 'created', - mintedAtFence: fence, - observedAt: HOST_TEST_NOW - } - }), - dispatch: async () => ({ - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { - surface: 'rejection' - }) - }), - cancelTurn: async () => ({ cancelled: false }), - answerPrompt: async () => undefined, - setOption: async () => undefined - } -} - -function createHost( - store: AgentSessionRecordStore, - probeOwner?: StructuredAgentSessionHost['deps']['probeOwner'] -): StructuredAgentSessionHost { - const host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: claudeAdapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - probeOwner, - now: () => HOST_TEST_NOW - }) - hosts.push(host) - return host -} - -afterEach(async () => { - await Promise.all(hosts.splice(0).map(abandonStructuredAgentSessionHost)) - await rm(root, { recursive: true, force: true }) - root = '' -}) - -describe('structured session provider restore', () => { - it('restores a durable Claude session tab with its recorded provider', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-provider-restore-')) - resetHostTestOperationIds() - const store = await openTestAgentSessionRecordStore(root) - const host = createHost(store) - const attached = await host.attach( - { callerKey: 'client-1' }, - hostTestAttachParams(null, { - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - providerHandle: { kind: 'claude', sessionId: CLAUDE_SESSION, leafUuid: null } - }) - ) - expect(attached).toMatchObject({ ok: true }) - - const reopenedStore = await openTestAgentSessionRecordStore(root) - const restarted = createHost(reopenedStore, async () => ({ - outcome: 'indeterminate', - reason: 'read does not need ownership' - })) - - await restarted.restoreReadableSessions() - - expect(restarted.listSessionTabs()).toEqual([ - { sessionId: HOST_TEST_SESSION, workspaceId: 'workspace-1', agent: 'claude' } - ]) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts deleted file mode 100644 index a256f02037e4..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -// A chat that proves its start shows its account can start one, so the model catalog's held -// "can't start" reason is made due for the probe instead of trusted until its TTL runs out. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -const providerStarted = vi.fn() - -function startHost(): void { - const acquire: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created' as const, - mintedAtFence: fence, - observedAt: NOW - }, - acquisitionGeneration: 'generation-1' - }) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: { - acquire, - releaseAcquisition: vi.fn(async () => true), - closeSession: vi.fn(async () => true), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - modelCatalog: { - read: vi.fn(), - recordLiveListing: vi.fn(), - prewarm: vi.fn(async () => {}), - stop: vi.fn(), - providerStarted - }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-1', - now: () => NOW - }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-started-catalog-')) - resetHostTestOperationIds() - providerStarted.mockReset() - store = await openTestAgentSessionRecordStore(root) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -/** The adapter's report that the child's protocol session exists. */ -function proveStart(): Promise { - return host.handleAdapterEvent({ - type: 'started', - sessionId: SESSION, - fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, - acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'sonnet' } - }) -} - -it('a published child tells the catalog only once its start is proven, with its record', async () => { - startHost() - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true }) - expect(providerStarted).not.toHaveBeenCalled() - await proveStart() - expect(providerStarted).toHaveBeenCalledTimes(1) - expect(providerStarted.mock.calls[0]?.[0]).toMatchObject({ sessionId: SESSION }) -}) - -it('a re-attach to the live child proves nothing new and tells the catalog nothing', async () => { - startHost() - const params = hostTestAttachParams(null) - await expect(host.attach(CALLER, params)).resolves.toMatchObject({ ok: true }) - await proveStart() - const fence = store.getRecord(SESSION)?.lease.runtimeFence - // A reconnecting client replays its attach; the same operation re-attaches the live child. - await expect(host.attach(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(fence) - expect(providerStarted).toHaveBeenCalledTimes(1) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts deleted file mode 100644 index e36758ff177c..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -// A publish-first create proves nothing about the model until Claude answers startup. The record -// must never hold the catalog's default in the meantime: an owner handoff or a reopen would -// launch it as `--model` and silently move a user whose CLI default is not Sonnet. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-store' -import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' -import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' -import { - fakeClaude, - PROVIDER_SESSION_ID -} from '../../claude/claude-structured-session-test-support' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - hostTestAttachParams, - hostTestMessage, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { createTestParams } from './structured-agent-session-create-test-fixture' - -const CALLER = { callerKey: 'client-1' } -const CLAUDE_MODELS = [ - { value: 'claude-sonnet', displayName: 'Sonnet' }, - { value: 'claude-opus-9', displayName: 'Opus 9' } -] -const LISTED = [ - expect.objectContaining({ id: 'claude-sonnet' }), - expect.objectContaining({ id: 'claude-opus-9' }) -] -const INIT_DELAY_MS = 40 - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let adapter: ClaudeStructuredSessionAdapter -let lifecycle: Promise[] -let statuses: AgentSessionStatusEvent[] -let savedListings: { sessionId: string; listing: AgentModelCatalogLiveListing }[] - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-provider-started-')) - resetHostTestOperationIds() - lifecycle = [] - statuses = [] - savedListings = [] - // A CLI whose own default is not the catalog's: startup reports it through get_settings, - // since system/init arrives only with the first command. - const claude = fakeClaude({ - initDelayMs: INIT_DELAY_MS, - initModel: 'claude-opus-9', - settings: { - applied: { model: 'claude-opus-9', effort: 'high', advisor: null, ultracode: false }, - effective: { model: 'claude-opus-9', effortLevel: 'high', env: {} }, - sources: {} - }, - // Startup lists from initialize, and a chat's options read from list_models: the same rows. - initModels: CLAUDE_MODELS, - routes: { list_models: () => CLAUDE_MODELS } - }) - adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => ({ - pathToClaudeCodeExecutable: 'claude', - options: {}, - cwd: root, - claudeConfigDir: join(root, 'claude-home'), - providerSessionId: PROVIDER_SESSION_ID, - resumeLeafUuid: null, - // A session that already minted its provider handle resumes it, as the real launch does. - resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, - continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 - }), - // The runtime's own mapping, so this test drives the same lifecycle path production does. - onEvent: (event) => { - const mapped = structuredClaudeLifecycleEvent(event) - if (mapped) { - lifecycle.push(host.handleAdapterEvent(mapped)) - } - }, - openConnection: claude.openConnection, - readProcessStartTime: async () => 1_700_000_000_000, - now: () => NOW - }) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - // The production router is what declares create support; the bare adapter only knows locations. - adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - modelCatalog: { - read: async () => ({ origin: 'unknown' }), - recordLiveListing: (sessionId, listing) => savedListings.push({ sessionId, listing }), - prewarm: async () => {}, - stop: () => {}, - providerStarted: () => {} - }, - now: () => NOW - }) - host.subscribeStatus({ id: 'status-1', emit: (event) => statuses.push(event) }) -}) - -afterEach(async () => { - await adapter.closeAll() - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function claudeParams(expectedRuntimeFence: number | null = null) { - return hostTestAttachParams(expectedRuntimeFence, { - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, - providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } - }) -} - -function lastPhase(): string | undefined { - const last = statuses.findLast((event) => event.type === 'status') - return last?.type === 'status' ? last.session.hostExecutionPhase : undefined -} - -describe('a publish-first Claude create whose init is slow', () => { - it('keeps provider defaults out of saved picks after starting', async () => { - await expect(host.attach(CALLER, claudeParams())).resolves.toMatchObject({ ok: true }) - - // Published, not yet answering: the record holds no model rather than a guessed one. - expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() - expect(lastPhase()).toBe('starting') - - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() - expect(lastPhase()).toBe('ready') - }) - - it('keeps the saved model as intent while starting, then confirms what the child runs', async () => { - const params = claudeParams() - await expect( - host.attach(CALLER, { ...params, options: { model: 'opus' } }) - ).resolves.toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - expect(lastPhase()).toBe('ready') - }) - - it('keeps the picked model across a resume whose new child starts on its own default', async () => { - const params = claudeParams() - await host.attach(CALLER, { ...params, options: { model: 'opus' } }) - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - await host.close(SESSION, 'evict') - const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - // Starting the chat again resumes the session under a new fence. - await expect(host.attach(CALLER, claudeParams(releasedFence))).resolves.toMatchObject({ - ok: true - }) - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(releasedFence) - // The new child is launched with the saved pick, whatever its CLI default. - expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - expect(lastPhase()).toBe('starting') - - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - expect(lastPhase()).toBe('ready') - }) - it('saves the account listing the child read at startup, though no view asks for options', async () => { - await host.attach(CALLER, claudeParams()) - expect(savedListings).toEqual([]) - - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - // `started` hands over the listing; the settings readback after it, what the config resolved. - expect(savedListings).toEqual([ - { sessionId: SESSION, listing: expect.objectContaining({ models: LISTED }) }, - { - sessionId: SESSION, - listing: expect.objectContaining({ - models: LISTED, - configuredDefault: expect.objectContaining({ modelId: 'claude-opus-9' }) - }) - } - ]) - expect(savedListings[0]?.listing).not.toHaveProperty('configuredDefault') - }) - it('reports the configured default from a chat created with its first message and no pick', async () => { - const first = { clientMessageId: 'opening', body: hostTestMessage('hello') } - await expect( - host.create(CALLER, createTestParams(first, claudeParams()), { firstMessage: first }) - ).resolves.toMatchObject({ ok: true }) - - // Delivery, not create, starts the CLI. Its `started` event carries the account listing; the - // settings read after it (`options-reported`) resolves the configured model and says so once. - await vi.waitFor(() => expect(savedListings.length).toBeGreaterThanOrEqual(1)) - expect(savedListings[0]).toEqual({ - sessionId: SESSION, - listing: expect.objectContaining({ models: LISTED }) - }) - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() - expect(savedListings.at(-1)).toEqual({ - sessionId: SESSION, - listing: expect.objectContaining({ - configuredDefault: expect.objectContaining({ modelId: 'claude-opus-9' }) - }) - }) - // A later options read lists the models and says nothing more about the default. - await host.readOptions(SESSION) - expect(savedListings.at(-1)?.listing).not.toHaveProperty('configuredDefault') - }) - - it('saves the configured default when the settings readback beats the attach', async () => { - // The attach is slow to prove the owner, so the child's readback lands before the host indexes it. - const proveOwner = store.proveOwner.bind(store) - store.proveOwner = async (input) => { - await new Promise((resolve) => setTimeout(resolve, 200)) - return proveOwner(input) - } - await expect(host.attach(CALLER, claudeParams())).resolves.toMatchObject({ ok: true }) - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - await vi.waitFor(() => - expect(savedListings.at(-1)?.listing).toMatchObject({ - configuredDefault: expect.objectContaining({ modelId: 'claude-opus-9' }) - }) - ) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index 2831b63153a7..31b15f6aa742 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts @@ -1,9 +1,7 @@ // Opening records the provider handle and recovers history before delivery; reports never replace picks. -import type { - StructuredAgentSessionOptionsReportedEvent, - StructuredAgentSessionStartedEvent -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionOptionsReportedEvent } from './structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenResult } from './structured-agent-session-provider-open-contract' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession, @@ -40,9 +38,15 @@ export type StructuredAgentSessionProviderStartedContext = { stopStartingChild: (sessionId: string, child: StructuredAgentSessionProviderChildIdentity) => void } +type OpenedProvider = StructuredAgentSessionProviderOpenResult & { + sessionId: string + fence: number + acquisitionGeneration: string +} + export async function settleStructuredAgentSessionProviderStarted( context: StructuredAgentSessionProviderStartedContext, - event: StructuredAgentSessionStartedEvent, + event: OpenedProvider, signal?: AbortSignal ): Promise { // Serialized behind the attach that published this child, so the lease it proved is committed. @@ -124,12 +128,12 @@ async function recoverRewindAtStart( * without it a later start could not resume what this child ran. False when there is none. */ async function recordStartedHandle( context: StructuredAgentSessionProviderStartedContext, - event: StructuredAgentSessionStartedEvent + event: OpenedProvider ): Promise { const { store, logger } = context.deps const { link } = event if (!link) { - // Only an acquisition that already held its handle may prove its start without one. + // Only a spawn that already held its handle may prove its open without one. return store.getRecord(event.sessionId)?.lease.provenHandleLinkId != null } try { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts index aa5844ef0eea..249d7b6f0a90 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts @@ -19,7 +19,7 @@ export function adapterSupportsCreate( return adapter.supportsLocation?.(location) ?? false } -/** Honors declared gates while retaining legacy adapters whose acquire path is authoritative. */ +/** Honors declared gates while retaining adapters whose spawn path is authoritative. */ export function adapterSupportsCreateIfDeclared( adapter: StructuredAgentSessionAdapter, location: AgentSessionExecutionLocation, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts index bdd6be6144a3..fd00ad61344c 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 @@ -56,8 +56,20 @@ export function createQueuedRigProvider( async () => true ) let events: StructuredAgentSessionEventSink | undefined + let proveOpen: (() => void) | null = null const scripted: StructuredAgentSessionAdapter = { + open: () => + options.starting + ? new Promise((resolve) => { + proveOpen = () => + resolve({ + reportedOptions: { model: 'default' } + }) + }) + : Promise.resolve({ + reportedOptions: { model: 'default' } + }), acquire: async ({ identity, fence, spawnToken, events: sink }) => { starts() const hold = startHold @@ -76,7 +88,6 @@ export function createQueuedRigProvider( ? `${THREAD}-context-${record!.providerContextBoundary!.operationId}` : (context?.head?.handle.nativeId ?? THREAD) return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, @@ -150,6 +161,7 @@ export function createQueuedRigProvider( return { adapter, + proveOpen: () => proveOpen?.(), dispatch, starts, holdNextStart, 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 f78d27579045..f87063bbefae 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 @@ -183,7 +183,7 @@ export async function createQueuedMessageTestRig( await result await eventually(async () => { if (host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase === 'starting') { - await emitStarted() + await proveProviderOpen() } expect((await submission(id))?.handedOverAt).toBeDefined() }) @@ -254,23 +254,17 @@ export async function createQueuedMessageTestRig( }) } - /** The starting child proves its start, as a publish-first provider's `started` event does: the - * host hands it what it held. */ + /** The starting child proves its open before the host hands it queued work. */ async function proveStart(): Promise { await eventually(() => expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') ) - await emitStarted() + await proveProviderOpen() } - function emitStarted(): Promise { - return host.handleAdapterEvent({ - type: 'started', - sessionId: SESSION, - fence: store.getRecord(SESSION)!.lease.runtimeFence, - acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'default' } - }) + async function proveProviderOpen(): Promise { + provider.proveOpen() + await Promise.resolve() } async function dispose(): Promise { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index ff3967c36347..eb079409cff5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -35,7 +35,7 @@ let acquire: Mock const spawnedOwners = new Set>() const supersededHosts = new Set() -async function spawnOwner(spawnToken: string) { +async function acquireOwner(spawnToken: string) { const child = spawnProcess({ program: process.execPath, args: ['-e', 'setInterval(() => {}, 1_000)'], @@ -73,13 +73,16 @@ async function startAgent(): Promise { function adapter(): StructuredAgentSessionAdapter { return { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, - releaseAcquisition: vi.fn(async () => undefined), + releaseAcquisition: vi.fn(async () => false), dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), setOption: vi.fn() - } as unknown as StructuredAgentSessionAdapter + } } function openHost(overrides: Partial = {}): void { @@ -105,6 +108,7 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-recovery-exits-')) resetHostTestOperationIds() acquire = vi.fn(async ({ fence }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -286,8 +290,9 @@ describe('recovery exits', () => { }) it('recovers when the outgoing runtime writes after the replacement probes its dying owner', async () => { - const outgoing = await spawnOwner('spawn-a') + const outgoing = await acquireOwner('spawn-a') acquire.mockResolvedValueOnce({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: outgoing.process, link: { linkId: 'link-outgoing', @@ -331,8 +336,9 @@ describe('recovery exits', () => { }) expect(acquire).toHaveBeenCalledOnce() - const replacement = await spawnOwner('spawn-b') + const replacement = await acquireOwner('spawn-b') acquire.mockResolvedValueOnce({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: replacement.process, link: { linkId: 'link-replacement', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts deleted file mode 100644 index 72944ea397b6..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' -import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { - AGENT_SESSION_WIRE_REFUSAL_CODES, - type AgentSessionMutationEnvelope, - type AgentSessionMutationResult, - type AgentSessionWireRefusalCode -} from '../../../shared/agent-session-wire' -import { - agentSessionRefusalOperationState, - type AgentSessionRefusalOperationState -} from '../../../shared/agent-session-refusal-retry' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' -import { AgentSessionJournal } from '../agent-session-journal/journal-store' - -const CALLER = { callerKey: 'client-1' } -const METHODS = ['agentSession.setOption', 'agentSession.send'] as const -type Method = (typeof METHODS)[number] -type Pair = `${Method}:${AgentSessionWireRefusalCode}` - -type CallSpec = { - method: Method - operationId: string - expectedRuntimeFence?: number - payloadFingerprint?: string -} - -type Harness = { - root: string - store: AgentSessionRecordStore - host: StructuredAgentSessionHost - setOption: Mock -} - -const harnesses: Harness[] = [] -let operationSequence = 1_000 - -function operationId(timestamp = NOW): string { - operationSequence += 1 - return `${timestamp}-${operationSequence.toString(16).padStart(32, '0')}` -} - -async function createHarness(options: { attached?: boolean } = {}) { - const root = await mkdtemp(join(tmpdir(), 'orca-refusal-oracle-')) - const store = await openTestAgentSessionRecordStore(root) - const setOption = vi.fn(async () => undefined) - const adapter: StructuredAgentSessionAdapter = startsWhenPublished( - { - acquire: async ({ fence }) => ({ - providerChildPhase: 'starting' as const, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW - 1_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - }), - dispatch: async () => ({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => undefined, - // A failed acquisition is proven gone, as the real adapters prove it. - releaseAcquisition: async () => true, - setOption - }, - () => host - ) - const host = new StructuredAgentSessionHost({ - agents: claudeAndCodexDeclared(), - logger: createStructuredAgentSessionLogger(), - store, - adapter, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - const harness = { root, store, host, setOption } - harnesses.push(harness) - if (options.attached !== false) { - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - } - return harness -} - -afterEach(async () => { - const completed = harnesses.splice(0) - await Promise.all(completed.map(async ({ host }) => abandonStructuredAgentSessionHost(host))) - await Promise.all(completed.map(async ({ root }) => rm(root, { recursive: true }))) -}) - -function callFields(spec: CallSpec): Record { - if (spec.method === 'agentSession.send') { - return { body: hostTestMessage('host oracle') } - } - return { key: 'model', value: 'gpt-5' } -} - -function envelope(harness: Harness, spec: CallSpec): AgentSessionMutationEnvelope { - const fields = callFields(spec) - return { - sessionId: SESSION, - clientOperationId: spec.operationId, - expectedRuntimeFence: - spec.expectedRuntimeFence ?? harness.store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: - spec.payloadFingerprint ?? - computeAgentSessionPayloadFingerprint({ method: spec.method, sessionId: SESSION, fields }) - } -} - -function invoke(harness: Harness, spec: CallSpec): Promise> { - const fields = callFields(spec) - const mutationEnvelope = envelope(harness, spec) - if (spec.method === 'agentSession.send') { - return harness.host.send(CALLER, { - envelope: mutationEnvelope, - body: fields.body as ReturnType - }) - } - return harness.host.setOption(CALLER, { - envelope: mutationEnvelope, - key: fields.key as string, - value: fields.value as string - }) -} - -function operationState(harness: Harness, operation: string) { - return harness.store - .listOperationRows() - .find((row) => row.callerKey === CALLER.callerKey && row.operationId === operation)?.outcome -} - -async function assertHostAgreement( - harness: Harness, - spec: CallSpec, - code: AgentSessionWireRefusalCode, - retry?: () => Promise<{ harness: Harness; spec: CallSpec }> -): Promise { - try { - const result = await invoke(harness, spec) - expect(result, `${spec.method}:${code}`).toMatchObject({ ok: false, refusal: { code } }) - } catch (error) { - expect(error).toMatchObject({ message: code }) - } - const outcome = operationState(harness, spec.operationId) - let oracle: AgentSessionRefusalOperationState - if ( - harness.store.readCommandReceipt({ kind: 'global' }, spec.operationId).verdict === 'unreadable' - ) { - oracle = 'unknown' - } else if (outcome?.status === 'failed') { - oracle = 'settled-rejected' - } else if (outcome?.status === 'unknown') { - oracle = 'unknown' - } else if (outcome?.status === 'pending') { - oracle = 'pending-admission' - } else if (retry) { - const next = await retry() - await expect(invoke(next.harness, next.spec)).resolves.toMatchObject({ - ok: true, - replayed: false - }) - oracle = 'pending-admission' - } else { - oracle = 'settled-rejected' - } - expect(agentSessionRefusalOperationState(code), `${spec.method}:${code}`).toBe(oracle) - return `${spec.method}:${code}` -} - -// sendPlan and setOptionPlan have no unsupported branch. -const UNREACHABLE = new Set([ - 'agentSession.send:structured_agent_session_unsupported', - 'agentSession.setOption:structured_agent_session_unsupported', - // performPrompt is the sole producer of prompt revision and resolution refusals. - 'agentSession.setOption:agent_session_item_revision_stale', - 'agentSession.send:agent_session_item_revision_stale', - 'agentSession.setOption:agent_session_already_resolved', - 'agentSession.send:agent_session_already_resolved', - // StructuredAgentSessionHost.mutate maps an absent record to AGENT_SESSION_NOT_ATTACHED. - 'agentSession.setOption:agent_session_identity_required', - 'agentSession.send:agent_session_identity_required', - // Only a send opens the conversation it writes to. - 'agentSession.setOption:agent_session_journal_unreadable', - // Only a send restarts a lost owner. - 'agentSession.setOption:agent_session_owner_restart_failed', - // 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', - // A send is a conversation write: admitted whoever owns the lease, and a start it needs that - // fails rejects the accepted message rather than refusing the call. - 'agentSession.send:agent_session_conflict', - 'agentSession.send:execution_owner_reconciling', - 'agentSession.send:agent_session_owner_restart_failed', - // Send admits through its receipt, which has no age budget. - 'agentSession.send:agent_session_operation_expired', - // The ledger has no count limit; only an older host still refuses with it. - 'agentSession.setOption:agent_session_operation_capacity', - 'agentSession.send:agent_session_operation_capacity' -]) - -describe('agentSessionRefusalOperationState host oracle', () => { - // Full host retries touch the durable store and successful cases rotate its backup. - it('agrees with every refusal the real host path can produce', { timeout: 90_000 }, async () => { - const produced = new Set() - const record = (pair: Pair) => produced.add(pair) - - const stale = await createHarness() - for (const method of METHODS) { - const spec = { method, operationId: operationId(), expectedRuntimeFence: 99 } - await expect(invoke(stale, spec), method).resolves.toMatchObject({ ok: true }) - } - expect(stale.setOption).not.toHaveBeenCalled() - - const absent = await createHarness({ attached: false }) - for (const method of METHODS) { - const spec = { method, operationId: operationId() } - record( - await assertHostAgreement(absent, spec, 'agent_session_ownership_unknown', async () => ({ - harness: await createHarness(), - spec - })) - ) - } - - const operationConflict = await createHarness() - for (const method of ['agentSession.setOption', 'agentSession.send'] as const) { - record( - await assertHostAgreement( - operationConflict, - { - method, - operationId: operationId(), - payloadFingerprint: 'wrong' - }, - 'agent_session_operation_conflict' - ) - ) - } - const ledgerRefusals = await createHarness() - for (const [code, timestamp, methods] of [ - [ - 'agent_session_operation_expired', - NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 1, - ['agentSession.setOption'] - ], - ['agent_session_operation_invalid', null, METHODS] - ] as const) { - for (const method of methods) { - record( - await assertHostAgreement( - ledgerRefusals, - { - method, - operationId: timestamp === null ? 'invalid-operation-id' : operationId(timestamp) - }, - code - ) - ) - } - } - - const unknown = await createHarness() - const sendUnknown = { method: 'agentSession.send' as const, operationId: operationId() } - await expect(invoke(unknown, sendUnknown)).resolves.toMatchObject({ ok: true }) - openTestJournalHostDatabase(unknown.root) - .db.prepare( - "UPDATE agent_session_command_receipts SET result_json = '{' WHERE operation_id = ?" - ) - .run(sendUnknown.operationId) - record(await assertHostAgreement(unknown, sendUnknown, 'agent_session_operation_unknown')) - - const unreadable = await createHarness() - await unreadable.host.close(SESSION, 'evict') - const unreadableOpen = vi - .spyOn(AgentSessionJournal.prototype, 'open') - .mockRejectedValue(new Error('journal path unreadable')) - const unreadableSend = { method: 'agentSession.send' as const, operationId: operationId() } - record( - await assertHostAgreement( - unreadable, - unreadableSend, - 'agent_session_journal_unreadable', - async () => { - unreadableOpen.mockRestore() - return { harness: unreadable, spec: unreadableSend } - } - ) - ) - - const allPairs = METHODS.flatMap((method) => - AGENT_SESSION_WIRE_REFUSAL_CODES.map((code) => `${method}:${code}` as Pair) - ) - expect(new Set([...produced, ...UNREACHABLE])).toEqual(new Set(allPairs)) - expect([...produced].filter((pair) => UNREACHABLE.has(pair))).toEqual([]) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts deleted file mode 100644 index 8dffa04c3d6b..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -// The registry is the only source of an agent's definition: routing, declared capabilities and the -// option rules a chat at rest reads all follow what composition registered, and a definition this -// build ships but did not register decides nothing. - -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' -import type { StructuredAgentDefinition } from './structured-agent-definition' -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionLiveOptions -} from './structured-agent-session-adapter' -import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' -import { StructuredAgentRegistry } from './structured-agent-registry' -import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -import { - readStructuredAgentSessionOptions, - recordStructuredAgentSessionOptionIntent -} from './structured-agent-session-options-read' - -const RECORD: AgentSessionRecord = { - ...agentSessionRecordFixture(), - options: { model: 'pilot-model' } -} - -/** Claude, registered with rules unlike the ones Claude's own module declares. */ -const NON_DEFAULT: StructuredAgentDefinition = { - ...CLAUDE_STRUCTURED_AGENT, - capabilities: { ...CLAUDE_STRUCTURED_AGENT.capabilities, threadGoal: true }, - restingOptions: { - acceptsKey: (key) => key === 'pilotOption', - fallbackModels: () => [ - { id: 'pilot-model', label: 'Pilot', isDefault: true, defaultEffort: 'low', efforts: [] } - ], - effortDefaultsToModel: true - } -} - -function fakeAdapter(): StructuredAgentSessionAdapter { - return { - acquire: vi.fn(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 1, processStartTimeMs: 1, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: claudeProviderHandle('provider-session-1', null), - origin: 'created' as const, - mintedAtFence: fence, - observedAt: 1 - } - })), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn(), - compact: vi.fn(), - changeThreadGoal: vi.fn() - } -} - -function pick(agents: StructuredAgentRegistry, record: AgentSessionRecord | null, key: string) { - const persistOptions = vi.fn(async () => {}) - const result = recordStructuredAgentSessionOptionIntent( - { store: { getRecord: () => record }, agents }, - { sessionId: RECORD.sessionId, persistOptions, publish: vi.fn() }, - { key, value: 'enabled' } - ) - return { result, persistOptions } -} - -/** At rest, or from a running child that answers `live`. */ -function readAtRest(agents: StructuredAgentRegistry, live?: StructuredAgentSessionLiveOptions) { - const resting = { - child: live ? { fence: 1 } : null, - params: { provider: RECORD.provider }, - journal: { threadGoal: () => null, contextUsage: () => null, context: { floor: () => null } } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the read touches only these members. - const context = { - deps: { - adapter: live - ? { readOptions: async () => live } - : new StructuredAgentSessionAdapterRouter(agents, async () => {}), - agents, - store: { getRecord: () => RECORD } - }, - serialize: (_sessionId: string, task: () => Promise) => task(), - openConversation: async () => resting, - conversation: async () => resting - } as unknown as StructuredAgentSessionMutationContext - return readStructuredAgentSessionOptions(context, RECORD.sessionId, { readsWithoutModel: true }) -} - -describe('a registered definition', () => { - it('routes, declares capabilities and rules options at rest for its agent', async () => { - const adapter = fakeAdapter() - const agents = new StructuredAgentRegistry([{ definition: NON_DEFAULT, adapter }]) - const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) - - await router.acquire({ - identity: { - sessionId: 'session-routed', - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'claude', - providerHandle: null - }, - fence: 1, - spawnToken: 'spawn-1' - }) - expect(adapter.acquire).toHaveBeenCalledOnce() - expect(agents.capabilities(RECORD.provider)).toBe(NON_DEFAULT.capabilities) - - const accepted = pick(agents, RECORD, 'pilotOption') - await expect(accepted.result).resolves.toMatchObject({ ok: true }) - expect(accepted.persistOptions).toHaveBeenCalledWith({ - model: 'pilot-model', - pilotOption: 'enabled' - }) - // Claude's own module accepts `model`; the registration says otherwise and wins. - const refused = pick(agents, RECORD, 'model') - await expect(refused.result).resolves.toMatchObject({ ok: false }) - - const options = await readAtRest(agents) - expect(options.models.map((model) => model.id)).toEqual(['pilot-model']) - // No pick for effort: the registered rules read the model's default. - expect(options.current).toEqual({ - model: 'pilot-model', - effort: 'low', - permissionMode: 'ask' - }) - expect(options.threadGoal).toEqual({ current: null }) - }) - - it('leaves an agent this runtime did not register with no rules, whatever the build ships', async () => { - const agents = new StructuredAgentRegistry([ - { - definition: CODEX_STRUCTURED_AGENT, - adapter: { ...fakeAdapter(), rewind: vi.fn(), recoverRewind: vi.fn() } - } - ]) - - expect(agents.definition(RECORD.provider)).toBeNull() - expect(agents.capabilities(RECORD.provider)).toBeNull() - const refused = pick(agents, RECORD, 'model') - await expect(refused.result).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid', details: { reason: 'optionRejected' } } - }) - expect(refused.persistOptions).not.toHaveBeenCalled() - const options = await readAtRest(agents) - expect(options.models).toEqual([]) - expect(options.current).toEqual({ model: 'pilot-model' }) - expect(options.conversationCommands).toEqual(['clear']) - expect(options.acceptsImages).toBe(false) - }) - - it('says whether the session takes images: the definition, narrowed by a running child', async () => { - const registry = (imagePrompts: boolean) => - new StructuredAgentRegistry([ - { - definition: { - ...NON_DEFAULT, - capabilities: { ...NON_DEFAULT.capabilities, imagePrompts } - }, - adapter: fakeAdapter() - } - ]) - const live = (acceptsImages?: boolean): StructuredAgentSessionLiveOptions => ({ - models: [], - current: {}, - ...(acceptsImages === undefined ? {} : { acceptsImages }) - }) - await expect(readAtRest(registry(true))).resolves.toMatchObject({ acceptsImages: true }) - await expect(readAtRest(registry(false))).resolves.toMatchObject({ acceptsImages: false }) - await expect(readAtRest(registry(true), live())).resolves.toMatchObject({ acceptsImages: true }) - await expect(readAtRest(registry(true), live(false))).resolves.toMatchObject({ - acceptsImages: false - }) - // A child's yes never widens a definition's no. - await expect(readAtRest(registry(false), live(true))).resolves.toMatchObject({ - acceptsImages: false - }) - }) - - it('refuses a pick for a session with no record, as before', async () => { - const agents = new StructuredAgentRegistry([ - { definition: CLAUDE_STRUCTURED_AGENT, adapter: fakeAdapter() } - ]) - const missing = pick(agents, null, 'model') - await expect(missing.result).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid' } - }) - expect(missing.persistOptions).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts deleted file mode 100644 index 6b37cbde714e..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts +++ /dev/null @@ -1,262 +0,0 @@ -// What the host does with a second press of the same control, against the real host, store and -// journal: under the first press's id it answers from that press and does nothing more, which is -// why a client sends every press under a new id; under a new id it acts. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { agentSessionRefusalOperationState } from '../../../shared/agent-session-refusal-retry' -import type { AgentSessionThreadGoalChange } from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let setOption: Mock -let changeThreadGoal: Mock> -let stopBackgroundTasks: Mock> -let cancelTurn: Mock -let providerEvents: Parameters[0]['events'] - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-repeat-press-')) - resetHostTestOperationIds() - setOption = vi.fn(async () => undefined) - changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) - cancelTurn = vi.fn(async () => ({ cancelled: true })) - store = await openTestAgentSessionRecordStore(root) - const adapter: StructuredAgentSessionAdapter = startsWhenPublished( - { - acquire: async ({ fence, spawnToken, events }) => { - providerEvents = events - return { - providerChildPhase: 'starting' as const, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - acquisitionGeneration: 'generation-1', - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: fence > 1 ? ('resumed' as const) : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - } - }, - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn, - answerPrompt: vi.fn(async () => undefined), - setOption, - changeThreadGoal, - stopBackgroundTasks - }, - () => host - ) - host = new StructuredAgentSessionHost({ - agents: claudeAndCodexAgents(adapter), - logger: createStructuredAgentSessionLogger(), - store, - adapter, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-1', - // One live background task, which the background Stops below reach. - statusSink: { - publish: () => {}, - forget: () => {}, - readChildWork: () => [ - { - id: 'child-task-1', - providerId: 'task-1', - kind: 'agent', - state: 'working', - membership: 'live', - firstObservedAt: 1, - observedAt: 1, - stoppable: true, - invocation: { invocationId: 'spawn-task-1', generation: 1 } - } - ] - }, - now: () => NOW - }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) -}) - -/** The provider's running turn, which a Stop naming it reaches. */ -async function runningTurn(): Promise { - providerEvents?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 900 }, - { kind: 'turn', turnId: 'turn-1', state: 'running' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await host.flushStreamedEvents(SESSION) -} - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function envelope( - method: string, - fields: Record, - clientOperationId: string, - expectedRuntimeFence: number | null = 1 -) { - return { - sessionId: SESSION, - clientOperationId, - expectedRuntimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -function pick(value: string, id = hostTestOperationId(), fence: number | null = 1) { - const fields = { key: 'model', value } - return host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', fields, id, fence), - ...fields - }) -} - -describe('a press sent again', () => { - it('leaves an option on the other pick under the first id, and applies it under a new one', async () => { - const first = hostTestOperationId() - await pick('A', first) - await pick('B') - - expect(await pick('A', first)).toMatchObject({ ok: true, replayed: true }) - expect(setOption).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.options?.model).toBe('B') - - expect(await pick('A')).toMatchObject({ ok: true, replayed: false }) - 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 () => { - const change = (next: AgentSessionThreadGoalChange, id = hostTestOperationId()) => - host.changeThreadGoal(CALLER, { - envelope: envelope('agentSession.threadGoal', { change: next }, id), - change: next - }) - const set: AgentSessionThreadGoalChange = { kind: 'set', objective: 'Ship the parser' } - const first = hostTestOperationId() - await change(set, first) - await change({ kind: 'clear' }) - - expect(await change(set, first)).toMatchObject({ ok: true, replayed: true }) - expect(changeThreadGoal).toHaveBeenCalledTimes(2) - - expect(await change(set)).toMatchObject({ ok: true, replayed: false }) - expect(changeThreadGoal).toHaveBeenCalledTimes(3) - }) - - it('stops a background task only under a new id once the first Stop ran', async () => { - const fields = { turnId: 'background-tasks', scope: 'background-tasks', taskId: 'task-1' } - const stop = (id = hostTestOperationId()) => - host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', fields, id), - turnId: 'background-tasks', - scope: 'background-tasks' as const, - taskId: 'task-1' - }) - // The first Stop reached the task before it could be stopped; its answer never arrived. - stopBackgroundTasks.mockResolvedValueOnce({ cancelled: false }) - const first = hostTestOperationId() - await stop(first) - - expect(await stop(first)).toMatchObject({ - ok: true, - replayed: true, - value: { cancelled: false } - }) - expect(stopBackgroundTasks).toHaveBeenCalledOnce() - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(stopBackgroundTasks).toHaveBeenCalledTimes(2) - }) - - it('interrupts a named turn only under a new id once the first Stop ran', async () => { - const stop = (id = hostTestOperationId()) => - host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }, id, null), - turnId: 'turn-1' - }) - await runningTurn() - cancelTurn.mockResolvedValueOnce({ cancelled: false }) - const first = hostTestOperationId() - await stop(first) - - expect(await stop(first)).toMatchObject({ - ok: true, - replayed: true, - value: { cancelled: false } - }) - expect(cancelTurn).toHaveBeenCalledOnce() - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(cancelTurn).toHaveBeenCalledTimes(2) - }) - - it('keeps nothing of a press it turned away before running, so its own id runs next time', async () => { - const fields = { turnId: 'background-tasks', scope: 'background-tasks' } - const id = hostTestOperationId() - const stop = (fence: number) => - host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', fields, id, fence), - turnId: 'background-tasks', - scope: 'background-tasks' as const - }) - await host.close(SESSION, 'evict') - - // No agent owns the chat, so the host turns the Stop away as not ready. - expect(await stop(1)).toMatchObject({ ok: false }) - const refused = await stop(1) - expect(refused.ok ? null : agentSessionRefusalOperationState(refused.refusal.code)).toBe( - 'pending-admission' - ) - const fence = store.getRecord(SESSION)!.lease.runtimeFence - expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) - - expect(await stop(store.getRecord(SESSION)!.lease.runtimeFence)).toMatchObject({ - ok: true, - replayed: false, - value: { cancelled: true } - }) - expect(stopBackgroundTasks).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 37e13a2c71e4..ad1cd60aef82 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -43,6 +43,7 @@ export const IDLE_MS = STRUCTURED_AGENT_SESSION_IDLE_MS export const SWEEP_INTERVAL_MS = 5 export type RestTestAdapter = { + open: StructuredAgentSessionAdapter['open'] acquire: Mock closeSession: Mock> dispatch: Mock @@ -66,11 +67,16 @@ export type RestTestRig = { forget: Mock readChildWork: Mock<(subject: unknown) => AgentChildWorkView[]> } - /** Wraps an acquire so its child stays `starting`: every other child proves its start once + /** Wraps a spawn so its child stays `starting`: every other child proves its start once * published. */ heldStart: ( acquire: StructuredAgentSessionAdapter['acquire'] ) => StructuredAgentSessionAdapter['acquire'] + proveStart: (child: { + sessionId: string + fence: number + acquisitionGeneration: string + }) => Promise /** Opens a fresh host over the same store and journals: what a restart leaves behind. */ restart: (deps?: Partial) => Promise dispose: () => Promise @@ -148,6 +154,9 @@ export async function createRestTestRig( } let store = await openTestAgentSessionRecordStore(root) const adapter: RestTestAdapter = { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(async ({ fence, spawnToken }) => ({ acquisitionGeneration: `generation-${++generations}`, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, @@ -199,6 +208,7 @@ export async function createRestTestRig( statusEvents, sink, heldStart: starts.held, + proveStart: starts.prove, restart: async (overrides = {}) => { await rig.host.flushAllStreamedEvents().catch(() => undefined) store = await openTestAgentSessionRecordStore(root) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index e1a4e14cd0d9..d26f99105a54 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -69,7 +69,6 @@ export function createStructuredAgentSessionRestartResume( sessions, getRecord: deps.store.getRecord, childWork: surfaces.readChildWork, - startAnswered: (sessionId) => deps.adapter.startAnswered?.(sessionId), ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), teardownId: randomUUID(), now: surfaces.now, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts deleted file mode 100644 index c0e8ee04a552..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -// Startup restore has to publish status, not just index the session. -// -// A tab nobody reopens after a restart still owes the sidebar a row. The host restores such a -// session read-only, without a provider child, so the only thing that can surface its state is -// the status publication the restore wiring makes. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import type { - AgentSessionMutationEnvelope, - AgentSessionStatusEvent -} from '../../../shared/agent-session-wire' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } - -const hosts: StructuredAgentSessionHost[] = [] -let root = '' - -function adapter(): StructuredAgentSessionAdapter { - return { - acquire: async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - }), - dispatch: async () => ({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => undefined, - setOption: async () => undefined - } -} - -function createHost(store: AgentSessionRecordStore): StructuredAgentSessionHost { - const host: StructuredAgentSessionHost = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: startsWhenPublished(adapter(), () => host), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - probeOwner: async () => ({ - outcome: 'indeterminate', - reason: 'read does not need ownership' - }), - now: () => NOW - }) - hosts.push(host) - return host -} - -function sendEnvelope( - store: AgentSessionRecordStore, - fields: Record -): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields - }) - } -} - -/** Persists one turn, then hands back a restarted host over the same directories. */ -async function restartWithPersistedTurn(): Promise { - root = await mkdtemp(join(tmpdir(), 'orca-restart-status-')) - resetHostTestOperationIds() - const store = await openTestAgentSessionRecordStore(root) - const host = createHost(store) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - const body = hostTestMessage('persisted conversation') - const sent = await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body }) - if (!sent.ok) { - throw new Error('send was refused') - } - // Delivered, not just accepted: a message still queued at the restart was never a request. - await host.waitForSendSettlement(SESSION, sent.value.clientMessageId) - await host.flushAllStreamedEvents() - return createHost(await openTestAgentSessionRecordStore(root)) -} - -afterEach(async () => { - await Promise.all(hosts.splice(0).map((host) => host.flushAllStreamedEvents())) - await rm(root, { recursive: true, force: true }) - root = '' -}) - -describe('structured session restart status publication', () => { - // Served by the subscribe-time re-projection rather than the restore's own publish, so this - // covers what a restored journal projects — not the restore wiring. The test below pins that. - it('projects the persisted turn of a session restored without a provider', async () => { - const restarted = await restartWithPersistedTurn() - - await restarted.restoreReadableSessions() - const events: AgentSessionStatusEvent[] = [] - restarted.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) - - expect(events).toEqual([ - { - type: 'snapshot', - sessions: [ - expect.objectContaining({ - sessionId: SESSION, - workspaceId: 'workspace-1', - agent: 'codex', - status: 'idle', - latestPrompt: 'persisted conversation' - }) - ] - } - ]) - }) - - it('publishes a restored session to a list already sitting on the stream', async () => { - const restarted = await restartWithPersistedTurn() - const events: AgentSessionStatusEvent[] = [] - restarted.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) - expect(events).toEqual([{ type: 'snapshot', sessions: [] }]) - - await restarted.restoreReadableSessions() - - // The restore wiring publishes; without it this list never hears about the session at all. - expect(events.at(-1)).toEqual({ - type: 'status', - session: expect.objectContaining({ sessionId: SESSION, status: 'idle' }) - }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts index dc71bc09f071..739ad2c5a33f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts @@ -20,7 +20,6 @@ function witnessed() { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: clear reads only the record's provider. AGENTS[sessionId] ? ({ provider: AGENTS[sessionId] } as AgentSessionRecord) : null, childWork: () => undefined, - startAnswered: () => undefined, capsule: { record: async (markers) => void recorded.push([...markers]) }, teardownId: 'teardown', now: () => 1, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts index 648f92b6d595..683fe79d9e87 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts @@ -33,7 +33,6 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { sessions: ReadonlyMap> getRecord: (sessionId: string) => AgentSessionRecord | null childWork: WorkingAtStopInput['childWork'] - startAnswered: NonNullable capsule?: Pick teardownId: string now: () => number @@ -73,7 +72,6 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { session: deps.sessions.get(sessionId), getRecord: deps.getRecord, childWork: deps.childWork, - startAnswered: deps.startAnswered, trigger, teardownId: deps.teardownId, now: deps.now() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index b36a7bcb7854..1f050cf80e66 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -49,11 +49,13 @@ function adapter(): StructuredAgentSessionAdapter { { supportsCreate: (_location, agent) => agent === 'codex', supportsLocation: () => true, + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: async (input) => { acquires += 1 sink = input.events! return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4000 + acquires, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts index 88474c453736..35ea3acdfd39 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts @@ -52,11 +52,13 @@ function adapter(): StructuredAgentSessionAdapter { { supportsCreate: (_location, agent) => agent === 'codex', supportsLocation: () => true, + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: async (input) => { acquires += 1 sink = input.events! return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4000 + acquires, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index f83e5ef34848..c276eba906f7 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 @@ -73,11 +73,13 @@ beforeEach(async () => { { supportsCreate: (_location, agent) => agent === 'codex', supportsLocation: () => true, + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: async (input) => { acquires.push(input) sink = input.events! return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4000 + acquires.length, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts deleted file mode 100644 index 431569f6fa97..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ /dev/null @@ -1,699 +0,0 @@ -// A send, or a hold, that finds the session's provider child gone, against the real host. The -// send is accepted at once; its delivery restarts the child, or rejects it with the reason. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { - AgentSessionMutationEnvelope, - AgentSessionSubscribeEvent -} from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { CodexAppServerRequestError } from '../../codex/codex-app-server-request-error' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } - -/** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ -function eventually(assertion: () => void | Promise): Promise { - return vi.waitFor(assertion, { timeout: 10_000 }) -} - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let spawnChild: StructuredAgentSessionAdapter['acquire'] -let dispatch: Mock -let hostErrors: unknown[] - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-send-recovery-')) - resetHostTestOperationIds() - hostErrors = [] - let generation = 0 - spawnChild = async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - acquisitionGeneration: `generation-${++generation}`, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length - ? ('resumed' as const) - : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - }) - acquire = vi.fn(spawnChild) - dispatch = vi.fn(async () => ({ - state: 'accepted' as const, - providerIdentity: { - provider: 'codex' as const, - threadId: THREAD, - turnId: `turn-${dispatch.mock.calls.length}`, - ordinal: dispatch.mock.calls.length - } - })) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: { - warn: (_message, fields) => hostErrors.push(fields.error), - error: (_message, fields) => hostErrors.push(fields.error) - }, - store, - adapter: startsWhenPublished( - { - acquire, - dispatch, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn: vi.fn(async () => ({ cancelled: false })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - () => host - ), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - now: () => NOW - }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function sendParams(text: string, operationId = hostTestOperationId()) { - const body = hostTestMessage(text) - const envelope: AgentSessionMutationEnvelope = { - sessionId: SESSION, - clientOperationId: operationId, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - } - return { envelope, body } -} - -/** Accepted at once, before any owner exists for it; answers the message id. */ -async function accept(params: ReturnType): Promise { - const result = await host.send(CALLER, params) - expect(result, JSON.stringify(result)).toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'pending', handoverRecorded: true } } - }) - return params.envelope.clientOperationId -} - -async function submission(clientMessageId: string) { - return (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.clientMessageId === clientMessageId - ) -} - -/** The submission once delivery is done with it: handed over, or rejected unwritten. */ -async function settled(clientMessageId: string) { - await eventually(async () => { - const current = await submission(clientMessageId) - expect(current?.dispatchState !== 'pending' || current.handedOverAt !== undefined).toBe(true) - }) - return submission(clientMessageId) -} - -/** The failure rows a start the chat needed left, oldest first. */ -async function errorStatuses(): Promise { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => - item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] - ) -} - -/** The child timed out or exited: its lease is handed back and the host holds no session. */ -async function loseOwner(): Promise { - await host.close(SESSION, 'evict') - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - acquire.mockClear() -} - -describe('a send with no live owner', () => { - it('restarts the owner once and delivers against it', async () => { - await loseOwner() - - await accept(sendParams('after the child died')) - - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) - - it('accepts the send before anything restarts, and the restart hands it over', async () => { - await loseOwner() - const order: string[] = [] - const spawnChild = acquire.getMockImplementation()! - acquire.mockImplementationOnce(async (input) => { - order.push('acquire') - return spawnChild(input) - }) - const admit = store.admitMutationOperation - vi.spyOn(store, 'admitMutationOperation').mockImplementation((args) => { - order.push('admit') - return admit(args) - }) - - await accept(sendParams('accept first')) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - - expect(order).toEqual(['acquire']) - }) - - it('leaves a live owner alone', async () => { - acquire.mockClear() - - await accept(sendParams('owner is live')) - - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).not.toHaveBeenCalled() - }) - - it('restarts an independently retained old-build clear source', async () => { - await loseOwner() - await store.transitionHandoff(SESSION, (current) => ({ - ...current, - conversationCommand: { - command: 'clear', - state: 'completed', - replacementSessionId: 'session-after-clear', - operationId: hostTestOperationId(), - callerKey: CALLER.callerKey, - phase: 'committed' - } - })) - - await expect(host.send(CALLER, sendParams('into the retained chat'))).resolves.toMatchObject({ - ok: true - }) - await eventually(() => expect(acquire).toHaveBeenCalledOnce()) - }) - - it('restarts an owner that exited while the session stayed readable', async () => { - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence, - acquisitionGeneration: 'generation-1' - }) - expect(host.hasSession(SESSION)).toBe(true) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - acquire.mockClear() - - await accept(sendParams('after an exit')) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - }) - - it('restarts nothing for a resend the journal already answers', async () => { - const params = sendParams('sent once') - await accept(params) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - // The child died during startup: the lease is handed back, the fence moves, and the session - // stays readable. The client resends against the new fence. - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'startup deadline', - cause: 'unexpected-exit', - fence: params.envelope.expectedRuntimeFence ?? 0, - acquisitionGeneration: 'generation-1' - }) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - acquire.mockClear() - const resent = { - ...params, - envelope: { - ...params.envelope, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - } - } - - await expect(host.send(CALLER, resent)).resolves.toMatchObject({ ok: true, replayed: true }) - - expect(acquire).not.toHaveBeenCalled() - expect(dispatch).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - - // Retry rotates the id: a genuinely new send restarts the owner once. - await accept(sendParams('sent once')) - await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(2)) - expect(acquire).toHaveBeenCalledOnce() - }) - - /** A row admitted for this send, then the host died before the journal write, left as `outcome` - * says: `pending` by this build, `unknown` by a build that marked it before running. */ - async function admittedThenHostDied( - params: ReturnType, - outcome: 'pending' | 'unknown' - ) { - await store.admitMutationOperation({ - callerKey: CALLER.callerKey, - envelope: params.envelope, - hostFingerprint: params.envelope.payloadFingerprint, - now: NOW, - operationIdScope: 'global' - }) - if (outcome === 'unknown') { - await store.recordOperationOutcome({ - callerKey: CALLER.callerKey, - operationId: params.envelope.clientOperationId, - outcome: { status: 'unknown' } - }) - } - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: params.envelope.expectedRuntimeFence ?? 0, - acquisitionGeneration: 'generation-1' - }) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - acquire.mockClear() - return { - ...params, - envelope: { - ...params.envelope, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - } - } - } - - it('runs a send admitted but never run for the first time, restarting the owner once', async () => { - const resent = await admittedThenHostDied(sendParams('admitted, then the host died'), 'pending') - - await expect(host.send(CALLER, resent)).resolves.toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'pending' } } - }) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - }) - - it('accepts afresh when only an older ledger row exists without a command receipt', async () => { - const resent = await admittedThenHostDied(sendParams('marked unknown, then died'), 'unknown') - - await expect(host.send(CALLER, resent)).resolves.toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'pending' } } - }) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - }) - - it('accepts a send that arrives while a restart holds the queue, and hands both over in order', async () => { - await loseOwner() - const lostFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - let claimed = () => {} - let release = () => {} - const claim = new Promise((resolve) => (claimed = resolve)) - const spawn = new Promise((resolve) => (release = resolve)) - const spawnChild = acquire.getMockImplementation() - acquire.mockImplementationOnce(async (input) => { - claimed() - await spawn - return spawnChild!(input) - }) - - const first = await accept(sendParams('first')) - await claim - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(lostFence + 1) - // Written against the lost owner's fence, which admits it: a send is a conversation write. - const late = sendParams('second') - late.envelope.expectedRuntimeFence = lostFence - const second = host.send(CALLER, late) - release() - - expect(await second).toMatchObject({ ok: true }) - await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(2)) - expect(acquire).toHaveBeenCalledOnce() - expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([ - first, - late.envelope.clientOperationId - ]) - }) - - it('shares one restart between concurrent sends', async () => { - await loseOwner() - - const results = await Promise.all([ - host.send(CALLER, sendParams('first')), - host.send(CALLER, sendParams('second')), - host.send(CALLER, sendParams('third')) - ]) - - expect(results.map((result) => result.ok)).toEqual([true, true, true]) - await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(3)) - expect(acquire).toHaveBeenCalledOnce() - }) - - it('replays into a closed session without spawning anything', async () => { - const params = sendParams('sent once') - await accept(params) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - await loseOwner() - - await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) - await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) - - // The conversation was opened for the answer; the record's lease was left as it was. - expect(host.hasSession(SESSION)).toBe(true) - expect(acquire).not.toHaveBeenCalled() - expect(dispatch).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it("rejects the accepted message with the restart's own cause, and says so in the chat once", async () => { - await loseOwner() - acquire.mockRejectedValue(new Error('Not signed in. Run codex login')) - const params = sendParams('while signed out') - // The acquire's error is Orca's wrapper, not the provider's words: it goes to the log. No exit - // was observed, so the chat does not say the provider stopped. - const cause = "Codex couldn't restart. Send your message to try again." - - const id = await accept(params) - - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - reason: cause, - rejection: { kind: 'restartFailed' } - }) - expect(dispatch).not.toHaveBeenCalled() - // Accepted, so the ledger answers a resend with the rejection rather than a second attempt. - expect( - store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId) - ).toMatchObject({ outcome: { status: 'succeeded' } }) - // One row, in the error tone, so the reason outlives the error strip. - expect(await errorStatuses()).toEqual([cause]) - }) - - it("keeps Codex's own words behind a refused resume without saying the provider stopped", async () => { - await loseOwner() - const said = `no rollout found for thread id ${THREAD}` - acquire.mockRejectedValue( - new CodexAppServerRequestError('thread/resume', -32600, `thread/resume failed: ${said}`, said) - ) - const id = await accept(sendParams('after the thread went away')) - - // The sentence names no cause and quotes nothing; Codex's words ride in the fact for Details. - const rejection = { - kind: 'restartFailed', - detail: { text: said, audience: 'person' }, - refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } - } - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - reason: "Codex couldn't restart. Send your message to try again.", - rejection - }) - expect(await errorStatuses()).toEqual([ - "Codex couldn't restart. Send your message to try again." - ]) - // Orca's own text is logged once where the start failed. - expect( - hostErrors.filter( - (error) => error instanceof Error && error.message === `thread/resume failed: ${said}` - ) - ).toHaveLength(1) - }) - - it('restarts again for a Retry under a new id, and replays a resend of the same id', async () => { - await loseOwner() - acquire.mockRejectedValue(new Error('Not signed in')) - const params = sendParams('while signed out') - await settled(await accept(params)) - expect(acquire).toHaveBeenCalledTimes(1) - - // A client that resends the same id gets the recorded rejection, and the chat no second row. - await expect(host.send(CALLER, params)).resolves.toMatchObject({ - ok: true, - replayed: true, - value: { submission: { dispatchState: 'rejected' } } - }) - expect(acquire).toHaveBeenCalledTimes(1) - expect(await errorStatuses()).toHaveLength(1) - - // The outbox's Retry rotates the id: a fresh attempt, with its own row. - expect(await settled(await accept(sendParams('while signed out')))).toMatchObject({ - dispatchState: 'rejected' - }) - expect(acquire).toHaveBeenCalledTimes(2) - expect(await errorStatuses()).toHaveLength(2) - expect(dispatch).not.toHaveBeenCalled() - }) - - it('restarts and delivers a later send once the cause clears', async () => { - await loseOwner() - acquire.mockRejectedValueOnce(new Error('Not signed in')) - expect(await settled(await accept(sendParams('while signed out')))).toMatchObject({ - dispatchState: 'rejected' - }) - - // The user signed in; nothing about the failed attempt is remembered. - await accept(sendParams('signed in now')) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledTimes(2) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) - - it('suggests a new chat only when this host has nothing to restart the chat from', async () => { - await loseOwner() - acquire.mockRejectedValue(new Error('Not signed in')) - const failed = await settled(await accept(sendParams('restart fails'))) - expect(failed).toMatchObject({ dispatchState: 'rejected' }) - expect(failed?.reason).not.toMatch(/new chat/) - - // The adapter cannot run this record where it lives: no retry would bring it back. - host.deps.adapter.supportsLocation = () => false - const unresumable = await settled(await accept(sendParams('cannot resume here'))) - - expect(unresumable).toMatchObject({ - dispatchState: 'rejected', - reason: "Codex couldn't restart. Start a new chat to continue.", - rejection: { - kind: 'restartFailed', - refusal: { - code: 'structured_agent_session_unsupported', - details: { reason: 'hostUnsupported' } - } - } - }) - }) - - it('rejects the message with the cause when the restart met a lease someone else is settling', async () => { - await loseOwner() - vi.spyOn( - host['conversationDelivery'].loop['deps'], - 'ensureProviderChild' - ).mockResolvedValueOnce({ - ok: false, - refusal: { - code: 'execution_owner_reconciling', - message: 'Another runtime is still adjudicating this lease.' - } - }) - - const id = await accept(sendParams('owner being settled')) - - // The refusal's prose stays out of the chat; its code rides in the fact. - const cause = "Codex couldn't restart. Send your message to try again." - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - reason: cause, - rejection: { kind: 'restartFailed', refusal: { code: 'execution_owner_reconciling' } } - }) - expect(acquire).not.toHaveBeenCalled() - expect(await errorStatuses()).toEqual([cause]) - }) - - it('rejects the message, and reports the fault, when the restart itself faults', async () => { - await loseOwner() - vi.spyOn( - host['conversationDelivery'].loop['deps'], - 'ensureProviderChild' - ).mockRejectedValueOnce(new Error('spawn-token mint failed')) - - const id = await accept(sendParams('bookkeeping failed')) - - // Orca's own fault: reported to the log, and the chat says only that Orca failed. - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - reason: "Orca ran into a problem, so this didn't go through. Try again.", - rejection: { kind: 'hostFault' } - }) - expect(hostErrors).toContainEqual( - expect.objectContaining({ message: 'spawn-token mint failed' }) - ) - expect(await errorStatuses()).toHaveLength(1) - }) - - it('exits the recovery stage a failed attempt latched before its delivery resumes it', async () => { - await loseOwner() - // What an acquisition whose exit could not be proven leaves behind: nobody's, but latched. - await store.transitionHandoff(SESSION, (current) => ({ - ...current, - lease: { ...current.lease, handoffStage: 'recovering' } - })) - host.deps.probeOwner = async () => ({ outcome: 'pid-absent' }) - - await accept(sendParams('latched owner')) - - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - handoffStage: null, - claimStatus: 'live' - }) - }) - - it('adjudicates a lease this host has not reconciled before its delivery resumes it', async () => { - await loseOwner() - await store.transitionHandoff(SESSION, (current) => ({ - ...current, - lease: { ...current.lease, unreconciled: true } - })) - - // The send's start is the same serialized resume a hold runs, reconciliation first. - await accept(sendParams('owner unverified')) - - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - unreconciled: false, - claimStatus: 'live' - }) - }) -}) - -// A pane keeps the fence of the last frame it read. An idle release and the restart after it -// each move the lease, so that fence can be several generations behind the one a write lands on. -describe('a write fenced to an owner the pane has not seen replaced', () => { - it('delivers a send fenced to the owner an idle release retired', async () => { - const seenFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await loseOwner() - const params = sendParams('after the release') - params.envelope.expectedRuntimeFence = seenFence - - const id = await accept(params) - expect(await settled(id)).toMatchObject({ dispatchState: 'accepted' }) - - expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(seenFence + 1) - }) - - // Clients resend a refused message when the fence they hold moves. A failed start is a - // rejected message now, never a refused send, and the pane keeps the fence it subscribed under. - it("keeps the pane's fence on the rows a failed start publishes, and rejects the message", async () => { - const seenFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - const frames: AgentSessionSubscribeEvent[] = [] - await host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => frames.push(event) }) - await loseOwner() - acquire.mockRejectedValueOnce(new Error('Not signed in')) - const subscribed = frames.length - - const id = await accept(sendParams('while signed out')) - - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - rejection: { kind: 'restartFailed' } - }) - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(seenFence + 1) - const published = frames.slice(subscribed) - expect(published.length).toBeGreaterThan(0) - for (const frame of published) { - expect(frame).toMatchObject({ fence: seenFence }) - } - }) - - it('admits a Stop and a send queued behind the cold start that replaced their owner', async () => { - await loseOwner() - const lostFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - let claimed = () => {} - let release = () => {} - const claim = new Promise((resolve) => (claimed = resolve)) - const spawn = new Promise((resolve) => (release = resolve)) - acquire.mockImplementationOnce(async (input) => { - claimed() - await spawn - return spawnChild(input) - }) - - const firstParams = sendParams('starts the agent') - const first = host.send(CALLER, firstParams) - await claim - const cancelFields = { turnId: 'turn-1' } - const stop = host.cancel(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: lostFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.cancel', - sessionId: SESSION, - fields: cancelFields - }) - }, - ...cancelFields - }) - const late = sendParams('typed during the start') - late.envelope.expectedRuntimeFence = lostFence - 1 - const second = host.send(CALLER, late) - release() - - expect(await first).toMatchObject({ ok: true }) - expect(await stop).toMatchObject({ ok: true, replayed: false }) - expect(await second).toMatchObject({ ok: true, replayed: false }) - // The Stop withdrew the message its start held; the one typed after it is delivered. - expect(await settled(late.envelope.clientOperationId)).toMatchObject({ - dispatchState: 'accepted' - }) - expect(await submission(firstParams.envelope.clientOperationId)).toMatchObject({ - dispatchState: 'rejected' - }) - // The Stop ended the child still proving its start, so the late send started another. - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index ae7359e7566f..015c6607a544 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -32,6 +32,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-ho import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { structuredAgentSessionHeldStarts } from './structured-agent-session-held-start.test-support' const CALLER = { callerKey: 'client-1' } @@ -44,6 +45,7 @@ const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost +const starts = structuredAgentSessionHeldStarts(() => host) let acquire: Mock let dispatch: Mock let generation = 0 @@ -98,11 +100,7 @@ function currentChild() { } function proveStarted(): Promise { - return host.handleAdapterEvent({ - type: 'started', - ...currentChild(), - reportedOptions: { model: 'sonnet' } - }) + return starts.prove(currentChild()) } function exitBeforeProof(): Promise { @@ -137,7 +135,6 @@ beforeEach(async () => { resetHostTestOperationIds() generation = 0 acquire = vi.fn(async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -155,15 +152,18 @@ beforeEach(async () => { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire, + adapter: starts.wrap({ + acquire: starts.held(acquire), releaseAcquisition: vi.fn(async () => true), closeSession: vi.fn(async () => true), dispatch, cancelTurn: vi.fn(async () => ({ cancelled: true })), answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, + setOption: vi.fn(async () => undefined), + open: async () => ({ + reportedOptions: { model: '' } + }) + }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${generation + 1}`, @@ -175,6 +175,7 @@ beforeEach(async () => { }) afterEach(async () => { + starts.clear() await host.flushAllStreamedEvents() await rm(root, { recursive: true, force: true }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts deleted file mode 100644 index 4377cf7b4840..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -// What a client's retry sees around a failed attach settlement: a crash between -// reserve and settlement replays into the original reservation, and a settled -// failure refuses sends without ever re-dispatching on the user's behalf. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import type * as AgentSessionRecordRows from '../../runtime/agent-session-record-rows' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { - AgentSessionDispatchOutcome, - StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } - -const publishFault = vi.hoisted(() => ({ failOnPublish: 0, publishCount: 0 })) - -vi.mock('../../runtime/agent-session-record-rows', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - writeAgentSessionStoreRows: (...args: Parameters) => { - publishFault.publishCount += 1 - if (publishFault.publishCount === publishFault.failOnPublish) { - throw new Error('simulated crash before failed-settlement publish') - } - return actual.writeAgentSessionStoreRows(...args) - } - } -}) - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let releaseAcquisition: Mock> -let dispatch: Mock - -function accepted(): AgentSessionDispatchOutcome { - return { - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - } -} - -function adapter(): StructuredAgentSessionAdapter { - return startsWhenPublished( - { - acquire, - releaseAcquisition, - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - () => host - ) -} - -function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-settled-attach-retry-')) - publishFault.failOnPublish = 0 - publishFault.publishCount = 0 - resetHostTestOperationIds() - acquire = vi.fn(async ({ fence }) => ({ - providerChildPhase: 'starting' as const, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW - } - })) - releaseAcquisition = vi.fn(async () => true) - dispatch = vi.fn(async () => accepted()) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) -}) - -afterEach(async () => { - publishFault.failOnPublish = 0 - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -/** The host starting the agent with no message to deliver, as an operation that needs it does. */ -function startAgent(): Promise { - return host['serialize'](SESSION, () => host['mutationContext']().ensureAgent(SESSION)) -} - -describe('settled attach retry', () => { - it('settles a post-acquisition journal failure and retries without a restart', async () => { - const journalDatabase = openTestJournalHostDatabase(root) - vi.spyOn(AgentSessionJournal.prototype, 'open').mockRejectedValueOnce( - new Error('journal path unavailable') - ) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase, - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - const first = hostTestAttachParams(null) - - await expect(host.attach(CALLER, first)).rejects.toThrow('journal path unavailable') - expect(releaseAcquisition).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - ownerProcess: null, - reservedSpawnToken: null, - runtimeFence: 2 - }) - expect( - store.listOperationRows().find((row) => row.operationId === first.envelope.clientOperationId) - ?.outcome - ).toMatchObject({ status: 'failed' }) - - await expect(host.attach(CALLER, hostTestAttachParams(2))).resolves.toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(2) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - handoffStage: null, - runtimeFence: 3 - }) - }) - - it('continues a safe same-host pending replay with the reserved token', async () => { - const spawnTokens: string[] = [] - acquire.mockImplementation(async ({ fence, spawnToken }) => { - spawnTokens.push(spawnToken) - if (spawnTokens.length === 1) { - throw new Error('reply lost') - } - return { - providerChildPhase: 'starting' as const, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - } - }) - const mintSpawnToken = vi.fn(() => 'spawn-safe') - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken, - now: () => NOW - }) - const params = hostTestAttachParams(null) - publishFault.failOnPublish = 2 - - await expect(host.attach(CALLER, params)).rejects.toThrow( - 'agent session acquisition failure settlement failed' - ) - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true }) - expect(mintSpawnToken).toHaveBeenCalledOnce() - expect(spawnTokens).toEqual(['spawn-safe', 'spawn-safe']) - }) - - it('releases a reservation a crash left ownerless at restart, so the next start goes ahead', async () => { - const spawnTokens: string[] = [] - acquire.mockImplementation(async ({ fence, spawnToken }) => { - spawnTokens.push(spawnToken) - if (spawnTokens.length === 1) { - throw new Error('reply lost') - } - return { - providerChildPhase: 'starting' as const, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - } - }) - let token = 0 - const mintSpawnToken = vi.fn(() => `spawn-${++token}`) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken, - // A host that cannot read another process's environment, so no scan can prove anything. - probeOwner: async () => ({ - outcome: 'indeterminate', - reason: 'spawn token scan unavailable' - }), - now: () => NOW - }) - const params = hostTestAttachParams(null) - publishFault.failOnPublish = 2 - - await expect(host.attach(CALLER, params)).rejects.toThrow( - 'agent session acquisition failure settlement failed' - ) - expect(publishFault.publishCount).toBe(2) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'reserved', - handoffStage: 'new-owner-proving', - runtimeFence: 1, - reservedSpawnToken: 'spawn-1', - ownerProcess: null - }) - - await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken, - // A host that cannot read another process's environment, so no scan can prove anything. - probeOwner: async () => ({ - outcome: 'indeterminate', - reason: 'spawn token scan unavailable' - }), - now: () => NOW - }) - - await host.restoreReadableSessions() - expect(releaseAcquisition).toHaveBeenCalledTimes(1) - // No owner was recorded: released at restart, with no evidence, since nothing proved one. - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - runtimeFence: 2, - reservedSpawnToken: null, - ownerProcess: null, - deathEvidence: null - }) - - // The interrupted operation's own retry continues it as a fresh reservation. - await expect(host.attach(CALLER, params)).resolves.toMatchObject({ ok: true }) - expect(mintSpawnToken).toHaveBeenCalledTimes(2) - expect(spawnTokens).toEqual(['spawn-1', 'spawn-2']) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - runtimeKind: 'native', - runtimeFence: 3, - handoffStage: null, - handoffOperationId: null, - ownerProcess: { spawnToken: 'spawn-2' } - }) - }) - - it('settles a submission the host restart left pending, and never redelivers it', async () => { - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) - // Admitted: written to the child, acknowledgement still outstanding. The - // restart below is the process fact that ends the wait, not a stopwatch. - dispatch.mockImplementationOnce(async () => ({ state: 'admitted' as const })) - const body = hostTestMessage('written before the host died') - const unknownParams = { - envelope: envelope('agentSession.send', { body }), - body - } - const first = await host.send(CALLER, unknownParams) - expect(first).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - // Handed over before the host dies: that is what makes the restart's answer doubt. - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) - - await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-restarted', - probeOwner: async () => ({ outcome: 'pid-absent' }), - now: () => NOW - }) - await host.restoreReadableSessions() - await startAgent() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - handoffStage: null, - runtimeFence: 3 - }) - expect(dispatch).toHaveBeenCalledTimes(1) - - const newBody = hostTestMessage('are you there?') - const sent = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body: newBody }), - body: newBody - }) - if (!sent.ok) { - throw new Error(`unexpected restored send refusal: ${sent.refusal.message}`) - } - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2)) - const restoredHistory = await host.history({ sessionId: SESSION, direction: 'tail' }) - if (!restoredHistory.ok) { - throw new Error(`unexpected restored history reset: ${restoredHistory.reset}`) - } - expect( - restoredHistory.page.submissions.find( - (submission) => submission.clientMessageId === unknownParams.envelope.clientOperationId - )?.dispatchState - ).toBe('unknown') - - // A restart ends the wait without proving the dead child never took the - // frame, so even an explicit retry replays rather than sending a second copy. - const explicitRetry = await host.send(CALLER, { - ...unknownParams, - envelope: { - ...unknownParams.envelope, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 3 - }, - retryUnknown: true - }) - expect(explicitRetry).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'unknown' } } - }) - expect(dispatch).toHaveBeenCalledTimes(2) - }) - - it('records proven acquisition cleanup as durable death evidence', async () => { - acquire.mockRejectedValueOnce(new Error('resume rejected')) - - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: false, - refusal: { - message: "Codex couldn't restart. Send your message to try again.", - ownerVerdict: 'exited' - } - }) - - expect(releaseAcquisition).toHaveBeenCalledTimes(1) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - deathEvidence: { - kind: 'exit-observed', - detail: 'acquisition cleanup proved no provider child remains' - } - }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index c4f08c6a71c0..d90943123b01 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -1,5 +1,3 @@ -import { failedProviderChildStart } from './structured-agent-session-provider-child' -import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' @@ -13,6 +11,27 @@ import type { import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { failedProviderChildStart } from './structured-agent-session-provider-child' +import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' + +/** A queued message whose child died while it waited inherits that child's failure. */ +export function startThatFailedWhileQueued( + session: StructuredAgentSessionHostSession, + oldest: AgentJournalSubmission +): { startKey: string | null; cause: StructuredAgentSessionStartFailureCause } | null { + const ended = failedProviderChildStart(session) + if ( + !ended || + oldest.acceptedSequence === undefined || + ended.endedAt.epoch !== session.journal.cursor().epoch || + ended.endedAt.sequence < oldest.acceptedSequence + ) { + return null + } + const cause = structuredAgentSessionEndedChildFailure(ended) + return cause ? { startKey: ended.generation, cause } : null +} export function refusedStructuredAgentSessionStart( record: AgentSessionRecord | null, @@ -108,22 +127,3 @@ export function oldestQueuedSubmission( } return oldest } - -/** A start that died while this message waited on it — a view's, say — is the message's failed - * start: settled with it, under its key, rather than started again into the same failure. */ -export function startThatFailedWhileQueued( - session: StructuredAgentSessionHostSession, - oldest: NonNullable> -): { startKey: string | null; cause: StructuredAgentSessionStartFailureCause } | null { - const ended = failedProviderChildStart(session) - if ( - !ended || - oldest.acceptedSequence === undefined || - ended.endedAt.epoch !== session.journal.cursor().epoch || - ended.endedAt.sequence < oldest.acceptedSequence - ) { - return null - } - const cause = structuredAgentSessionEndedChildFailure(ended) - return cause ? { startKey: ended.generation, cause } : null -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index f71343d75d58..3bc7b80506b2 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 @@ -117,9 +117,9 @@ beforeEach(async () => { logger: createStructuredAgentSessionLogger(), store, adapter: { + open: () => new Promise(() => {}), acquire: vi.fn(async ({ fence, spawnToken }) => { const child = { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, 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 14767b058d1f..274ffb6f208a 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 @@ -31,13 +31,13 @@ export type StructuredAgentSessionStartupAttempt = { readonly options?: Readonly> /** The permission mode the start launches with; absent for an agent with none. */ readonly permissionMode?: AgentChatPermissionMode - /** Provider events may begin before acquisition returns. */ + /** Provider events may begin before spawn returns. */ readonly events?: StructuredAgentSessionEventSink /** Background work this start's child reports, scoped to the attempt. Unset until the host routes * child work by attempt; adapters report through their registration until then. */ readonly childWork?: (evidence: AgentChildWorkEvidence[]) => void /** Aborted by a close, a Stop admitted now, quit, or the startup limit: the adapter stops what it - * started and the acquire fails. */ + * started and spawn fails. */ readonly signal?: AbortSignal /** Any stdout frame or stderr line from the child: proof the start is still moving. */ readonly onOutput?: () => void 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 8e60ca568978..707d01677ba6 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 @@ -48,10 +48,17 @@ let closeSession: Mock let startupLimits: Partial | undefined const providerStarted = vi.fn() +const openGates = new Map void>() + +async function proveOpen(model = 'default'): Promise { + const generation = `generation-${acquire.mock.calls.length}` + await eventually(() => expect(openGates.has(generation)).toBe(true)) + openGates.get(generation)!(model) + openGates.delete(generation) +} function acquisition(input: StructuredAgentSessionAcquireInput): AgentSessionAcquisition { return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, @@ -69,7 +76,7 @@ function acquisition(input: StructuredAgentSessionAcquireInput): AgentSessionAcq } } -/** An acquire that publishes at spawn, as every one does: the child proves its start later. */ +/** A spawn that publishes its child before open proves readiness. */ const publishFirst: StructuredAgentSessionAdapter['acquire'] = async (input) => acquisition(input) async function startHost(): Promise { @@ -78,6 +85,10 @@ async function startHost(): Promise { logger: createStructuredAgentSessionLogger(), store, adapter: { + open: ({ acquisitionGeneration }) => + new Promise((resolve) => { + openGates.set(acquisitionGeneration!, (model) => resolve({ reportedOptions: { model } })) + }), acquire, dispatch, closeSession, @@ -119,6 +130,7 @@ beforeEach(async () => { resetHostTestOperationIds() startupLimits = undefined providerStarted.mockReset() + openGates.clear() acquire = vi.fn(async (input) => acquisition(input)) dispatch = vi.fn(async () => ({ state: 'accepted' as const, @@ -177,16 +189,6 @@ async function submission(id: string): Promise { const id = await accept(text) @@ -220,7 +222,7 @@ const HOST_STOPPED = agentSessionFailureWords(agentSessionFailureFact('hostStopp }) describe('a send while the agent starts', () => { - it('is accepted at once and handed over only once the start proves itself', async () => { + it('keeps a spawned child unopened until required protocol open proves readiness', async () => { await restartWith(publishFirst) const id = await heldBehindStart('hello') @@ -228,7 +230,7 @@ describe('a send while the agent starts', () => { expect((await submission(id))?.handedOverAt).toBeUndefined() expect(dispatch).not.toHaveBeenCalled() - await host.handleAdapterEvent(startedEvent()) + await proveOpen() await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) expect(dispatch).toHaveBeenCalledTimes(1) @@ -241,37 +243,19 @@ describe('a send while the agent starts', () => { // The same id again is the same message: it is recorded and handed over once. await accept('first', first) - await host.handleAdapterEvent(startedEvent()) + await proveOpen() await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([first, second]) }) - it('ignores a start proof from a child it no longer holds', async () => { - await restartWith(publishFirst) - const id = await heldBehindStart('hello') - - await host.handleAdapterEvent(startedEvent('generation-stale')) - await host.handleAdapterEvent({ ...startedEvent(), fence: startedEvent().fence + 1 }) - await host.flushStreamedEvents(SESSION) - - expect(dispatch).not.toHaveBeenCalled() - expect((await host.readStatusSummary(SESSION))?.hostExecutionPhase).toBe('starting') - await host.handleAdapterEvent(startedEvent()) - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - }) - it('tells the model catalog once the start proves itself, never for a child it no longer holds', async () => { await restartWith(publishFirst) providerStarted.mockClear() const id = await heldBehindStart('hello') expect(providerStarted).not.toHaveBeenCalled() - await host.handleAdapterEvent(startedEvent('generation-stale')) - await host.flushStreamedEvents(SESSION) - expect(providerStarted).not.toHaveBeenCalled() - - await host.handleAdapterEvent(startedEvent()) + await proveOpen() await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) expect(providerStarted).toHaveBeenCalledTimes(1) expect(providerStarted.mock.calls[0]?.[0]).toMatchObject({ sessionId: SESSION }) @@ -310,6 +294,8 @@ describe('a start that ends before it proves itself', () => { }) expect((await submission(id))?.handedOverAt).toBeUndefined() expect(closeSession).toHaveBeenCalled() + const rows = (await host.journalSnapshot(SESSION)).items.map(({ body }) => body) + expect(rows).toContainEqual({ kind: 'status', text: 'Cancellation requested.' }) expect(dispatch).not.toHaveBeenCalled() expect(startupAttemptOpen()).toBe(false) }) @@ -409,9 +395,9 @@ describe('a start that ends before it proves itself', () => { it('is not ended by the limit once it proved itself', async () => { startupLimits = { silenceMs: 50 } await restartWith(publishFirst) - const id = await heldBehindStart('hello', 0) - await host.handleAdapterEvent(startedEvent()) - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted'), 0) + const id = await heldBehindStart('hello') + await proveOpen() + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) await vi.advanceTimersByTimeAsync(100) @@ -425,11 +411,13 @@ describe('what a started child reports', () => { it('keeps provider reports out of the saved next-message choice', async () => { await restartWith(publishFirst) const id = await heldBehindStart('hello') - await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'reported' } }) + await proveOpen('reported') await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) await host.handleAdapterEvent({ - ...startedEvent(), type: 'options-reported', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, reportedOptions: { model: 'from-settings' } }) await host.flushStreamedEvents(SESSION) @@ -445,11 +433,13 @@ describe('what a started child reports', () => { options: { model: 'picked' }, now: NOW }) - await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'reported' } }) + await proveOpen('reported') await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) await host.handleAdapterEvent({ - ...startedEvent(), type: 'options-reported', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, reportedOptions: { model: 'from-settings' } }) await host.flushStreamedEvents(SESSION) @@ -489,7 +479,7 @@ describe('a chat after startup permission backfill', () => { await restartWith(publishFirst) await heldBehindStart('hello') expect(acquire.mock.calls.at(-1)![0].permissionMode).toBe(mode) - await host.handleAdapterEvent(startedEvent()) + await proveOpen() await vi.advanceTimersByTimeAsync(50) await host.flushStreamedEvents(SESSION) expect(store.getRecord(SESSION)?.options?.permissionMode).toBe(mode) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts index 936a2bd202f8..cd1bf63a09c8 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 @@ -71,7 +71,6 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ spawnToken: input.spawnToken }, acquisitionGeneration: `generation-${acquire.mock.calls.length}`, - providerChildPhase: 'starting' as const, link: { linkId: `link-${input.fence}`, handle: handle(definition), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts deleted file mode 100644 index 841b35825589..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { - STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS as CEILING_MS, - STRUCTURED_AGENT_SESSION_STARTUP_SILENCE_MS as SILENCE_MS -} from './structured-agent-session-startup-attempt-contract' -import { - StructuredAgentSessionStartupAttempts, - mintStructuredAgentSessionStartupAttempt, - type StructuredAgentSessionExpiredStartup, - type StructuredAgentSessionStartupSettled -} from './structured-agent-session-startup-attempt' - -const SESSION = 'session-alpha' -const CHILD = { generation: 'generation-1', fence: 3 } - -let expired: StructuredAgentSessionExpiredStartup[] -let settled: StructuredAgentSessionStartupSettled[] -let attempts: StructuredAgentSessionStartupAttempts - -function mint() { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mint reads only the lease fence, location, account home and options this literal sets. - const record = { - lease: { runtimeFence: CHILD.fence }, - location: { executionHostId: 'local', workspaceId: 'workspace-1' }, - accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' } - } as unknown as AgentSessionRecord - return mintStructuredAgentSessionStartupAttempt({ - record, - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: null - }, - spawnToken: 'spawn-1' - }) -} - -/** Output every `everyMs` for `forMs`. */ -function chatter(output: () => void, forMs: number, everyMs = 10_000): void { - for (let elapsed = 0; elapsed < forMs; elapsed += everyMs) { - vi.advanceTimersByTime(everyMs) - output() - } -} - -beforeEach(() => { - vi.useFakeTimers() - expired = [] - settled = [] - attempts = new StructuredAgentSessionStartupAttempts({ - expire: (startup) => expired.push(startup), - settled: (startup) => settled.push(startup) - }) -}) - -afterEach(() => { - attempts.dispose() - vi.useRealTimers() -}) - -describe('the startup clock', () => { - it('does not run before the spawn: resolving a launch is not starting', () => { - attempts.track(SESSION, mint()) - - vi.advanceTimersByTime(CEILING_MS * 2) - - expect(expired).toEqual([]) - }) - - it('fails a spawned agent that says nothing for the silence limit, naming the acquire', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - - vi.advanceTimersByTime(SILENCE_MS - 1) - expect(expired).toEqual([]) - vi.advanceTimersByTime(1) - - expect(expired).toEqual([{ sessionId: SESSION, attemptId: attempt.attemptId, child: null }]) - vi.advanceTimersByTime(CEILING_MS) - expect(expired).toHaveLength(1) - // Measured at the limit, recorded once the aborted acquire settles. - expect(settled).toEqual([]) - attempts.abandon(SESSION, attempt.attemptId) - expect(settled).toEqual([{ agent: 'codex', outcome: 'silent', durationMs: SILENCE_MS }]) - }) - - it('records a start that turned ready as the limit fired as ready', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - vi.advanceTimersByTime(SILENCE_MS) - expect(expired).toHaveLength(1) - - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'ready' }) - - expect(settled).toEqual([{ agent: 'codex', outcome: 'ready', durationMs: SILENCE_MS }]) - }) - - it('keeps a slow start that is still talking past the silence limit', () => { - const attempt = mint() - const progress = attempts.track(SESSION, attempt) - progress.spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) - - chatter(progress.output, SILENCE_MS * 3) - attempts.ready(SESSION, CHILD) - - expect(expired).toEqual([]) - expect(settled).toEqual([{ agent: 'codex', outcome: 'ready', durationMs: SILENCE_MS * 3 }]) - expect(attempts.isOpen(SESSION)).toBe(false) - }) - - it('times silence from the last output, not the spawn', () => { - const progress = attempts.track(SESSION, mint()) - progress.spawned() - vi.advanceTimersByTime(SILENCE_MS - 1_000) - progress.output() - - vi.advanceTimersByTime(SILENCE_MS - 1) - expect(expired).toEqual([]) - vi.advanceTimersByTime(1) - - expect(expired).toHaveLength(1) - }) - - it('fails a chatty agent that never becomes ready at the ceiling', () => { - const attempt = mint() - const progress = attempts.track(SESSION, attempt) - progress.spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) - - chatter(progress.output, CEILING_MS - 10_000) - expect(expired).toEqual([]) - chatter(progress.output, 10_000) - - expect(expired).toEqual([{ sessionId: SESSION, attemptId: attempt.attemptId, child: CHILD }]) - expect(settled).toEqual([{ agent: 'codex', outcome: 'ceiling', durationMs: CEILING_MS }]) - }) - - it('starts on output or publication when no spawn was reported', () => { - const byOutput = mint() - attempts.track(SESSION, byOutput).output() - const byPublish = mint() - attempts.track('session-beta', byPublish) - attempts.published('session-beta', byPublish.attemptId, { ...CHILD, phase: 'starting' }) - - vi.advanceTimersByTime(SILENCE_MS) - - expect(expired.map((startup) => startup.attemptId).sort()).toEqual( - [byOutput.attemptId, byPublish.attemptId].sort() - ) - }) -}) - -describe('an attempt ends', () => { - it('with the start its child proved, and ignores a stale child’s proof', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) - - attempts.ready(SESSION, { ...CHILD, generation: 'generation-0' }) - expect(attempts.isOpen(SESSION)).toBe(true) - attempts.ready(SESSION, CHILD) - vi.advanceTimersByTime(CEILING_MS) - - expect(expired).toEqual([]) - expect(attempts.isOpen(SESSION)).toBe(false) - }) - - it('with its child, however the child ended, leaving no entry or timer', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) - - attempts.childEnded(SESSION, { ...CHILD, generation: 'generation-0' }) - expect(attempts.isOpen(SESSION)).toBe(true) - attempts.childEnded(SESSION, CHILD) - - expect(attempts.isOpen(SESSION)).toBe(false) - expect(vi.getTimerCount()).toBe(0) - expect(settled.map((startup) => startup.outcome)).toEqual(['ended']) - }) - - it('once an expiry has stopped its published child', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) - - vi.advanceTimersByTime(SILENCE_MS) - - expect(expired).toHaveLength(1) - expect(attempts.isOpen(SESSION)).toBe(false) - expect(vi.getTimerCount()).toBe(0) - }) - - it('when an acquire the expiry aborted is abandoned, or publishes late and is expired then', () => { - const abandoned = mint() - attempts.track(SESSION, abandoned).spawned() - vi.advanceTimersByTime(SILENCE_MS) - expect(attempts.isOpen(SESSION)).toBe(true) - attempts.abandon(SESSION, abandoned.attemptId) - expect(attempts.isOpen(SESSION)).toBe(false) - - const late = mint() - attempts.track(SESSION, late).spawned() - vi.advanceTimersByTime(SILENCE_MS) - attempts.published(SESSION, late.attemptId, { ...CHILD, phase: 'starting' }) - - expect(expired.map((startup) => startup.child)).toEqual([null, null, CHILD]) - expect(attempts.isOpen(SESSION)).toBe(false) - expect(vi.getTimerCount()).toBe(0) - // Each start is measured once, at the limit that ended it. - expect(settled.map((startup) => startup.outcome)).toEqual(['silent', 'silent']) - }) - - it('with an acquire that answered ready, or an attach that failed', () => { - const ready = mint() - attempts.track(SESSION, ready).spawned() - attempts.published(SESSION, ready.attemptId, { ...CHILD, phase: 'ready' }) - const failed = mint() - attempts.track('session-beta', failed).spawned() - attempts.abandon('session-beta', failed.attemptId) - - vi.advanceTimersByTime(CEILING_MS) - - expect(expired).toEqual([]) - expect(vi.getTimerCount()).toBe(0) - }) - - it('when the next attempt replaces it, and every clock stops at quit', () => { - const first = mint() - const firstProgress = attempts.track(SESSION, first) - firstProgress.spawned() - const second = mint() - attempts.track(SESSION, second).spawned() - // The replaced attempt's reports reach nothing. - firstProgress.output() - attempts.abandon(SESSION, first.attemptId) - expect(attempts.isOpen(SESSION)).toBe(true) - - attempts.dispose() - vi.advanceTimersByTime(CEILING_MS) - - expect(expired).toEqual([]) - expect(attempts.isOpen(SESSION)).toBe(false) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts index 4b16f3f12ad6..90bbb3680299 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 @@ -58,11 +58,11 @@ export function isStructuredAgentSessionStartupExpired(reason: unknown): boolean return reason instanceof Error && reason.name === 'StructuredAgentSessionStartupExpiredError' } -/** Where an expired attempt stood: still inside its acquire, or published as a starting child. */ +/** Where an expired attempt stood: still spawning, or published as a starting child. */ export type StructuredAgentSessionExpiredStartup = { sessionId: string attemptId: string - /** Null while the acquire has not returned. */ + /** Null while spawn has not returned. */ child: StructuredAgentSessionProviderChildIdentity | null } @@ -74,7 +74,7 @@ export type StructuredAgentSessionStartupSettled = { durationMs: number } -/** What the host learns about a start while its acquire runs. */ +/** What the host learns about a start while spawn runs. */ export type StructuredAgentSessionStartupProgress = { /** The process exists: the clock starts. */ spawned: () => void @@ -89,7 +89,7 @@ type Tracked = { lastOutputAt: number timer: ReturnType | null expired: boolean - /** The limit's measure, held until an acquire it aborted settles: one that is ready anyway + /** The limit's measure, held until an aborted spawn settles: one that is ready anyway * reports that instead. */ expiry: StructuredAgentSessionStartupSettled | null readiness: PromiseWithResolvers<'ready' | 'ended'> @@ -105,7 +105,7 @@ export class StructuredAgentSessionStartupAttempts { constructor( private readonly deps: { - /** Runs outside the session's queue: an acquire may hold it for the whole handshake. */ + /** Runs outside the session's queue: spawn or open may hold it through the handshake. */ expire: (expired: StructuredAgentSessionExpiredStartup) => void /** Never throws back into the clock. */ settled?: (settled: StructuredAgentSessionStartupSettled) => void @@ -285,7 +285,7 @@ export class StructuredAgentSessionStartupAttempts { } } - /** `reason` null: a start whose limit already passed inside its acquire was published now. */ + /** `reason` null: a start whose limit already passed inside spawn was published now. */ private expire(sessionId: string, tracked: Tracked, reason: 'silent' | 'ceiling' | null): void { if (this.open.get(sessionId) !== tracked || this.disposed) { return @@ -295,7 +295,7 @@ export class StructuredAgentSessionStartupAttempts { } tracked.expired = true // A published child's stop is under way and ends nothing a newer attempt owns; one still - // acquiring stays tracked until its aborted acquire is abandoned or published. + // spawning stays tracked until its aborted spawn is abandoned or published. if (tracked.child) { this.report(tracked.expiry) this.end(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts index d514035812f2..f28abc333f16 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts @@ -52,6 +52,9 @@ async function stopWhileTheTurnOpens( agents: NO_STRUCTURED_AGENTS, agent: 'codex', adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(), dispatch: vi.fn(), closeSession: vi.fn(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts index 5f3a934e91e9..60582c11ddf9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts @@ -81,6 +81,9 @@ async function stopEndingTheChild(options: { agents: NO_STRUCTURED_AGENTS, agent: 'codex', adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(), dispatch: vi.fn(), closeSession: vi.fn(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-receipts.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-receipts.test.ts index 5a890036f753..1bfddabfc5ba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-receipts.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-receipts.test.ts @@ -134,6 +134,8 @@ it('is saved before it interrupts, and a retry of its id never stops the newer t expect((await rig.submission(behind))?.dispatchState).not.toBe('rejected') expect((await rig.submission(newer))?.dispatchState).not.toBe('rejected') expect(await rig.drafts()).toEqual([]) + expect(await rig.stop()).toMatchObject({ ok: true, replayed: false, value: { cancelled: true } }) + expect(rig.cancelTurn).toHaveBeenCalledTimes(2) }) // Round 1: a late Stop acted on newer work while the next message was handed over. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts deleted file mode 100644 index 3dd914f647fa..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ /dev/null @@ -1,873 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// The lifetime of a provider child, against the real host rather than a double. -// -// Two leaks meet here: a chat that closes without stopping its app-server, and a launch that -// starts one for every record on disk. A view never starts or keeps a child; work starts one, the -// idle sweep stops it, and an exit is settled and left for the next send. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionMutationEnvelope, - AgentSessionSubscribeEvent -} from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { - AgentSessionAcquisitionRootExitObservedError, - type StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const UNEXPECTED_PROVIDER_EXIT_OUTCOME = - 'Codex stopped while this response was in progress. You can continue in this conversation.' - -const CALLER = { callerKey: 'client-1' } -/** Short enough to keep the suite fast; the host clock below decides what is idle. */ -const SWEEP_MS = 5 -const IDLE_MS = 1_000 - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let closeSession: Mock> -let dispatch: Mock -let sink: StructuredAgentSessionEventSink | null -let hostErrors: unknown[] -let statusSink: StructuredAgentSessionStatusSink -let clock: number -function adapter(): StructuredAgentSessionAdapter { - return startsWhenPublished( - { - acquire, - closeSession, - releaseAcquisition: vi.fn(async () => true), - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: false })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - () => host - ) -} - -function openHost( - probeOwner?: (record: AgentSessionRecord) => Promise -): void { - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: { - warn: (_message, fields) => hostErrors.push(fields.error), - error: (_message, fields) => hostErrors.push(fields.error) - }, - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, - now: () => clock, - statusSink, - ...(probeOwner ? { probeOwner } : {}) - }) -} - -/** A fresh app generation over the same durable store, with its owner proven gone. */ -async function reboot(): Promise { - await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(root) - openHost(async () => ({ outcome: 'pid-absent' })) - acquire.mockClear() - closeSession.mockClear() -} - -async function attach(): Promise { - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) -} - -/** What a send's delivery or `agentSession.ensure` does: attach at the record's current fence. */ -async function startAgent(): Promise { - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) -} - -function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -function emitTurnLifecycle(state: 'running' | 'completed', ordinal: number): void { - sink?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal }, - { kind: 'status', text: state, turnLifecycle: { turnId: 'turn-1', state } }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) -} - -/** The sweep stops the child first and closes the conversation last. */ -function waitForEviction(): Promise { - return vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(host.hasSession(SESSION)).toBe(false) - }) -} - -/** Long enough for many sweep ticks, so "not stopped" means the sweep declined. */ -function waitOutSeveralSweeps(): Promise { - return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) -} - -/** Fails the next eviction at `drain-published`, which leaves the session indexed for a retry. The - * stop drains once before it, to judge whether it ends work. */ -function failEvictionDrain(): void { - const sink = host['runtimeState'].eventSinkFor(SESSION) - const drained = sink.drained.bind(sink) - vi.spyOn(sink, 'drained') - .mockImplementationOnce(drained) - .mockResolvedValueOnce({ ok: false, error: new Error('drain barrier lost') }) -} - -/** The submissions as they stood when the session was forgotten; its journal is gone after that. */ -async function failJournalSinkUntilReleased(): Promise { - const session = ( - host as unknown as { - sessions: Map Promise } }> - } - ).sessions.get(SESSION) - expect(session).toBeDefined() - vi.spyOn(session!.journal, 'appendItem').mockRejectedValueOnce(new Error('disk unavailable')) - sink?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'lost write' }] }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - deathEvidence: { kind: 'exit-observed' } - }) - }) -} - -/** Replaces the failed cached sink so suite cleanup can drain the host. */ -function replaceFailedSink(): void { - ;( - host as unknown as { - runtimeState: { eventSinkFor: (sessionId: string) => unknown } - } - ).runtimeState.eventSinkFor(SESSION) -} - -function captureSettledSubmissions(): { value: AgentJournalSubmission[] } { - const captured: { value: AgentJournalSubmission[] } = { value: [] } - const journal = host['sessions'].get(SESSION)!.journal - const closeJournal = journal.close.bind(journal) - vi.spyOn(journal, 'close').mockImplementation(async () => { - captured.value = journal.snapshot().submissions - await closeJournal() - }) - return captured -} - -async function sendPending(text: string): Promise { - dispatch.mockResolvedValueOnce({ state: 'admitted' }) - const body = hostTestMessage(text) - expect( - await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-surface-lifetime-')) - resetHostTestOperationIds() - sink = null - hostErrors = [] - clock = NOW - statusSink = { publish: vi.fn(), forget: vi.fn() } - let generation = 0 - acquire = vi.fn(async ({ fence, spawnToken, events }) => { - sink = events ?? null - return { - providerChildPhase: 'starting' as const, - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - acquisitionGeneration: `generation-${++generation}`, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length - ? ('resumed' as const) - : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - } - }) - closeSession = vi.fn(async () => true) - dispatch = vi.fn(async () => ({ - state: 'rejected' as const, - ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { - surface: 'rejection' - }) - })) - store = await openTestAgentSessionRecordStore(root) - openHost() -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -describe('a chat that closes', () => { - it('stops the provider child it started', async () => { - await attach() - - await host.close(SESSION, 'evict') - - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(host.hasSession(SESSION)).toBe(false) - expect(hostErrors).toEqual([]) - // The record and its journal stay; only the process and the claim on it go. - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null, - deathEvidence: { kind: 'exit-observed' } - }) - }) - - // The pane outlives the close by a few frames — a workspace delete closes the chats inside it - // while their panes are still mounted. A read in that window reopens the conversation as a cache - // and is answered, never refused; it starts nothing. - it('answers a read from the pane that outlived it without starting a child', async () => { - await attach() - - await host.close(SESSION, 'evict') - expect(host.hasSession(SESSION)).toBe(false) - - expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) - const unsubscribe = await host.subscribe({ - id: 'sub-1', - sessionId: SESSION, - emit: () => undefined - }) - unsubscribe() - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('answers a compatibility wait with what eviction recorded', async () => { - await attach() - dispatch.mockResolvedValueOnce({ state: 'admitted' }) - const body = hostTestMessage('pending until close') - const result = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - expect(result).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - if (!result.ok) { - throw new Error('send was refused') - } - // Handed over first: a message still queued at close is rejected as never sent instead. - await vi.waitFor(() => expect(dispatch).toHaveBeenCalled()) - const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId) - - await host.close(SESSION, 'evict') - - // Eviction's settlement is a journal write, so the wait sees it rather than timing out. - await expect(settlement).resolves.toMatchObject({ - value: { - submission: { dispatchState: 'unknown', reason: 'provider_closed_before_acknowledgement' } - } - }) - }) - - it('keeps the stop it made when the journal close loses its result', async () => { - await attach() - const session = host['sessions'].get(SESSION) - expect(session).toBeDefined() - const closeJournal = session!.journal.close.bind(session!.journal) - vi.spyOn(session!.journal, 'close') - .mockImplementationOnce(async () => { - await closeJournal() - throw new Error('journal close result lost') - }) - .mockImplementation(closeJournal) - - // The child is stopped and the lease released before the handle closes; the entry is dropped - // before that close, so a lost result leaves no closing handle for a reader to find. - await expect(host.close(SESSION, 'evict')).rejects.toThrow('journal close result lost') - expect(host.hasSession(SESSION)).toBe(false) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(closeSession).toHaveBeenCalledOnce() - }) - - it('reports a failed drain after the child stopped, and still settles and releases', async () => { - await attach() - dispatch.mockResolvedValueOnce({ state: 'admitted' }) - const body = hostTestMessage('pending across an aborted eviction') - const sent = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - const session = host['sessions'].get(SESSION) - expect(session).toBeDefined() - failEvictionDrain() - const settled = captureSettledSubmissions() - - // The child is proven gone: the failed drain is bookkeeping, reported, and the rest still runs. - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(session!.child).toBeNull() - expect(closeSession).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - expect(hasUnansweredStructuredAgentSessionDispatch(settled.value)).toBe(false) - }) -}) - -describe('a session with a turn in flight', () => { - it('is not stopped while the turn runs, and is once it ends and idles', async () => { - await attach() - emitTurnLifecycle('running', 1) - await host.flushStreamedEvents(SESSION) - - clock += IDLE_MS - await waitOutSeveralSweeps() - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - - emitTurnLifecycle('completed', 2) - await host.flushStreamedEvents(SESSION) - clock += IDLE_MS - - await waitForEviction() - }) - - // Codex settles an admitted send only on its echo, which may never come; the stop retires it. - it('is stopped with an admitted send outstanding once no turn runs', async () => { - await attach() - await sendPending('admitted, never echoed') - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - - clock += IDLE_MS - - await waitForEviction() - }) -}) - -describe('startup', () => { - it('settles an idle absent owner without chat pollution and resumes the same provider identity', async () => { - await attach() - const beforeRestart = store.getRecord(SESSION) - await abandonStructuredAgentSessionHost(host) - - store = await openTestAgentSessionRecordStore(root) - openHost(async () => ({ outcome: 'pid-absent' })) - await host.restoreReadableSessions() - - const restored = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(restored.ok && restored.page.items.some((item) => item.body.kind === 'status')).toBe( - false - ) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - - await startAgent() - expect(store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toEqual( - beforeRestart?.providerHandleChain.at(-1)?.handle - ) - expect(store.getRecord(SESSION)?.providerHandleChain.at(-1)?.origin).toBe('resumed') - }) - - it('restores a session for reading without spawning a provider child', async () => { - await attach() - await reboot() - - await host.restoreReadableSessions() - - // The record is readable — the tab comes back, history answers — and nothing is running. - expect(acquire).not.toHaveBeenCalled() - expect(host.listSessionTabs()).toEqual([ - { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } - ]) - expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('gives the child back for work, never for a read', async () => { - await attach() - await reboot() - await host.restoreReadableSessions() - - const unsubscribe = await host.subscribe({ - id: 'viewer-1', - sessionId: SESSION, - emit: () => undefined - }) - expect(acquire).not.toHaveBeenCalled() - await startAgent() - unsubscribe() - - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - runtimeKind: 'native', - ownerProcess: { pid: 4242 } - }) - }) -}) - -describe('a session closed and started again', () => { - it('publishes provider events to the reattached chat', async () => { - await attach() - await host.close(SESSION, 'evict') - expect(host.hasSession(SESSION)).toBe(false) - - await startAgent() - const events: AgentSessionSubscribeEvent[] = [] - const unsubscribe = await host.subscribe({ - id: 'subscriber-1', - sessionId: SESSION, - emit: (event) => events.push(event) - }) - sink?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 1 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'back again' }] }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - sink?.publish() - await host.flushStreamedEvents(SESSION) - unsubscribe() - - expect(JSON.stringify(events)).toContain('back again') - }) -}) - -async function submissionState(clientMessageId: string): Promise { - return (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.clientMessageId === clientMessageId - )?.dispatchState -} - -describe('an unexpected provider exit', () => { - it('publishes terminal settlement to a waiting older client', async () => { - await attach() - dispatch.mockResolvedValueOnce({ state: 'admitted' }) - const body = hostTestMessage('pending until provider exit') - const result = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - expect(result).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - if (!result.ok) { - throw new Error('send was refused') - } - // Accepted first; the exit must meet a message the provider was handed. - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - - await expect(settlement).resolves.toMatchObject({ - value: { submission: { dispatchState: 'unknown' } } - }) - }) - - it('turns a journal sink failure into observed-exit settlement and lease release', async () => { - await attach() - - await failJournalSinkUntilReleased() - - expect(dispatch).not.toHaveBeenCalled() - const history = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect( - history.ok && - history.page.items.some( - (item) => item.body.kind === 'status' && item.body.text.includes('journal sink failure') - ) - ).toBe(false) - replaceFailedSink() - }) - - // TypeScript accepts the body; the persisted reader rejects a call id that is only spaces. - it('stops a provider whose output would not read back, and the next send starts a child', async () => { - await attach() - - sink?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, - { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'tool-call', name: 'Bash', input: null, callId: ' ' }] - }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) - }) - expect(hostErrors).toContainEqual(expect.objectContaining({ code: 'journal_row_rejected' })) - replaceFailedSink() - - // Nothing restarts it on its own; the next send does, and the chat still loads. - expect(acquire).toHaveBeenCalledOnce() - await startAgent() - expect(acquire).toHaveBeenCalledTimes(2) - emitTurnLifecycle('running', 2) - await host.flushAllStreamedEvents() - await reboot() - expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) - }) - - it('settles a journal sink failure whose stop saw the provider root exit', async () => { - await attach() - // The lease follows the root, so its seen exit settles like a proven one. - closeSession.mockRejectedValueOnce( - new AgentSessionAcquisitionRootExitObservedError(new Error('provider close unproven')) - ) - - await failJournalSinkUntilReleased() - - replaceFailedSink() - }) - - it('releases the exact generation, starts nothing, and the next message starts a child', async () => { - await attach() - dispatch.mockRejectedValueOnce(new Error('provider delivery became unknown')) - const unknownBody = hostTestMessage('message with unknown delivery') - const unknownEnvelope = envelope('agentSession.send', { body: unknownBody }) - await expect( - host.send(CALLER, { envelope: unknownEnvelope, body: unknownBody }) - ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - // Accepted, then handed over by the delivery loop, where the thrown dispatch becomes doubt. - await vi.waitFor(async () => - expect(await submissionState(unknownEnvelope.clientOperationId)).toBe('unknown') - ) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - - const recoveredHistory = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect( - recoveredHistory.ok && - hasUnansweredStructuredAgentSessionDispatch(recoveredHistory.page.submissions) - ).toBe(false) - // No respawn: the exit is settled and shown, and the conversation waits for work. - expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - runtimeFence: exitedFence + 1 - }) - dispatch.mockResolvedValueOnce({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-next', ordinal: 1 } - }) - const body = hostTestMessage('a distinct next message') - const nextEnvelope = envelope('agentSession.send', { body }) - await expect(host.send(CALLER, { envelope: nextEnvelope, body })).resolves.toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await vi.waitFor(async () => - expect(await submissionState(nextEnvelope.clientOperationId)).toBe('accepted') - ) - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).toHaveBeenCalledTimes(2) - }) - - it('does not reacquire for a live reader or a stale child generation', async () => { - await attach() - const unsubscribe = await host.subscribe({ - id: 'subscriber-1', - sessionId: SESSION, - emit: () => undefined - }) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'stale child exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-stale' - }) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'current child exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - runtimeFence: exitedFence + 1, - deathEvidence: { kind: 'exit-observed' } - }) - unsubscribe() - }) - - it('ends the record of a requested close as a close, never as a crash to recover from', async () => { - await attach() - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider closed', - cause: 'requested-close', - fence, - acquisitionGeneration: 'generation-1' - }) - - expect(acquire).toHaveBeenCalledOnce() - expect(host['sessions'].get(SESSION)?.child).toBeNull() - expect(host['sessions'].get(SESSION)?.lastEndedChild).not.toHaveProperty('failure') - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('recovers after a failed lifecycle barrier and dispatches a distinct next message', async () => { - await attach() - dispatch.mockRejectedValueOnce(new Error('provider delivery became unknown')) - const unknownBody = hostTestMessage('message with unknown delivery') - const unknownParams = { - envelope: envelope('agentSession.send', { body: unknownBody }), - body: unknownBody - } - await expect(host.send(CALLER, unknownParams)).resolves.toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await vi.waitFor(async () => - expect(await submissionState(unknownParams.envelope.clientOperationId)).toBe('unknown') - ) - const runtimeState = ( - host as unknown as { - runtimeState: { lifecycleBarrier: () => Promise<{ ok: false; error: Error }> } - } - ).runtimeState - vi.spyOn(runtimeState, 'lifecycleBarrier').mockResolvedValueOnce({ - ok: false, - error: new Error('journal failed') - }) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - runtimeFence: exitedFence + 1 - }) - expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledOnce() - expect(hostErrors).toContainEqual(expect.objectContaining({ message: 'journal failed' })) - const history = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(history.ok && history.page.submissions[0]?.dispatchState).toBe('unknown') - // A send whose delivery outcome is unknown IS work in progress, so the reassuring outcome is - // written — its failure fact beside it, and never the old bare `Provider exited: ` row. - const statuses = history.ok - ? history.page.items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) - : [] - expect(statuses).toEqual([UNEXPECTED_PROVIDER_EXIT_OUTCOME]) - expect(statuses.some((text) => text.startsWith('Provider exited'))).toBe(false) - - dispatch.mockResolvedValueOnce({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-next', ordinal: 1 } - }) - const body = hostTestMessage('a distinct next message after failed-barrier recovery') - const nextEnvelope = envelope('agentSession.send', { body }) - await expect(host.send(CALLER, { envelope: nextEnvelope, body })).resolves.toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await vi.waitFor(async () => - expect(await submissionState(nextEnvelope.clientOperationId)).toBe('accepted') - ) - expect(dispatch).toHaveBeenCalledTimes(2) - }) - - it('releases the lease when the exit settlement cannot be written, and the next send settles the turn it left', async () => { - await attach() - emitTurnLifecycle('running', 1) - await host.flushStreamedEvents(SESSION) - const runtimeState = ( - host as unknown as { - runtimeState: { lifecycleBarrier: () => Promise<{ ok: false; error: Error }> } - } - ).runtimeState - vi.spyOn(runtimeState, 'lifecycleBarrier').mockResolvedValueOnce({ - ok: false, - error: new Error('journal failed') - }) - const session = ( - host as unknown as { - sessions: Map< - string, - { journal: { appendLifecycleBatch: (...args: never[]) => Promise } } - > - } - ).sessions.get(SESSION) - expect(session).toBeDefined() - // The conversation's one handle refuses every write of the exit's settlement. - const refusing = vi - .spyOn(session!.journal, 'appendLifecycleBatch') - .mockRejectedValue(new Error('settlement still unavailable')) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - - const released = store.getRecord(SESSION)?.lease - expect(released).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - ownerProcess: null, - runtimeFence: exitedFence + 1, - deathEvidence: { kind: 'exit-observed', detail: 'provider exited', observedAt: NOW } - }) - // The retry recording the exit queues is refused too; once the journal writes again, the next - // acquire re-derives it. - await host.collaboratorsForTests().serialize(SESSION, async () => {}) - refusing.mockRestore() - - dispatch.mockResolvedValueOnce({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-next', ordinal: 1 } - }) - const body = hostTestMessage('sent after a settlement that never landed') - const sentEnvelope = envelope('agentSession.send', { body }) - await expect(host.send(CALLER, { envelope: sentEnvelope, body })).resolves.toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await vi.waitFor(async () => - expect(await submissionState(sentEnvelope.clientOperationId)).toBe('accepted') - ) - expect(acquire).toHaveBeenCalledTimes(2) - // The new child's acquire settled the turn from the release's evidence: ended at the exit's - // receipt. The evidence is Orca's log text, so the row says only that the provider stopped. - const history = await host.history({ sessionId: SESSION, direction: 'tail' }) - const items = history.ok ? history.page.items : [] - expect(items.map((item) => readAgentJournalTurn(item.body)).filter(Boolean)).toContainEqual( - expect.objectContaining({ turnId: 'turn-1', state: 'interrupted', completedAt: NOW }) - ) - const statuses = items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) - expect(statuses).toContainEqual({ - kind: 'status', - text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, - failure: { kind: 'providerExited' }, - tone: 'error' - }) - expect(statuses.map((status) => status.text).join('\n')).not.toContain('provider exited') - }) -}) - -describe('a quit after a close whose drain failed', () => { - // The close's own wind-down hands the lease back past a failed drain, so quit finds nothing owed. - it('has nothing of that child left to finish', async () => { - await attach() - await sendPending('pending across a failed drain') - const settled = captureSettledSubmissions() - failEvictionDrain() - - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(host['sessions'].get(SESSION)?.child ?? null).toBeNull() - - await host.flushAllStreamedEvents() - - expect(closeSession).toHaveBeenCalledOnce() - expect(host.hasSession(SESSION)).toBe(false) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - expect(hasUnansweredStructuredAgentSessionDispatch(settled.value)).toBe(false) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 6727b0144c03..687418e80c81 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -132,14 +132,17 @@ function openHost(overrides: Partial = {}): void logger: createStructuredAgentSessionLogger(), store, adapter: { + open: async () => ({ + reportedOptions: { model: '' } + }), acquire, - releaseAcquisition: vi.fn(async () => undefined), + releaseAcquisition: vi.fn(async () => false), dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), setOption: vi.fn(), supportsCreate: () => true - } as unknown as StructuredAgentSessionAdapter, + }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-new', @@ -158,6 +161,7 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-wedged-profile-')) resetHostTestOperationIds() acquire = vi.fn(async ({ fence }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -695,6 +699,7 @@ describe('already-wedged profiles become usable on load', () => { acquire.mockImplementation(async ({ fence }) => { order.push('acquire') return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-new' }, link: { linkId: `link-${fence}`, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index fe724185de34..2a2e13e19c2a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -52,6 +52,7 @@ import { import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { isUnansweredHandedOverSubmission } from '../agent-session-journal/journal-unsent-send-hold' import { structuredAgentSessionShownStatus } from './structured-agent-session-shown-work' +import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' /** A send Orca journaled that the provider has neither opened a turn for nor refused. Mirrors the * projection's own unanswered-dispatch rule, which is what makes that window read as `working`. */ @@ -164,7 +165,7 @@ function liveTasks( type WorkingCandidateSession = { journal: AgentSessionJournal /** Only this host generation's own child counts. A restored-for-reading journal has none. */ - child: { fence: number; phase?: 'starting' | 'ready' } | null + child: Pick | null } /** The offer one session is owed, taken right before teardown stops its provider child; null when @@ -175,8 +176,6 @@ export function structuredAgentSessionWorkingAtStop(input: { getRecord: (sessionId: string) => AgentSessionRecord | null /** The session's child records, the same read the status feed publishes. */ childWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined - /** False when the session's live child never answered its start; the exit reads the same fact. */ - startAnswered?: (sessionId: string) => boolean | undefined trigger: AgentSessionResumeTrigger /** Stable teardown identity for continuation deduplication, not launch ancestry. */ teardownId: string @@ -190,8 +189,7 @@ export function structuredAgentSessionWorkingAtStop(input: { const current = agentSessionCurrentContextRows(snapshot.items, snapshot.submissions) // A queued message reached no agent, and one handed to a start that never answered ran nowhere: // neither is work to resume. The next open or the exit keeps it as unsent. - const unanswered = - session.child.phase === 'starting' || input.startAnswered?.(sessionId) === false + const unanswered = session.child.phase === 'starting' const handedOver = current.submissions.filter( (submission) => !isQueuedAgentJournalSubmission(submission) && diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 7191ada60a86..12451d92a4c4 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -103,21 +103,13 @@ beforeEach(async () => { adapter = { supportsLocation: (location) => location.executionHostId === 'local' && location.wslDistro === null, + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(async (input) => { acquisitions++ const acquisitionGeneration = `acquisition-${acquisitions}` - const starter = host - // The handshake answers at once, reporting what the child runs with. - void starter.handleAdapterEvent({ - type: 'started', - sessionId: input.identity.sessionId, - fence: input.fence, - acquisitionGeneration, - reportedOptions: { model: '' } - // Never admitted, so the empty report persists nothing. - }) return { - providerChildPhase: 'starting' as const, acquisitionGeneration, process: { hostId: 'local', diff --git a/src/main/native-chat/structured-chat-naming-command-first.test.ts b/src/main/native-chat/structured-chat-naming-command-first.test.ts deleted file mode 100644 index 730b66be5416..000000000000 --- a/src/main/native-chat/structured-chat-naming-command-first.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' -import { - claudeProviderHandle, - codexProviderHandle -} from '../../shared/agent-session-provider-handle-encoding' -import { createStructuredChatNamingHandler } from './structured-chat-naming' -import { firstStructuredChatNamingPrompt } from '../../shared/structured-chat-naming-eligibility' -import type { StructuredAgentSessionAdapter } from './agent-session-wire/structured-agent-session-adapter' -import { - hostTestState, - envelope -} from './agent-session-wire/structured-agent-session-host-test-harness' -import { - hostTestAttachParams, - hostTestMessage, - HOST_TEST_SESSION, - HOST_TEST_THREAD -} from './agent-session-wire/structured-agent-session-host-test-data' - -describe.each(['claude', 'codex'] as const)('%s command-first naming', (provider) => { - it.each(['Please repair login', ''])( - 'keeps the first real input eligible after compact: %j', - async (input) => { - const { host, store, acquire, dispatch, log } = hostTestState() - const compact = vi.fn>(async () => ({ - state: 'accepted', - providerIdentity: null - })) - host.deps.adapter.compact = compact - const handle = - provider === 'claude' - ? claudeProviderHandle(HOST_TEST_THREAD, null) - : codexProviderHandle(HOST_TEST_THREAD) - acquire.mockImplementation(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle, - origin: 'created', - mintedAtFence: fence, - observedAt: 100 - } - })) - dispatch.mockResolvedValue({ state: 'admitted' }) - const readFirstPrompt = vi.fn(async (sessionId: string, startedAt: number) => - firstStructuredChatNamingPrompt(await host.journalSnapshot(sessionId), startedAt) - ) - const generate = vi.fn(async () => 'Saved first input') - const naming = createStructuredChatNamingHandler({ - getStore: () => store, - getSettings: () => ({}), - workerTaskTitle: () => null, - readFirstPrompt, - generate, - onNamed: vi.fn(), - logger: log.logger - }) - const observer = vi.fn(naming) - host.deps.onSessionStatusChanged = observer - expect( - await host.attach( - { callerKey: 'client-1' }, - hostTestAttachParams(null, { - provider, - agent: provider, - providerHandle: - provider === 'claude' - ? { kind: 'claude', sessionId: HOST_TEST_THREAD, leafUuid: null } - : { kind: 'codex', threadId: HOST_TEST_THREAD }, - accountHome: { - variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', - path: '/isolated/account' - } - }) - ) - ).toMatchObject({ ok: true }) - expect( - await host.conversationCommand( - { callerKey: 'client-1' }, - { - command: 'compact', - envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) - } - ) - ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(1)) - expect( - observer.mock.calls.some( - ([summary, options]) => - summary.status === 'working' && options.firstInputSubmissionKey === null - ) - ).toBe(true) - const events = acquire.mock.calls.at(-1)?.[0].events - const command = compact.mock.calls[0]?.[0].command - if (!events || !command) { - throw new Error('Missing actual compact turn') - } - for (let index = 0; index < 12; index++) { - events.publish() - } - await host.flushStreamedEvents(HOST_TEST_SESSION) - expect(readFirstPrompt).not.toHaveBeenCalled() - events.appendLifecycleBatch?.( - 'compact-finished', - [ - { - kind: 'item', - identity: command.identity, - body: { - ...command.running, - state: 'completed', - outcome: 'success', - completedAt: Date.now() - }, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ], - { lifecycle: true } - ) - await host.flushStreamedEvents(HOST_TEST_SESSION) - const body = hostTestMessage(input) - expect( - await host.send( - { callerKey: 'client-1' }, - { body, envelope: envelope('agentSession.send', { body }) } - ) - ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(readFirstPrompt).toHaveBeenCalledTimes(1)) - if (input) { - await vi.waitFor(() => - expect(store.getRecord(HOST_TEST_SESSION)?.conversationName).toBe('Saved first input') - ) - expect(generate).toHaveBeenCalledExactlyOnceWith(expect.anything(), input) - } else { - expect(generate).not.toHaveBeenCalled() - } - for (let index = 0; index < 12; index++) { - events.publish() - } - const later = hostTestMessage('Later input must not rename') - await host.send( - { callerKey: 'client-1' }, - { body: later, envelope: envelope('agentSession.send', { body: later }) } - ) - await host.flushStreamedEvents(HOST_TEST_SESSION) - expect(readFirstPrompt).toHaveBeenCalledTimes(1) - expect(generate).toHaveBeenCalledTimes(input ? 1 : 0) - } - ) -}) diff --git a/src/main/native-chat/structured-chat-naming-status-hook.test.ts b/src/main/native-chat/structured-chat-naming-status-hook.test.ts deleted file mode 100644 index f00d207591dc..000000000000 --- a/src/main/native-chat/structured-chat-naming-status-hook.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' -import type { StructuredChatNamingDeps } from './structured-chat-naming' -import { createStructuredChatNamingHandler } from './structured-chat-naming' -import { firstStructuredChatNamingPrompt } from '../../shared/structured-chat-naming-eligibility' -import { - claudeProviderHandle, - codexProviderHandle -} from '../../shared/agent-session-provider-handle-encoding' -import { - hostTestState, - envelope -} from './agent-session-wire/structured-agent-session-host-test-harness' -import { - hostTestAttachParams, - hostTestMessage, - HOST_TEST_SESSION, - HOST_TEST_THREAD -} from './agent-session-wire/structured-agent-session-host-test-data' - -describe.each(['claude', 'codex'] as const)('%s first-message naming status hook', (provider) => { - it.each([ - { outcome: 'accepted', completesDuringRead: false }, - { outcome: 'admitted', completesDuringRead: false }, - { outcome: 'accepted', completesDuringRead: true }, - { outcome: 'admitted', completesDuringRead: true } - ] as const)( - 'names a live $outcome send (first turn completes during journal read: $completesDuringRead)', - async ({ outcome, completesDuringRead }) => { - const { host, store, acquire, dispatch, log } = hostTestState() - const handle = - provider === 'claude' - ? claudeProviderHandle(HOST_TEST_THREAD, null) - : codexProviderHandle(HOST_TEST_THREAD) - acquire.mockImplementation(async ({ fence }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(HOST_TEST_SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle, - origin: 'created', - mintedAtFence: fence, - observedAt: 100 - } - })) - dispatch.mockImplementation(async () => - outcome === 'admitted' - ? { state: 'admitted' } - : { - state: 'accepted', - providerIdentity: - provider === 'claude' - ? { provider: 'claude', sessionId: HOST_TEST_THREAD, uuid: 'turn-1' } - : { provider: 'codex', threadId: HOST_TEST_THREAD, turnId: 'turn-1', ordinal: 1 } - } - ) - let finish: (value: string | null) => void = () => { - throw new Error('Not initialized') - } - const generation = new Promise((resolve) => { - finish = resolve - }) - const journalRead = Promise.withResolvers() - const readFirstPrompt = vi.fn(async (sessionId: string, hostStartedAt: number) => { - await journalRead.promise - return firstStructuredChatNamingPrompt(await host.journalSnapshot(sessionId), hostStartedAt) - }) - const generate = vi.fn(() => generation) - const deps: StructuredChatNamingDeps = { - getStore: () => store, - getSettings: () => ({}), - workerTaskTitle: () => null, - readFirstPrompt, - generate, - onNamed: vi.fn(), - logger: log.logger - } - const naming = createStructuredChatNamingHandler(deps) - const status = vi.fn(naming) - host.deps.onSessionStatusChanged = status - const attached = await host.attach( - { callerKey: 'client-1' }, - hostTestAttachParams(null, { - provider, - agent: provider, - providerHandle: - provider === 'claude' - ? { kind: 'claude', sessionId: HOST_TEST_THREAD, leafUuid: null } - : { kind: 'codex', threadId: HOST_TEST_THREAD }, - accountHome: { - variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', - path: '/isolated/account' - } - }) - ) - expect(attached).toMatchObject({ ok: true }) - const body = hostTestMessage('Please repair the login flow') - const sent = await host.send( - { callerKey: 'client-1' }, - { - envelope: envelope('agentSession.send', { body }), - body - } - ) - expect(sent).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(readFirstPrompt).toHaveBeenCalledTimes(1)) - expect(generate).not.toHaveBeenCalled() - if (completesDuringRead) { - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) - const events = acquire.mock.calls.at(-1)?.[0].events - const first = (await host.journalSnapshot(HOST_TEST_SESSION)).items.find( - (item) => item.body.kind === 'message' && item.body.role === 'user' - ) - if (!events || !first) { - throw new Error('Missing acquired first turn') - } - events.appendItem( - provider === 'claude' - ? { provider: 'claude', sessionId: HOST_TEST_THREAD, uuid: 'turn-1' } - : { provider: 'codex', threadId: HOST_TEST_THREAD, turnId: 'turn-1', ordinal: 1 }, - body, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - events.appendItem( - provider === 'claude' - ? { provider: 'claude', sessionId: HOST_TEST_THREAD, uuid: 'turn-completion' } - : { provider: 'codex', threadId: HOST_TEST_THREAD, turnId: 'turn-1', ordinal: 2 }, - { kind: 'turn', turnId: 'turn-1', userItemId: first.itemId, state: 'completed' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - events.publish() - await host.flushStreamedEvents(HOST_TEST_SESSION) - await vi.waitFor(() => - expect(status.mock.calls.at(-1)?.[0]).toMatchObject({ status: 'idle' }) - ) - expect(generate).not.toHaveBeenCalled() - expect(store.getRecord(HOST_TEST_SESSION)?.conversationName).toBeUndefined() - } - journalRead.resolve() - await vi.waitFor(() => expect(generate).toHaveBeenCalledTimes(1)) - expect( - status.mock.calls.some( - ([summary, options]) => - summary.agent === provider && summary.status === 'working' && !options.replay - ) - ).toBe(true) - expect(store.getRecord(HOST_TEST_SESSION)?.conversationName).toBeUndefined() - finish('auth/login') - await vi.waitFor(() => - expect(store.getRecord(HOST_TEST_SESSION)?.conversationName).toBe('auth/login') - ) - } - ) -}) diff --git a/src/main/pi/rpc-configured-default.test.ts b/src/main/pi/rpc-configured-default.test.ts deleted file mode 100644 index c6e684dc4827..000000000000 --- a/src/main/pi/rpc-configured-default.test.ts +++ /dev/null @@ -1,423 +0,0 @@ -// Pi's `--list-models` names no model it runs by default, so a new Pi chat with no saved pick showed -// "Model" and then whatever its session started on. Through the real Pi probe, adapter and catalog -// host, a chat started with no pick teaches the host the account's default for the next new chat. - -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionModelCatalogResult } from '../../shared/agent-session-wire' -import { structuredAgentSessionSeedCatalog } from '../../shared/structured-agent-session-seed-catalog' -import { - applyStructuredAgentSessionModelCatalog, - createStructuredAgentSessionOptionState, - structuredAgentSessionOptionSnapshot -} from '../../shared/structured-agent-session-options' -import { createAgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' -import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import { - agentReadsProjectModelConfig, - workspaceMayOverrideDefaultModel -} from '../native-chat/agent-model-catalog/agent-project-model-override' -import { - closeProviderTimelineRigs, - openProviderTimelineRig -} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { agentModelCatalogFingerprint } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' -import { createPiModelCatalogProbe, piModelCatalogFromListing } from './rpc-model-catalog-probe' -import type { PiRpcConnection } from './rpc-session' -import { PiRpcSessionAdapter } from './rpc-session-adapter' - -const PI_HOME = { variable: 'PI_CODING_AGENT_DIR', path: '/homes/pi' } -// What `pi --list-models` prints: the first row is not the model Pi starts on. -const LISTING = [ - 'provider model context max-out thinking images', - 'anthropic claude-sonnet-4 200K 64K yes yes', - 'openai gpt-6 400K 128K yes yes' -].join('\n') -const AVAILABLE = [ - { provider: 'anthropic', id: 'claude-sonnet-4', name: 'Claude Sonnet 4', reasoning: true }, - // Its thinking map adds xhigh, which `--list-models` never shows. - { - provider: 'openai', - id: 'gpt-6', - name: 'GPT-6', - reasoning: true, - thinkingLevelMap: { xhigh: 'xhigh' } - } -] -const RUNS = AVAILABLE[1]! - -let root: string -const adapters: PiRpcSessionAdapter[] = [] -beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orca-pi-configured-default-')) -}) -afterEach(async () => { - for (const adapter of adapters.splice(0)) { - await adapter.closeAll() - } - rmSync(root, { recursive: true, force: true }) - await closeProviderTimelineRigs() -}) - -function workspace(name: string, files: Record = {}): string { - const dir = join(root, name) - mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, '.git'), 'gitdir: /elsewhere') - for (const [file, text] of Object.entries(files)) { - mkdirSync(join(dir, file, '..'), { recursive: true }) - writeFileSync(join(dir, file), text) - } - return dir -} - -type PiChild = { connection: PiRpcConnection; switchTo: (modelId: string) => void } - -/** A Pi child that starts on `startsOn` at `thinking`; a signed-out one lists no model. `switchTo` - * is a switch Orca didn't send, as an extension's `setModel` makes. */ -function piChild(available: readonly object[], thinking: string, startsOn: string): PiChild { - let model: object | null = AVAILABLE.find((entry) => entry.id === startsOn) ?? RUNS - const switchTo = (modelId: string): void => { - model = AVAILABLE.find((entry) => entry.id === modelId) ?? model - } - const connection: PiRpcConnection = { - pid: 4123, - closed: false, - rootVerdict: 'live', - processless: false, - lastCloseResult: null, - request: async (command: string, params?: Record) => { - if (command === 'get_state') { - return { - sessionFile: '/homes/pi/sessions/one.jsonl', - isStreaming: false, - isCompacting: false, - model, - thinkingLevel: thinking - } - } - if (command === 'get_available_models') { - return { models: available } - } - if (command === 'set_model') { - switchTo(String(params?.modelId)) - } - return command === 'get_commands' ? { commands: [] } : {} - }, - send: async () => {}, - close: async () => ({ root: 'exited', tree: 'exited' }), - pauseReading: () => {}, - resumeReading: () => {}, - onExit: () => {} - } - return { connection, switchTo } -} - -type ChatOptions = { - saved?: Record - resumed?: boolean - forked?: boolean - /** A new Pi session standing in for one whose saved file was gone. */ - replacement?: boolean - available?: object[] - thinking?: string - startsOn?: string - /** What happens between the chat's start and the pane's options read. */ - afterStart?: (chat: { - child: PiChild - adapter: PiRpcSessionAdapter - sessionId: string - }) => Promise -} - -/** A host with Pi's real probe and catalog service, and one Pi chat in `workspacePath`. */ -async function piHost() { - const records = new Map() - const clock = { now: 1_000 } - const store = new AgentModelCatalogStore({ now: () => clock.now }) - const catalog = createAgentModelCatalogService({ - store, - getRecord: (sessionId) => records.get(sessionId), - drivesRecord: () => true, - resolveAccountHome: async () => PI_HOME, - recordWorkspacePath: async (row) => row.launchDirectory ?? null, - agentReadsProjectModelConfig, - workspaceMayOverrideDefaultModel, - probes: { - pi: createPiModelCatalogProbe({ - resolveEnvironment: async () => ({ PATH: '/usr/bin' }), - resolveCommand: () => '/opt/pi/bin/pi', - probeVersion: async () => true, - homePath: '/home/user', - runListing: async () => LISTING - }) - } - }) - - let chats = 0 - /** A Pi chat in `workspacePath`: the host saves what its start listed, then the pane reads its - * options once, and the host saves each read's listing too. */ - async function chat(workspacePath: string, opts: ChatOptions = {}) { - const sessionId = `pi-${++chats}` - let child: PiChild | undefined - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the catalog service reads only these fields. - records.set(sessionId, { - sessionId, - provider: 'pi', - accountHome: PI_HOME, - launchDirectory: workspacePath, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'ws-1', - workspaceKind: 'git-worktree' - } - } as AgentSessionRecord) - const rig = await openProviderTimelineRig({ agent: 'pi', sessionId }) - const adapter = new PiRpcSessionAdapter({ - resolveLaunch: async () => ({ - command: '/opt/pi/bin/pi', - cwd: workspacePath, - fullAccess: true, - previous: null, - ...(opts.resumed ? { sessionFile: '/homes/pi/sessions/one.jsonl' } : {}), - ...(opts.forked ? { forkFile: '/homes/pi/sessions/elsewhere.jsonl' } : {}), - ...(opts.replacement ? { replacement: 'unsaved' as const } : {}) - }), - readProcessStartTime: async () => 1, - openConnection: () => { - child = piChild(opts.available ?? AVAILABLE, opts.thinking ?? 'high', opts.startsOn ?? '') - return child.connection - }, - onLifecycle: vi.fn(), - onSettled: vi.fn(), - onIdle: vi.fn(), - logger: { warn: vi.fn(), error: vi.fn() } - }) - adapters.push(adapter) - const acquired = await adapter.acquire({ - identity: { - sessionId, - workspaceId: 'ws-1', - hostId: 'local', - agent: 'pi', - providerHandle: null - }, - fence: 1, - spawnToken: 'spawn-token', - options: opts.saved ?? {}, - events: rig.eventSink - }) - const started = await adapter - .open({ - sessionId, - fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, - signal: new AbortController().signal - }) - .catch(async (error: unknown) => { - await adapter.closeSession(sessionId) - throw error - }) - if (!started.catalogListing) { - throw new Error('a Pi start hands the host its listing') - } - catalog.recordLiveListing(sessionId, started.catalogListing) - if (opts.afterStart && child) { - await opts.afterStart({ child, adapter, sessionId }) - } - const read = async (): Promise => { - const { catalogListing } = await adapter.readOptions({ sessionId, fence: 1 }) - if (catalogListing) { - catalog.recordLiveListing(sessionId, catalogListing) - } - } - await read() - return { listing: started.catalogListing, read } - } - return { catalog, chat, store, clock } -} - -/** What a new Pi chat's composer paints first from a host answer. */ -function firstFrame(answer: AgentSessionModelCatalogResult) { - const seed = structuredAgentSessionSeedCatalog('pi') - const state = applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState('pi', seed), - seed, - answer, - { newLaunch: true } - ) - const snapshot = structuredAgentSessionOptionSnapshot(state) - const select = (id: string) => { - const entry = snapshot.find((descriptor) => descriptor.id === id) - return entry?.kind.type === 'select' ? (entry.kind.currentValue ?? null) : null - } - return { model: select('model'), effort: select('effort') } -} - -const NOTHING = { model: null, effort: null } - -describe('Pi’s default comes from what a chat with no pick runs', () => { - it('names no model from the listing, then the model and thinking level a no-pick chat ran', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - const listed = await catalog.read({ agent: 'pi', workspacePath: clean, waitForListing: true }) - expect(listed).toMatchObject({ origin: 'probe', listingNamesConfiguredModel: false }) - expect(firstFrame(listed)).toEqual(NOTHING) - - await chat(clean) - // The next new chat, in this workspace or another whose config picks nothing. - for (const path of [clean, workspace('other')]) { - const answer = await catalog.read({ agent: 'pi', workspacePath: path }) - expect(answer).toMatchObject({ listingNamesConfiguredModel: true }) - expect(answer).not.toHaveProperty('defaultHoldsInEveryWorkspace') - expect(firstFrame(answer)).toEqual({ model: 'openai/gpt-6', effort: 'high' }) - } - }) - - it('keeps the placeholder in a workspace whose Pi settings pick the model', async () => { - const { catalog, chat } = await piHost() - await chat(workspace('clean')) - const configured = workspace('configured', { - '.pi/settings.json': '{"defaultProvider":"anthropic","defaultModel":"claude-sonnet-4"}' - }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: configured }))).toEqual( - NOTHING - ) - }) - - it('learns nothing from a chat whose workspace picks its model', async () => { - const { catalog, chat } = await piHost() - await catalog.read({ agent: 'pi', waitForListing: true }) - await chat(workspace('configured', { '.pi/extensions/pick.ts': 'export default () => {}\n' })) - expect( - firstFrame(await catalog.read({ agent: 'pi', workspacePath: workspace('clean') })) - ).toEqual(NOTHING) - }) - - it('learns its configured default despite a saved next-message pick, but not from a resumed conversation', async () => { - const { catalog, chat } = await piHost() - await catalog.read({ agent: 'pi', waitForListing: true }) - const clean = workspace('clean') - expect( - (await chat(clean, { saved: { model: 'openai/gpt-6' } })).listing.configuredDefault - ).toEqual({ - modelId: 'openai/gpt-6', - effort: 'high' - }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'openai/gpt-6', - effort: 'high' - }) - const resumed = await piHost() - await resumed.catalog.read({ agent: 'pi', waitForListing: true }) - expect( - (await resumed.chat(workspace('resumed'), { resumed: true })).listing - ).not.toHaveProperty('configuredDefault') - }) - - it('saves no default from a signed-out Pi, which lists no model', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - await catalog.read({ agent: 'pi', waitForListing: true }) - await expect(chat(clean, { available: [] })).rejects.toMatchObject({ reason: 'notSignedIn' }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual(NOTHING) - - // Nor does it forget one a signed-in chat taught. - await chat(clean) - await expect(chat(clean, { available: [] })).rejects.toMatchObject({ reason: 'notSignedIn' }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'openai/gpt-6', - effort: 'high' - }) - }) - - it('teaches the model the chat started on, not one an extension switched to later', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - await catalog.read({ agent: 'pi', waitForListing: true }) - await chat(clean, { afterStart: async ({ child }) => child.switchTo('claude-sonnet-4') }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'openai/gpt-6', - effort: 'high' - }) - }) - - it('teaches the start model after a pick made in the chat, never the pick', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - await catalog.read({ agent: 'pi', waitForListing: true }) - await chat(clean, { - afterStart: async ({ adapter, sessionId }) => { - await adapter.setOption({ - sessionId, - fence: 1, - key: 'model', - value: 'anthropic/claude-sonnet-4' - }) - } - }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'openai/gpt-6', - effort: 'high' - }) - }) - - it('learns nothing from a chat forked from a conversation in another folder', async () => { - const { catalog, chat } = await piHost() - await catalog.read({ agent: 'pi', waitForListing: true }) - const clean = workspace('clean') - expect((await chat(clean, { forked: true })).listing).not.toHaveProperty('configuredDefault') - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual(NOTHING) - }) - - it('keeps a learned thinking level only the chat’s menu offers when a later listing is newer', async () => { - const { catalog, chat, store, clock } = await piHost() - const clean = workspace('clean') - await chat(clean, { thinking: 'xhigh' }) - // A background re-list after the chat: `--list-models` offers no xhigh. - clock.now = 2_000 - store.recordSuccess( - agentModelCatalogFingerprint({ agent: 'pi', accountHome: PI_HOME, wslDistro: null }), - 'pi', - { - models: piModelCatalogFromListing(LISTING), - origin: 'probe' - }, - 'discovery' - ) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'openai/gpt-6', - effort: 'xhigh' - }) - }) - - it('a chat’s later reads teach nothing: a newer chat’s default stands', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - await catalog.read({ agent: 'pi', waitForListing: true }) - const first = await chat(clean) - // Pi's own default changed since (a `/model` in a terminal): the next new chat starts on it. - await chat(clean, { startsOn: 'claude-sonnet-4' }) - // The first chat keeps running and reads its options again, as every turn does. - await first.read() - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'anthropic/claude-sonnet-4', - effort: 'high' - }) - }) - - it('learns the configured model and thinking level despite a saved next-message thinking pick', async () => { - const { chat } = await piHost() - const { listing } = await chat(workspace('clean'), { saved: { effort: 'low' } }) - expect(listing.configuredDefault).toEqual({ modelId: 'openai/gpt-6', effort: 'high' }) - }) - - it('learns from a new Pi session standing in for a conversation whose file was gone', async () => { - const { chat } = await piHost() - const { listing } = await chat(workspace('clean'), { replacement: true }) - expect(listing.configuredDefault).toEqual({ modelId: 'openai/gpt-6', effort: 'high' }) - }) -}) diff --git a/src/main/pi/rpc-session-adapter.test-fixture.ts b/src/main/pi/rpc-session-adapter.test-fixture.ts index 10810ec4abf0..b5d1d48dc400 100644 --- a/src/main/pi/rpc-session-adapter.test-fixture.ts +++ b/src/main/pi/rpc-session-adapter.test-fixture.ts @@ -171,7 +171,7 @@ export async function setup( const started = await adapter.open({ sessionId, fence: 7, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) lifecycle.mockClear() diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index a70f4419a46d..4888c52c9951 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -94,8 +94,7 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { } return { process, - acquisitionGeneration: session.generation, - providerChildPhase: 'starting' + acquisitionGeneration: session.generation } } catch (error) { if (!session) { diff --git a/src/main/runtime/agent-session-record-options.test.ts b/src/main/runtime/agent-session-record-options.test.ts deleted file mode 100644 index 86e87486cca0..000000000000 --- a/src/main/runtime/agent-session-record-options.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { readFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it } from 'vitest' -import { readNativeSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' -import { - openTestAgentSessionRecordStore, - seedTestAgentSessionRecordStore -} from './agent-session-record-store-test-harness' -import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' -import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import { AcpStructuredOptions } from '../acp/acp-structured-options' -import { NewSessionResponseSchema } from '../acp/generated/acp-protocol.generated' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-options' -let directory: string - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-options-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -it('fails option hydration before ownership can be proved', async () => { - await expect( - readNativeSessionOptions({ - adapter: { - readOptions: async () => { - throw new Error('model list unavailable') - } - }, - sessionId: SESSION, - fence: 2 - }) - ).rejects.toThrow('model list unavailable') -}) - -it('omits empty model values from other option producers', async () => { - await expect( - readNativeSessionOptions({ - adapter: { readOptions: async () => ({ models: [], current: { model: '', effort: 'off' } }) }, - sessionId: SESSION, - fence: 2, - priorOptions: { model: 'old-model', other: 'keep' } - }) - ).resolves.toEqual({ effort: 'off', other: 'keep' }) -}) - -it('durably replaces model-less ACP options when a later report reaches the option writer', async () => { - const response = NewSessionResponseSchema.parse( - JSON.parse( - readFileSync( - new URL('../acp/fixtures/omp-v17-windows-new-no-model.json', import.meta.url), - 'utf8' - ) - ) - ) - const reader = new AcpStructuredOptions() - reader.adoptSession(response) - const fixture = agentSessionRecordFixture() - const record = { - ...fixture, - provider: 'omp', - options: reader.reported(), - providerHandleChain: fixture.providerHandleChain.map((link) => ({ - ...link, - handle: { transport: 'acp', agent: 'omp', nativeId: response.sessionId } - })) - } - await seedTestAgentSessionRecordStore(directory, { records: [record] }) - const store = await openTestAgentSessionRecordStore(directory) - expect(store.getRecord(record.sessionId)?.options).toEqual({ effort: 'off' }) - - reader.adoptConfigOptions([ - ...(response.configOptions ?? []), - { - id: 'model', - name: 'Model', - category: 'model', - type: 'select', - currentValue: 'reported-model', - options: [{ value: 'reported-model', name: 'Reported Model' }] - } - ]) - const options = await readNativeSessionOptions({ - adapter: { readOptions: async () => reader.read() }, - sessionId: record.sessionId, - fence: record.lease.runtimeFence, - priorOptions: store.getRecord(record.sessionId)?.options - }) - if (!options) { - throw new Error('The later ACP report must provide options') - } - await store.replaceSessionOptions({ - sessionId: record.sessionId, - fence: record.lease.runtimeFence, - options, - now: NOW - }) - const reopened = await openTestAgentSessionRecordStore(directory) - expect(reopened.getRecord(record.sessionId)?.options).toEqual({ - model: 'reported-model', - effort: 'off' - }) -}) - -it('persists resumed provider options atomically with owner proof', async () => { - const store = await openTestAgentSessionRecordStore(directory) - const reserved = await store.reserveOwner({ - sessionId: SESSION, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, - expectedFence: null, - spawnToken: 'spawn-options', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'indeterminate', reason: 'new session' }, - operation: { - callerKey: 'client-1', - operationId: '1800000000000-00000000000000000000000000000000', - fingerprint: 'options-create' - }, - now: NOW - }) - const fence = reserved.record.lease.runtimeFence - await store.commitProcessIdentity({ - sessionId: SESSION, - fence, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW - 1, - spawnToken: 'spawn-options' - }, - now: NOW - }) - const options = await readNativeSessionOptions({ - adapter: { - readOptions: async () => ({ - models: [], - current: { model: 'gpt-tui', effort: 'low' } - }) - }, - sessionId: SESSION, - fence - }) - await store.proveOwner({ - sessionId: SESSION, - fence, - link: { - linkId: 'codex-options-1', - handle: codexProviderHandle('thread-options'), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - }, - now: NOW, - ...(options ? { options } : {}) - }) - - const reopened = await openTestAgentSessionRecordStore(directory) - expect(reopened.getRecord(SESSION)?.options).toEqual({ model: 'gpt-tui', effort: 'low' }) -}) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 572234b3a23c..58c43b939bb0 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -214,20 +214,16 @@ export class AgentSessionRecordStore { link?: AgentSessionProviderHandleLink now: number leaseTtlMs?: number - options?: Readonly> }): Promise { - return this.mutate(args.sessionId, (record) => { - const proved = proveAgentSessionOwner({ + return this.mutate(args.sessionId, (record) => + proveAgentSessionOwner({ record, fence: args.fence, ...(args.link ? { link: args.link } : {}), now: args.now, leaseTtlMs: args.leaseTtlMs ?? AGENT_SESSION_LEASE_TTL_MS }) - return args.options - ? replaceAgentSessionRecordOptions(proved, { ...args, options: args.options }) - : proved - }) + ) } /** Settle the failed attach and its reservation in one durable transaction. */ diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 4a82cb904752..ba8221e0c9d1 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -34,9 +34,13 @@ let host: StructuredAgentSessionHost function adapter(): StructuredAgentSessionAdapter { return { supportsCreate: () => true, + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi .fn() .mockImplementation(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts deleted file mode 100644 index 25467e57248d..000000000000 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import '../unused-default-rpc-methods.test-fixture' -// `agentSession.hold` / `release` are kept answering for clients that still send them, and do -// nothing else: a view never starts or keeps an agent. -// -// Run against the REAL subscription registry rather than a stub, because the claim includes that a -// connection closing runs nothing hold-related — a stubbed registry could not show that. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' -import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - resetHostTestOperationIds -} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import type { AgentSessionRecordStore } from '../../agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' -import { OrcaRuntimeService } from '../../orca-runtime' -import type { RpcResponse } from '../core' -import { RpcDispatcher } from '../dispatcher' -import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' -import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' -import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' -import { codexProviderHandle } from '../../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' -import { getDefaultPersistedState } from '../../../../shared/constants' -import { RuntimeClientSettingsController } from '../../runtime-client-settings' - -const CONNECTION = 'connection-1' -const CLIENT = { - clientId: 'device-1', - clientKind: 'runtime' as const, - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], - connectionId: CONNECTION -} - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let runtime: OrcaRuntimeService -let dispatcher: RpcDispatcher -let closeSession: Mock> -let acquire: Mock -let requests = 0 -let structuredNativeChatEnabled = true - -async function call(method: string, params: unknown): Promise { - const replies: RpcResponse[] = [] - requests += 1 - await dispatcher.dispatchStreaming( - { id: `request-${requests}`, authToken: 'token', method, params }, - (raw) => replies.push(JSON.parse(raw) as RpcResponse), - CLIENT - ) - return replies[0] as RpcResponse -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-hold-wire-')) - resetHostTestOperationIds() - requests = 0 - structuredNativeChatEnabled = true - closeSession = vi.fn(async () => true) - acquire = vi.fn(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length - ? ('resumed' as const) - : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - })) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: { - acquire, - // A failed acquisition is proven gone, as the real adapters prove it; without this an - // acquire that throws leaves an unverifiable owner nothing may replace. - releaseAcquisition: vi.fn(async () => true), - closeSession, - dispatch: async () => ({ - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { - surface: 'rejection' - }) - }), - cancelTurn: async () => ({ cancelled: false }), - answerPrompt: async () => undefined, - setOption: async () => undefined - }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - setStructuredAgentSessionHost(host) - runtime = new OrcaRuntimeService() - // The structured surface is settings-gated for every caller, in-process included. - const clientSettings = new RuntimeClientSettingsController({ - getSettings: () => ({ - ...getDefaultPersistedState(root).settings, - experimentalNativeChat: structuredNativeChatEnabled - }), - updateSettings: () => undefined - }).get() - vi.spyOn(runtime, 'getClientSettings').mockImplementation(() => clientSettings) - dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS }) - expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ - ok: true - }) -}) - -afterEach(async () => { - setStructuredAgentSessionHost(null) - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -describe('the hold surface, for clients that still call it', () => { - it('answers a hold without starting an agent or registering a cleanup', async () => { - await host.close(SESSION, 'evict') - expect(host.hasSession(SESSION)).toBe(false) - const registered = vi.spyOn(runtime, 'registerOwnedSubscriptionCleanup') - const acquiresBefore = acquire.mock.calls.length - - expect( - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { held: true } }) - - expect(acquire.mock.calls.length).toBe(acquiresBefore) - expect(registered).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('answers a release without stopping anything, and a connection close runs nothing', async () => { - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - - expect( - await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { released: true } }) - runtime.cleanupSubscriptionsForConnection(CONNECTION) - await new Promise((resolve) => setTimeout(resolve, 20)) - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - }) - - it('answers a hold and a release whatever the host structured-chat setting says', async () => { - structuredNativeChatEnabled = false - - expect( - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { held: true } }) - expect( - await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { released: true } }) - expect(closeSession).not.toHaveBeenCalled() - }) - - it('answers a hold even when no agent could be started', async () => { - await host.close(SESSION, 'evict') - acquire.mockRejectedValue(new Error('provider unavailable')) - const acquiresBefore = acquire.mock.calls.length - - expect( - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { held: true } }) - expect(acquire.mock.calls.length).toBe(acquiresBefore) - }) -}) - -describe('a stream', () => { - it('reads a closed conversation without starting its agent', async () => { - await host.close(SESSION, 'evict') - expect(host.hasSession(SESSION)).toBe(false) - const acquiresBefore = acquire.mock.calls.length - const frames: unknown[] = [] - - await dispatcher.dispatchStreaming( - { - id: 'request-subscribe', - authToken: 'token', - method: 'agentSession.subscribe', - params: { sessionId: SESSION } - }, - (raw) => frames.push(JSON.parse(raw)), - CLIENT - ) - - expect(frames).toContainEqual( - expect.objectContaining({ result: expect.objectContaining({ type: 'snapshot' }) }) - ) - expect(acquire.mock.calls.length).toBe(acquiresBefore) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('keeps nothing alive when its transport dies', async () => { - const transport = new AbortController() - await dispatcher.dispatchStreaming( - { - id: 'desktop-subscription', - authToken: 'token', - method: 'agentSession.subscribe', - params: { sessionId: SESSION } - }, - () => {}, - { - signal: transport.signal, - clientId: 'desktop-renderer', - clientKind: 'runtime', - clientCapabilities: CLIENT.clientCapabilities - } - ) - - transport.abort() - await new Promise((resolve) => setTimeout(resolve, 20)) - - // The idle sweep, not a departing reader, is what stops an agent. - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - }) -}) diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts index 8e7bdf86c223..2110c2e6b5bd 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts @@ -55,12 +55,16 @@ function providerAdapter(): StructuredAgentSessionAdapter { return { supportsLocation: (location) => location.executionHostId === 'local' && location.wslDistro === null, + open: async () => ({ + reportedOptions: { model: '' } + }), acquire: vi.fn(async (input) => { if (acquireFails) { throw new Error('provider failed to start') } acquisitions++ return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4000 + acquisitions, diff --git a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts index 809b99aaeea3..065ff1819ffa 100644 --- a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts +++ b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts @@ -11,6 +11,9 @@ describe('ACP agents in the runtime registrations', () => { expect( STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) ).toEqual(['pi', 'codex', 'claude', 'grok', 'opencode', 'omp']) + expect(structuredAgentRuntimeRegistration('codex')?.definition.capabilities.contextUsage).toBe( + true + ) expect(structuredAgentRuntimeRegistration('grok')?.definition).toMatchObject({ handleTransport: 'acp', accountHomeVariable: 'GROK_HOME', diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts deleted file mode 100644 index dbd98119d2e2..000000000000 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -// Failures the desktop host used to drop: it installed the runtime with no error callback, so -// each of these reached nobody. Every one now lands in the required logger under its own scope. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' -import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' -import type { StructuredAgentSessionHostDeps } from '../native-chat/agent-session-wire/structured-agent-session-host' -import { StructuredAgentSessionHostRuntimeState } from '../native-chat/agent-session-wire/structured-agent-session-host-runtime-state' -import { - StructuredAgentSessionDeliveryLoop, - type StructuredAgentSessionDeliveryLoopDeps -} from '../native-chat/agent-session-wire/structured-agent-session-delivery-loop' -import { - StructuredAgentSessionIdleSweep, - type StructuredAgentSessionIdleSweepDeps -} from '../native-chat/agent-session-wire/structured-agent-session-idle-sweep' -import { - StructuredAgentSessionQueuedMessageDrain, - type QueuedMessageDrainDeps -} from '../native-chat/agent-session-wire/structured-agent-session-queued-messages' -import { - createStructuredAgentSessionLogger, - neverThrowingStructuredAgentSessionLogger -} from '../native-chat/agent-session-wire/structured-agent-session-logger' -import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' -import { StructuredAgentSessionDeliveryOrder } from '../native-chat/agent-session-wire/structured-agent-session-delivery-order' -import { _resetTracerForTests, setActiveSink } from '../observability/tracer' -import { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups' -import { createStructuredAgentSessionLifecycleDelivery } from './structured-agent-session-lifecycle-delivery' -import { - STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED, - ensureStructuredAgentSessionHost, - stopStructuredAgentSessionRuntime -} from './structured-agent-session-runtime' - -const SESSION = 'session-1' - -function rejectingHost(error: Error) { - return { - settleLateDispatch: vi.fn(async () => { - throw error - }), - releaseUnansweredDispatches: vi.fn(async () => { - throw error - }) - } -} - -describe('failures the desktop host used to drop', () => { - it('logs a late dispatch settlement that fails', async () => { - const log = recordingStructuredAgentSessionLogger() - const error = new Error('settlement write failed') - const followUps = createStructuredAgentSessionDispatchFollowUps({ - host: () => rejectingHost(error), - logger: log.logger - }) - - followUps.onDispatchSettledLate({ - sessionId: SESSION, - clientMessageId: 'client-1', - state: 'unknown', - reason: 'provider went idle' - }) - - await vi.waitFor(() => - expect(log.entries).toEqual([ - { - level: 'warn', - message: 'settling a dispatch the provider answered late failed', - fields: { scope: 'late-settlement', sessionId: SESSION, error } - } - ]) - ) - }) - - it('logs releasing the sends an idle provider never answered when that fails', async () => { - const log = recordingStructuredAgentSessionLogger() - const error = new Error('release write failed') - const followUps = createStructuredAgentSessionDispatchFollowUps({ - host: () => rejectingHost(error), - logger: log.logger - }) - - followUps.releaseUnansweredDispatches({ sessionId: SESSION }) - - await vi.waitFor(() => - expect(log.entries).toEqual([ - expect.objectContaining({ - fields: { scope: 'unanswered-dispatch', sessionId: SESSION, error } - }) - ]) - ) - }) - - it('logs a provider start or exit the host could not take', async () => { - const log = recordingStructuredAgentSessionLogger() - const error = new Error('host refused the event') - const lifecycle = createStructuredAgentSessionLifecycleDelivery({ - handle: async () => { - throw error - }, - logger: log.logger, - drainObservedExits: async () => {} - }) - - lifecycle.deliver({ - type: 'started', - sessionId: SESSION, - fence: 1, - acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'gpt-5' } - }) - lifecycle.deliver({ - type: 'ended', - sessionId: SESSION, - reason: 'exited', - cause: 'unexpected-exit', - fence: 1, - acquisitionGeneration: 'generation-1' - }) - await lifecycle.drain() - - expect(log.entries.map((entry) => entry.fields)).toEqual([ - { scope: 'lifecycle-started', sessionId: SESSION, error }, - { scope: 'lifecycle-exit', sessionId: SESSION, error } - ]) - }) - - it('logs provider events the chat journal would not take', async () => { - const log = recordingStructuredAgentSessionLogger() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: minting and binding a sink reads no other host dependency. - const deps = { logger: log.logger } as unknown as StructuredAgentSessionHostDeps - const runtime = new StructuredAgentSessionHostRuntimeState(deps, new Map()) - const sink = runtime.eventSinkFor(SESSION) - const error = new Error('journal append failed') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sink calls only appendItem before it fails. - const journal = { - appendItem: async () => { - throw error - } - } as unknown as AgentSessionJournal - sink.bind({ journal, fence: 1, publish: () => {} }) - - sink.sink.appendItem( - { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await expect(sink.drained()).resolves.toMatchObject({ ok: false }) - - expect(log.entries).toEqual([ - expect.objectContaining({ - level: 'error', - fields: { scope: 'journal-event-sink', sessionId: SESSION, error } - }) - ]) - }) -}) - -describe('host collaborators log with their own scope', () => { - const error = new Error('serialize refused') - const rejectingSerialize = async (): Promise => { - throw error - } - - it('the delivery loop logs a failed delivery, and a failed recording of it', async () => { - const log = recordingStructuredAgentSessionLogger() - const loop = new StructuredAgentSessionDeliveryLoop( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a step that rejects at its first serialize reads only these members. - { - serialize: rejectingSerialize, - sessions: new Map(), - deliveryOrder: new StructuredAgentSessionDeliveryOrder(), - trackStart: (start: Promise) => start, - logger: log.logger - } as unknown as StructuredAgentSessionDeliveryLoopDeps - ) - - loop.wake(SESSION) - - await vi.waitFor(() => - expect(log.entries.map((entry) => entry.fields)).toEqual([ - { scope: 'delivery-loop', sessionId: SESSION, error }, - { scope: 'delivery-loop-fail', sessionId: SESSION, error } - ]) - ) - }) - - it('the idle sweep logs a step that fails', async () => { - const log = recordingStructuredAgentSessionLogger() - const sweep = new StructuredAgentSessionIdleSweep( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a tick whose serialize rejects reads only these members. - { - sessions: new Map([[SESSION, {}]]), - isDisposed: () => false, - serialize: rejectingSerialize, - logger: log.logger - } as unknown as StructuredAgentSessionIdleSweepDeps - ) - - await sweep.tick() - - expect(log.entries.map((entry) => entry.fields)).toEqual([ - { scope: 'idle-sweep', sessionId: SESSION, error } - ]) - }) - - it('the queued-message drain logs a step that fails', async () => { - const log = recordingStructuredAgentSessionLogger() - const journal = { queuedMessages: { settlementOwed: () => true } } - const drain = new StructuredAgentSessionQueuedMessageDrain( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a schedule past the owed-settlement check reads only these members. - { - sessions: new Map([[SESSION, { journal }]]), - serialize: rejectingSerialize, - logger: log.logger - } as unknown as QueuedMessageDrainDeps - ) - - drain.schedule(SESSION) - - await vi.waitFor(() => - expect(log.entries.map((entry) => entry.fields)).toEqual([ - { scope: 'queued-drain', sessionId: SESSION, error } - ]) - ) - }) -}) - -describe('a logger that throws', () => { - it('never throws out of the report, and still prints what failed', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const loggerError = new Error('logger broke') - const logger = neverThrowingStructuredAgentSessionLogger({ - warn: () => { - throw loggerError - }, - error: () => { - throw loggerError - } - }) - const error = new Error('what failed') - - expect(() => logger.warn('a step failed', { scope: 'step', error })).not.toThrow() - expect(() => logger.error('a step failed', { scope: 'step', error })).not.toThrow() - expect(warn).toHaveBeenCalledWith('[agent-session] a step failed', { - scope: 'step', - error, - loggerError - }) - warn.mockRestore() - }) -}) - -describe('the production logger', () => { - afterEach(() => { - _resetTracerForTests() - vi.restoreAllMocks() - }) - - it('writes each entry to the trace file as a failed span named for its scope', () => { - const push = vi.fn() - setActiveSink({ push, flush: () => {}, close: () => {} }) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - - createStructuredAgentSessionLogger().warn('settling a late dispatch failed', { - scope: 'late-settlement', - sessionId: SESSION, - error: new Error('disk full') - }) - - expect(push).toHaveBeenCalledOnce() - expect(push.mock.calls[0]?.[0]).toMatchObject({ - type: 'effect-span', - name: 'agentSession.late-settlement', - attributes: { - level: 'warn', - message: 'settling a late dispatch failed', - sessionId: SESSION - }, - exit: { _tag: 'Failure', cause: expect.stringContaining('Error: disk full') } - }) - }) -}) - -describe('installing the runtime', () => { - let stateDirectory: string | null = null - - afterEach(async () => { - await stopStructuredAgentSessionRuntime() - if (stateDirectory) { - await rm(stateDirectory, { recursive: true, force: true }) - stateDirectory = null - } - }) - - it('refuses without a logger rather than dropping every failure', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-logger-wiring-')) - stateDirectory = root - - await expect( - ensureStructuredAgentSessionHost( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: deliberately missing the logger, as a `@ts-nocheck` caller could drop it. - { - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async () => root, - resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ account: 'managed' }) - } as unknown as Parameters[0] - ) - ).rejects.toThrow(STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED) - }) - - it('hands the host a logger whose throw cannot reach the operation it reports', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-logger-wiring-')) - stateDirectory = root - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const host = await ensureStructuredAgentSessionHost({ - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async () => root, - resolveEnvironment: async () => ({}), - resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ account: 'managed' }), - logger: { - warn: () => { - throw new Error('logger broke') - }, - error: () => { - throw new Error('logger broke') - } - } - }) - - expect(() => host.deps.logger.warn('a step failed', { scope: 'step' })).not.toThrow() - }) -}) diff --git a/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 9f7c48f20f1c..46f931c7fa19 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 @@ -703,7 +703,7 @@ describe('cross-version structured agent sessions', () => { starts += 1 await startGate return { - providerChildPhase: 'starting' as const, + acquisitionGeneration: `cursor-${fence}`, process: { hostId: 'local', pid: 4242, diff --git a/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts b/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts index d14aee3b5996..06af14308354 100644 --- a/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts +++ b/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeAll, beforeEach, expect } from 'vitest' import { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' import { NO_STRUCTURED_AGENTS } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' +import { startsWhenPublished } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support' import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' import { createStructuredAgentSessionLogger } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness' @@ -82,23 +83,33 @@ export function createReleasedStopNoteRig(port: ScenarioPort, ref: string) { claimKeyId: 'key-1', logger: createStructuredAgentSessionLogger(), now: () => NOW, - adapter: { - supportsCreate: () => true, - acquire: async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - }), - dispatch: async () => ({ state: 'unknown', reason: 'unused' }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => {}, - setOption: async () => {} - } + // A ready provider proves its start as soon as it is published. + adapter: startsWhenPublished( + { + supportsCreate: () => true, + acquire: async ({ fence, spawnToken }) => ({ + acquisitionGeneration: `stop-note-${fence}`, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: async () => ({ state: 'unknown', reason: 'unused' }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => {}, + setOption: async () => {} + }, + () => host + ) }) setStructuredAgentSessionHost(host) expect(await host.attach({ callerKey: 'released-client' }, attachParams(null))).toMatchObject({ diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index a24e11d902f3..9dcff82c307d 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 @@ -184,6 +184,7 @@ export function readyCodexProviderAdapter(input: { return startsWhenPublished( { acquire: async ({ fence }) => ({ + acquisitionGeneration: `wire-host-${fence}`, process: { hostId: 'local', pid: 4242, 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 bc02eefb2cba..de0ce6d5d983 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -47,7 +47,7 @@ function openHost(): void { adapter: startsWhenPublished( { acquire: async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, + acquisitionGeneration: `generation-${fence}`, process: { hostId: 'local', pid: 4242, From ad9db009e95a9b59795617b27b33df5eec292c48 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:10:57 -0700 Subject: [PATCH 099/127] Restore the live checks the startup cleanup's deleted tests pinned One assertion each, in the surviving suites: a stale-generation exit is ignored, a failed create lets its sink go, a readback before open is saved, an idle stop that fails is retried, a no-pick Pi start teaches its default, an older client's pre-spawn failure and packed answer, an undrivable chat stays readable, the resting slash menu stays on its own host, the main agent reads apart from subagents, restore publishes status, naming runs off the host's status feed, and a throwing logger never reaches the operation. Also corrects the reliability gate note that claimed the send-idempotency suite replaced the refusal-retry tests. --- config/reliability-gates.jsonc | 2 +- ...claude-structured-session-commands.test.ts | 17 ++++ ...agent-session-child-work-selection.test.ts | 41 +++++++- ...ession-failed-create-owner-verdict.test.ts | 16 ++++ .../structured-agent-session-host.test.ts | 12 ++- ...tructured-agent-session-idle-sweep.test.ts | 17 ++++ .../structured-agent-session-logger.test.ts | 24 ++++- ...ent-session-prompt-answer-receipts.test.ts | 93 ++++++++++++------- ...d-agent-session-restart-undrivable.test.ts | 25 ++++- ...agent-session-startup-attempt-host.test.ts | 23 ++++- ...ured-agent-session-unexpected-exit.test.ts | 30 ++++++ .../structured-chat-naming.test.ts | 35 +++++++ src/main/pi/rpc-session-startup.test.ts | 20 ++++ ...tured-agent-session-create-message.test.ts | 18 ++++ 14 files changed, 336 insertions(+), 37 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index ae56f88cd4f7..61976ee05ef9 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1655,7 +1655,7 @@ "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "result": "passed", "durationSeconds": 6.47, - "summary": "Eleven focused host, shared, renderer and orchestration files passed 196 tests, including unknown send outcomes, reused message IDs and host replay without duplicate dispatch. The retained send-idempotency suite replaces the removed branch-by-branch refusal matrix." + "summary": "Eleven focused host, shared, renderer and orchestration files passed 196 tests, including unknown send outcomes, reused message IDs and host replay without duplicate dispatch. The send-idempotency suite pins only that an unknown outcome is never resent; the removed refusal-retry matrix has no replacement here." } ], "runtimeBudget": { diff --git a/src/main/claude/claude-structured-session-commands.test.ts b/src/main/claude/claude-structured-session-commands.test.ts index dbe751882dd2..fbbad26ef4e4 100644 --- a/src/main/claude/claude-structured-session-commands.test.ts +++ b/src/main/claude/claude-structured-session-commands.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, vi } from 'vitest' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { ClaudeAtRestCommandCatalog } from './claude-at-rest-commands' import { adapterFor, fakeClaude, @@ -113,3 +115,18 @@ it('keeps a buffered stream catalog newer than the initialization response', asy await adapter.closeSession('session-1') } }) + +it("answers a resting chat's `/` menu only for chats this host runs", async () => { + const listed = [{ name: 'review-pr', kind: 'skill' as const }] + const catalog = new ClaudeAtRestCommandCatalog({ + resolveWorkspacePath: async () => '/work', + probe: async () => listed + }) + const local = agentSessionRecordFixture() + catalog.read(local) + await vi.waitFor(() => expect(catalog.read(local)).toEqual(listed)) + + // Same workspace and account on another host: only that host can read its folders. + const remote = { ...local, location: { ...local.location, executionHostId: 'ssh:box' } } + expect(catalog.read(remote)).toBeUndefined() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts index 144ab38a7d7d..93b4f5bc8568 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-work-selection.test.ts @@ -4,12 +4,17 @@ import { describe, expect, it } from 'vitest' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { structuredRunningChildWork } from '../../../shared/agent-child-work-listing' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import { attach, hostTestState, serveHostTestChildWork } from './structured-agent-session-host-test-harness' -import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD +} from './structured-agent-session-host-test-data' function running(id: string, over: Partial = {}): AgentChildWorkView { return { @@ -83,3 +88,37 @@ describe("the host's strip channel and summary list the same running children", expect(await strip()).toBeNull() }) }) + +describe('a session list reads the main agent apart from its subagents', () => { + it('as done once its turn ends while only a subagent still runs', async () => { + let records: AgentChildWorkView[] = [] + serveHostTestChildWork(() => records) + await attach() + const { host, acquire } = hostTestState() + const turn = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + for (const state of ['running', 'completed'] as const) { + acquire.mock.calls + .at(-1)?.[0] + .events?.appendItem( + turn, + { kind: 'turn', turnId: 'turn-1', state }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + records = [running('subagent')] + host.publishChildWorkEvidence(SESSION, []) + await host.flushStreamedEvents(SESSION) + + const summary = host.readStatusSummary(SESSION)! + const row = structuredAgentSessionAgentStatus({ + ...summary, + status: summary.status!, + childWork: summary.children + }) + // Working for the subagent; the main agent, which the chat's Stop follows, is done. + expect({ mainAgent: row.mainAgent.state, row: row.state }).toEqual({ + mainAgent: 'done', + row: 'working' + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index 00ea74c46541..5404d277cc15 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -183,4 +183,20 @@ describe('failed create owner verdict', () => { deathEvidence: null }) }) + + it('lets the sink a failed create wrote through go, so the next create and shutdown proceed', async () => { + acquire.mockImplementationOnce(async ({ events }) => { + // The child's exit reached the translator before any journal was attached. + events?.setActivity?.(null) + throw new Error(EXIT_REASON) + }) + + await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + ok: false + }) + await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + ok: true + }) + await expect(host.flushAllStreamedEvents()).resolves.toBeUndefined() + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 6a66fa068495..9290892950a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -3,7 +3,10 @@ import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import type { + AgentSessionStatusEvent, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -625,8 +628,15 @@ describe('restart', () => { await reboot(async () => ({ outcome: 'indeterminate', reason: 'read does not need ownership' })) acquire.mockClear() const listRecords = vi.spyOn(store, 'listRecords') + // A list already on the stream hears of a chat restored without a child only from the restore. + const listed: AgentSessionStatusEvent[] = [] + host.subscribeStatus({ id: 'session-list', emit: (event) => listed.push(event) }) await host.restoreReadableSessions() + expect(listed.at(-1)).toEqual({ + type: 'status', + session: expect.objectContaining({ sessionId: SESSION, status: 'idle' }) + }) const restoreReads = listRecords.mock.calls.length await host.restoreReadableSessions() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index f3adb18b2a2d..b65dcab3e6e6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -279,6 +279,23 @@ describe('the idle sweep', () => { }) }) +describe('an idle stop that fails (P2-11 a)', () => { + it('is retried on the next tick until the agent is proven gone', async () => { + await foundRestTestChat(rig) + rig.host.collaboratorsForTests().lifetime.idleSweep.dispose() + rig.adapter.closeSession.mockClear() + rig.adapter.closeSession.mockResolvedValueOnce(false) + rig.clock.now += IDLE_MS + 1 + + await sweepOnce(rig.host) + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + await sweepOnce(rig.host) + + expect(rig.adapter.closeSession).toHaveBeenCalledTimes(2) + expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + }) +}) + describe('the idle sweep with no child running (P2-22 ii)', () => { it('closes a handle whose only leftover is a prompt nobody can answer', async () => { const journal = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-logger.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-logger.test.ts index 0cacbda8587e..8808e35e15da 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-logger.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-logger.test.ts @@ -5,7 +5,10 @@ import { agentSessionRefusalError, refuse } from '../../../shared/agent-session-wire-refusals' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { + createStructuredAgentSessionLogger, + neverThrowingStructuredAgentSessionLogger +} from './structured-agent-session-logger' import { STRUCTURED_AGENT_SESSION_LOG_REPEAT_WINDOW_MS as WINDOW_MS, createStructuredAgentSessionLogRepeats @@ -210,3 +213,22 @@ describe('what an Error leaves in the trace file', () => { expect(written()[0]?.attributes['errorCause']).toEqual(['Error: no space [ENOSPC]']) }) }) + +describe('a logger that throws', () => { + it('never throws into the operation it reports, and still prints what failed', () => { + const loggerError = new Error('logger broke') + const broken = () => { + throw loggerError + } + const logger = neverThrowingStructuredAgentSessionLogger({ warn: broken, error: broken }) + const error = new Error('what failed') + + expect(() => logger.warn('a step failed', { scope: 'step', error })).not.toThrow() + expect(() => logger.error('a step failed', { scope: 'step', error })).not.toThrow() + expect(console.warn).toHaveBeenCalledWith('[agent-session] a step failed', { + scope: 'step', + error, + loggerError + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts index a5bc09e798b5..8a6cd9ecc6e1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts @@ -9,6 +9,7 @@ import { type AgentJournalItemBody } from '../../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { encodeAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestJournalHostDatabase, @@ -132,6 +133,40 @@ function failReceiptWrites(): () => void { return () => db.exec('DROP TRIGGER fail_prompt_receipt') } +const GROUPED_ANSWERS = [ + { questionId: 'q1', optionIds: ['web', 'mobile'] }, + { questionId: 'q2', optionIds: [], other: 'SSH host' } +] + +/** A provider question with a multi-select and a free-text part; resolves its item id. */ +async function seedGroupedQuestion(): Promise { + const question = { ...PROMPT, ordinal: 100 } + acquire.mock.calls.at(-1)?.[0].events?.appendItem( + question, + { + kind: 'question', + question: '2 grouped questions', + options: [], + questions: [ + { + id: 'q1', + question: 'Targets', + multiSelect: true, + options: [ + { id: 'web', label: 'Web' }, + { id: 'mobile', label: 'Mobile' } + ] + }, + { id: 'q2', question: 'Host', multiSelect: false, options: [], freeTextQuestionId: 'q2' } + ], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await host.flushStreamedEvents(SESSION) + return agentJournalItemKey(question) +} + async function resolution(itemId: string) { const page = await host.history({ sessionId: SESSION, direction: 'tail' }) const item = page.ok ? page.page.items.find((entry) => entry.itemId === itemId) : undefined @@ -319,43 +354,19 @@ describe('a prompt answer', () => { it('replays a grouped answer whole after a rewind, free text included', async () => { await attach() - const question = { ...PROMPT, ordinal: 100 } - acquire.mock.calls.at(-1)?.[0].events?.appendItem( - question, - { - kind: 'question', - question: '2 grouped questions', - options: [], - questions: [ - { - id: 'q1', - question: 'Targets', - multiSelect: true, - options: [ - { id: 'web', label: 'Web' }, - { id: 'mobile', label: 'Mobile' } - ] - }, - { id: 'q2', question: 'Host', multiSelect: false, options: [], freeTextQuestionId: 'q2' } - ], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await host.flushStreamedEvents(SESSION) - const itemId = agentJournalItemKey(question) - const answers = [ - { questionId: 'q1', optionIds: ['web', 'mobile'] }, - { questionId: 'q2', optionIds: [], other: 'SSH host' } - ] - const fields = { itemId, expectedRevision: journal().item(itemId)!.revision, answers } + const itemId = await seedGroupedQuestion() + const fields = { + itemId, + expectedRevision: journal().item(itemId)!.revision, + answers: GROUPED_ANSWERS + } const params = { envelope: envelope('agentSession.respondTo:question', fields, { clientOperationId: opId() }), kind: 'question' as const, ...fields } const first = await host.respondToPrompt(CALLER, params) - expect(first).toMatchObject({ ok: true, value: { resolution: { answers } } }) + expect(first).toMatchObject({ ok: true, value: { resolution: { answers: GROUPED_ANSWERS } } }) await journal().replaceEpochItems( 'handle_forked', store.getRecord(SESSION)!.lease.runtimeFence, @@ -369,6 +380,26 @@ describe('a prompt answer', () => { expect(answerPrompt).toHaveBeenCalledOnce() }) + it('unpacks the grouped answer an older client packs into its option id', async () => { + await attach() + const itemId = await seedGroupedQuestion() + const optionId = encodeAgentSessionQuestionAnswers(GROUPED_ANSWERS) + const fields = { itemId, expectedRevision: journal().item(itemId)!.revision, optionId } + + expect( + await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:question', fields, { + clientOperationId: opId() + }), + kind: 'question', + ...fields + }) + ).toMatchObject({ ok: true, value: { resolution: { answers: GROUPED_ANSWERS } } }) + expect(answerPrompt).toHaveBeenCalledWith( + expect.objectContaining({ itemId, response: { kind: 'answers', answers: GROUPED_ANSWERS } }) + ) + }) + it('answers unknown, never success, when acknowledging an answer cannot be recorded', async () => { const prompt = await seeded() await host.respondToPrompt(CALLER, answer(prompt, opId())) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts index 11d23efa8048..d0309cc890ea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts @@ -5,7 +5,12 @@ import { afterEach, expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { HOST_TEST_NOW as NOW } from './structured-agent-session-host-test-data' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams +} from './structured-agent-session-host-test-data' +import { CALLER } from './structured-agent-session-host-test-harness' import { StructuredAgentRegistry } from './structured-agent-registry' import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' @@ -70,3 +75,21 @@ it('lists a recorded failure of such a chat as not retryable', async () => { { sessionId: marker!.sessionId, retryable: false } ]) }) + +it('keeps such a chat readable and refuses to start its agent', async () => { + const { host, store, acquire } = await interruptedRestart( + undefined, + undefined, + undefined, + codexOnAnotherTransport() + ) + const fence = store.getRecord(SESSION)!.lease.runtimeFence + + expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ + ok: false, + refusal: { code: 'structured_agent_session_unsupported' } + }) + expect(acquire).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)!.lease.runtimeFence).toBe(fence) + expect(await host.history({ sessionId: SESSION, direction: 'tail' })).toMatchObject({ ok: true }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts index 04d27555bd5c..3e558d1c32d7 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 @@ -48,6 +48,7 @@ let closeSession: Mock let startupLimits: Partial | undefined const providerStarted = vi.fn() +const recordLiveListing = vi.fn() const openGates = new Map void>() async function proveOpen(model = 'default'): Promise { @@ -99,7 +100,7 @@ async function startHost(): Promise { }, modelCatalog: { read: vi.fn(async () => ({ origin: 'unknown' as const })), - recordLiveListing: vi.fn(), + recordLiveListing, prewarm: vi.fn(async () => {}), stop: vi.fn(), providerStarted @@ -130,6 +131,7 @@ beforeEach(async () => { resetHostTestOperationIds() startupLimits = undefined providerStarted.mockReset() + recordLiveListing.mockReset() openGates.clear() acquire = vi.fn(async (input) => acquisition(input)) dispatch = vi.fn(async () => ({ @@ -445,6 +447,25 @@ describe('what a started child reports', () => { await host.flushStreamedEvents(SESSION) expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' }) }) + + it('saves a settings readback that arrives before open proves the start', async () => { + await restartWith(publishFirst) + await heldBehindStart('hello') + const listing = { models: [], configuredDefault: { modelId: 'opus' } } + // Claude's settings readback can land while the host still holds the child as starting. + const reported = host.handleAdapterEvent({ + type: 'options-reported', + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + reportedOptions: { model: 'opus' }, + catalogListing: listing + }) + await proveOpen() + await reported + + await eventually(() => expect(recordLiveListing).toHaveBeenCalledWith(SESSION, listing)) + }) }) function startupAttemptOpen(): boolean { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 0891fa6f1705..62cbe6260072 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -439,4 +439,34 @@ describe('provider-exit settlement', () => { expect(generationEnded).toHaveBeenLastCalledWith(SESSION, { restate: true }) expect(log.scopes()).toEqual(['exit-lifecycle-barrier', 'exit-settlement']) }) + + it('ignores an exit reported for an earlier generation at the same fence', async () => { + const appendLifecycleBatch = vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })) + const session: StructuredAgentSessionChildExitSession = { + child: { generation: GENERATION, fence: 7, phase: 'ready' }, + journal: exitJournalDouble({ items: () => [], appendLifecycleBatch }) + } + const { store } = mutableStore() + const context: StructuredAgentSessionChildExitContext = { + logger: recordingStructuredAgentSessionLogger().logger, + store, + sessions: new Map([[SESSION, session]]), + flushLifecycle: async () => ({ ok: true }), + generationEnded: vi.fn(), + serialize: async (_sessionId, task) => task(), + now: () => 1 + } + await settleStructuredAgentSessionChildExit(context, { + type: 'ended', + sessionId: SESSION, + reason: 'provider exited', + cause: 'unexpected-exit', + fence: 7, + acquisitionGeneration: 'generation-stale' + }) + + expect(store.getRecord().lease.claimStatus).toBe('live') + expect(session.child?.generation).toBe(GENERATION) + expect(appendLifecycleBatch).not.toHaveBeenCalled() + }) }) diff --git a/src/main/native-chat/structured-chat-naming.test.ts b/src/main/native-chat/structured-chat-naming.test.ts index abb907ec48e7..892705354ab1 100644 --- a/src/main/native-chat/structured-chat-naming.test.ts +++ b/src/main/native-chat/structured-chat-naming.test.ts @@ -8,6 +8,17 @@ import { createStructuredChatNamingHandler, type StructuredChatNamingDeps } from './structured-chat-naming' +import { firstStructuredChatNamingPrompt } from '../../shared/structured-chat-naming-eligibility' +import { + attach, + CALLER, + envelope, + hostTestState +} from './agent-session-wire/structured-agent-session-host-test-harness' +import { + HOST_TEST_SESSION, + hostTestMessage +} from './agent-session-wire/structured-agent-session-host-test-data' function deferred() { let resolve: (value: T) => void = () => { @@ -300,3 +311,27 @@ describe.each(['claude', 'codex'] as const)('structured %s chat naming', (provid print.mockRestore() }) }) + +it("names a chat from a real host's status feed once its first send runs", async () => { + const { host, store, log } = hostTestState() + host.deps.onSessionStatusChanged = createStructuredChatNamingHandler({ + getStore: () => store, + getSettings: () => ({}), + workerTaskTitle: () => null, + readFirstPrompt: async (id, startedAt) => + firstStructuredChatNamingPrompt(await host.journalSnapshot(id), startedAt), + generate: async () => 'auth/login', + onNamed: vi.fn(), + logger: log.logger + }) + await attach() + + const body = hostTestMessage('Please repair the login flow') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + + await vi.waitFor(() => + expect(store.getRecord(HOST_TEST_SESSION)?.conversationName).toBe('auth/login') + ) +}) diff --git a/src/main/pi/rpc-session-startup.test.ts b/src/main/pi/rpc-session-startup.test.ts index e39c84ec3457..1395a6f8a58d 100644 --- a/src/main/pi/rpc-session-startup.test.ts +++ b/src/main/pi/rpc-session-startup.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { closePiRpcAdapterFixtures, + file, restart, sessionId, setup, @@ -52,4 +53,23 @@ describe('Pi host awaited open', () => { stateReply.resolve(state) await expect(opening).rejects.toThrow('lane failed during startup') }) + + it('teaches the account default only from a new session started with no pick', async () => { + const h = await setup() + expect(h.started.catalogListing?.configuredDefault).toEqual({ + modelId: 'anthropic/model-1', + effort: null + }) + + h.resolveLaunch.mockResolvedValueOnce({ ...(await h.resolveLaunch()), sessionFile: file }) + const { acquired } = await restart(h, () => {}) + const resumed = await h.adapter.open({ + sessionId, + fence: 8, + acquisitionGeneration: acquired.acquisitionGeneration ?? null, + signal: new AbortController().signal + }) + expect(resumed.catalogListing).toBeDefined() + expect(resumed.catalogListing).not.toHaveProperty('configuredDefault') + }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts index 8e3ca3fbf942..afa22af7a1af 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts @@ -283,3 +283,21 @@ it.each(['options', 'firstMessage'] as const)( expect(rig.adapter.acquire).not.toHaveBeenCalled() } ) + +it('answers an old client whose start failed before spawning in plain words', async () => { + const raw = 'codex sessions pin CODEX_HOME, not CLAUDE_CONFIG_DIR' + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + rig.adapter.acquire.mockRejectedValueOnce(new AgentSessionPreSpawnError(new Error(raw))) + + const answer = await call('agentSession.create', params(), legacy) + + // Thrown as before, so the wire code an old client reads is unchanged; only the words are new. + expect(answer).toMatchObject({ + ok: false, + error: { + code: 'runtime_error', + message: "Codex couldn't start. Send your message to try again." + } + }) + expect(JSON.stringify(answer)).not.toContain(raw) +}) From fdca336c13684d9a1a228b9b3b7ab3bec8cc7348 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:17:16 -0700 Subject: [PATCH 100/127] Read a rewind in doubt outside the chat's serialize and apply it in the commit The started settle and the attach's ready-child path read the provider's history first, then apply what it proves in a short commit that re-checks the rewind it read. --- ...ructured-agent-session-provider-started.ts | 60 +++++++++++---- .../structured-rewind-recovery.ts | 74 ++++++++++++++++--- 2 files changed, 110 insertions(+), 24 deletions(-) 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 31b15f6aa742..60072a50cb4b 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 @@ -19,7 +19,11 @@ import { import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { recordAgentSessionProviderHandle } from '../../runtime/agent-session-provider-handle-transition' -import { recoverStructuredRewind } from './structured-rewind-recovery' +import { + applyStructuredRewindRecovery, + readStructuredRewindRecovery, + type StructuredRewindRecoveryEvidence +} from './structured-rewind-recovery' export type StructuredAgentSessionProviderStartedContext = { deps: StructuredAgentSessionHostDeps @@ -49,6 +53,8 @@ export async function settleStructuredAgentSessionProviderStarted( event: OpenedProvider, signal?: AbortSignal ): Promise { + // The provider read runs before the commit, so no send waits behind it. + const rewind = await readRewindAtStart(context, event.sessionId, signal) // Serialized behind the attach that published this child, so the lease it proved is committed. await context.serialize(event.sessionId, async () => { signal?.throwIfAborted() @@ -68,7 +74,7 @@ export async function settleStructuredAgentSessionProviderStarted( context.stopStartingChild(event.sessionId, child) return null } - if (!(await recoverRewindAtStart(context, event.sessionId, session, signal))) { + if (!(await applyRewindAtStart(context, event.sessionId, session, rewind))) { context.stopStartingChild(event.sessionId, child) return null } @@ -93,35 +99,63 @@ export async function settleStructuredAgentSessionProviderStarted( /** A rewind left in doubt is settled by the first child that can read the provider's history: * this one, now that its protocol session is open, before it is handed anything a recovered * rewind would move. One that stays in doubt fails the start, as it would refuse every send. */ -async function recoverRewindAtStart( +async function readRewindAtStart( context: StructuredAgentSessionProviderStartedContext, sessionId: string, - session: StructuredAgentSessionHostSession, signal?: AbortSignal -): Promise { +): Promise { try { - await recoverStructuredRewind( + return await readStructuredRewindRecovery( context.deps, sessionId, - session.journal, structuredAgentSessionConversationFence(context.deps.store, sessionId), context.deps.adapter, - context.now, - undefined, signal ) + } catch (error) { + return rewindRecoveryFailed(context, sessionId, error, signal) + } +} + +async function applyRewindAtStart( + context: StructuredAgentSessionProviderStartedContext, + sessionId: string, + session: StructuredAgentSessionHostSession, + evidence: StructuredRewindRecoveryEvidence | 'failed' +): Promise { + if (evidence === 'failed') { + return false + } + try { + await applyStructuredRewindRecovery( + context.deps, + sessionId, + session.journal, + structuredAgentSessionConversationFence(context.deps.store, sessionId), + evidence, + context.now + ) return true } catch (error) { - if (signal?.aborted) { - return false - } + rewindRecoveryFailed(context, sessionId, error) + return false + } +} + +function rewindRecoveryFailed( + context: StructuredAgentSessionProviderStartedContext, + sessionId: string, + error: unknown, + signal?: AbortSignal +): 'failed' { + if (!signal?.aborted) { context.deps.logger.warn('settling a rewind in doubt at the start failed', { scope: 'rewind-recovery', sessionId, error }) - return false } + return 'failed' } /** The handle the child's protocol session answered with, recorded before it is handed anything: 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 23a51acec32c..9b68bfe797d2 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 @@ -69,6 +69,17 @@ async function settleUnsupportedClaudeRewind( } } +type RecoveredRewind = Awaited< + ReturnType> +> + +/** What the provider says about a rewind in doubt, read with no journal or record write, so a + * caller can read it outside the session's serialize and apply it inside. Null: none in doubt. */ +export type StructuredRewindRecoveryEvidence = { + rewind: AgentSessionRewindRecord + recovered?: RecoveredRewind +} | null + /** Recovery observes provider state; it never repeats an ambiguous native mutation. */ export async function recoverStructuredRewind( deps: RewindRecoveryDeps, @@ -84,28 +95,69 @@ export async function recoverStructuredRewind( ) => JournalOperationReceipt, signal?: AbortSignal ): Promise { + const evidence = await readStructuredRewindRecovery(deps, sessionId, fence, adapter, signal) + signal?.throwIfAborted() + await applyStructuredRewindRecovery(deps, sessionId, journal, fence, evidence, now, receipt) +} + +export async function readStructuredRewindRecovery( + { store }: RewindRecoveryDeps, + sessionId: string, + fence: number, + adapter?: StructuredAgentSessionAdapter, + signal?: AbortSignal +): Promise { signal?.throwIfAborted() + const rewind = store.getRecord(sessionId)?.rewind + if (rewind?.phase !== 'provider-succeeded' && rewind?.phase !== 'prepared') { + return null + } + const target = parseAgentJournalItemKey(rewind.providerItemId ?? rewind.itemId) + if (target?.provider !== 'codex' || rewind.hydrationVerified) { + return { rewind } + } + const read = adapter?.recoverRewind?.({ + sessionId, + fence, + beforeTurnId: target.turnId, + ...(signal ? { signal } : {}) + }) + const recovered = signal + ? await waitForPromiseWithSignal(Promise.resolve(read), signal) + : await read + return recovered ? { rewind, recovered } : { rewind } +} + +/** The journal and record writes the evidence proves; a rewind that moved since the read is not + * this evidence's to settle. */ +export async function applyStructuredRewindRecovery( + deps: RewindRecoveryDeps, + sessionId: string, + journal: AgentSessionJournal, + fence: number, + evidence: StructuredRewindRecoveryEvidence, + now: () => number = Date.now, + receipt?: ( + rewind: AgentSessionRewindRecord, + fence: number, + cursor: AgentJournalCursor + ) => JournalOperationReceipt +): Promise { const { store } = deps let rewind = store.getRecord(sessionId)?.rewind - if (rewind?.phase !== 'provider-succeeded' && rewind?.phase !== 'prepared') { + if (!evidence || (rewind?.phase !== 'provider-succeeded' && rewind?.phase !== 'prepared')) { return } + if (!isDeepStrictEqual(rewind, evidence.rewind)) { + throw new Error('agent_session_rewind:stale-evidence') + } const target = parseAgentJournalItemKey(rewind.providerItemId ?? rewind.itemId) if (target?.provider === 'claude') { await settleUnsupportedClaudeRewind(deps, sessionId, fence, rewind) return } if (target?.provider === 'codex' && !rewind.hydrationVerified) { - const read = adapter?.recoverRewind?.({ - sessionId, - fence, - beforeTurnId: target.turnId, - ...(signal ? { signal } : {}) - }) - const recovered = signal - ? await waitForPromiseWithSignal(Promise.resolve(read), signal) - : await read - signal?.throwIfAborted() + const { recovered } = evidence if (!recovered?.ok) { // The target is still in the provider's history, and the journal is replaced only once the // revert is proven, so both still hold it even when the provider acknowledged the revert. From 8637a03b0f372bd71f4ad2d48cd78d2bab49d406 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:17:32 -0700 Subject: [PATCH 101/127] Accept messages while the agent starts: run the start outside the chat's serialize The chat's serialize was both where a send was admitted and where its agent started, so every slow step of a start (closing-child join, lease reconcile, recovery probes, resolvers, history read, spawn, sink drain, provider dispatch) held every later send. A start now registers in one short step before its first wait: its abort controller (which Stop, close and quit already reach outside the lock), its startup clock (the 10-minute ceiling now counts from here) and quit's drain. Every wait after runs outside the lock, raced with that signal, and every write is a commit that first re-checks the start is still the session's. A start that loses the check is late: its spawn is closed through the post-acquisition failure path and nothing is handed to it. - The delivery loop's prepare returns a start handle; the loop awaits the start outside, then re-prepares. Handover writes the pending row and begins the dispatch abort handle in one commit, calls the provider outside after re-checking the child, and resolves in a second commit. - Ready operations claim a start instead of running one in their step. - A client attach runs the same steps under one serialize, as before. - An exit reported between spawn and publish is held for the publish commit, which ends the child it indexes. - An unproven close still fails only what was queued when its start was asked for. --- .../acp-structured-host-option-hang.test.ts | 3 +- ...-agent-session-accept-then-deliver.test.ts | 33 +-- .../structured-agent-session-acquisition.ts | 10 +- .../structured-agent-session-agent-start.ts | 227 ++++++++++-------- ...structured-agent-session-attach-failure.ts | 30 +++ .../structured-agent-session-attach-flow.ts | 119 ++++----- .../structured-agent-session-attach-lock.ts | 18 ++ ...ured-agent-session-attach-orchestration.ts | 199 +++++++-------- ...structured-agent-session-attach-publish.ts | 117 +++++++++ .../structured-agent-session-child-close.ts | 30 ++- .../structured-agent-session-child-exit.ts | 33 ++- .../structured-agent-session-delivery-loop.ts | 152 ++++++------ ...structured-agent-session-event-recovery.ts | 1 + .../structured-agent-session-handover.ts | 203 ++++++++++++++++ .../structured-agent-session-host-delivery.ts | 21 +- ...ctured-agent-session-host-runtime-state.ts | 4 +- .../structured-agent-session-host.ts | 13 +- ...red-agent-session-mutation-context.test.ts | 4 +- ...ructured-agent-session-mutation-context.ts | 5 +- ...tructured-agent-session-operation-child.ts | 91 +++++++ ...nt-session-operation-start-refusal.test.ts | 15 +- ...tructured-agent-session-ready-operation.ts | 121 +++------- ...tured-agent-session-recovery-resolution.ts | 17 ++ ...ructured-agent-session-send-preparation.ts | 2 +- ...uctured-agent-session-start-eligibility.ts | 85 +++++++ ...uctured-agent-session-start-failure-row.ts | 42 +++- .../structured-agent-session-start-flight.ts | 99 ++++++++ ...-agent-session-startup-attempt-contract.ts | 12 + ...tructured-agent-session-startup-attempt.ts | 77 +++--- .../structured-agent-session-turns.ts | 178 +------------- ...t-session-wedged-profile-migration.test.ts | 2 +- 31 files changed, 1251 insertions(+), 712 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-attach-lock.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-attach-publish.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-operation-child.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-start-eligibility.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-start-flight.ts 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 547cf84225b8..96f268f8582f 100644 --- a/src/main/acp/acp-structured-host-option-hang.test.ts +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -52,7 +52,8 @@ describe('a Grok model pick Grok never answers during send', () => { const invalid = host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}, { payloadFingerprint: 'invalid' }) }) - expect(await within(invalid, 100)).toBe('pending') + // The write runs outside the session's serialize, so even a refused Stop is answered at once. + expect(await within(invalid, 100)).toMatchObject({ settled: 'resolved' }) const request = { envelope: envelope('agentSession.cancel', {}) } expect(await within(host.cancel(CALLER, request), 2_000)).toMatchObject({ settled: 'resolved' }) expect(await invalid).toMatchObject({ ok: false }) 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 87138afd2add..dc77f4c468ac 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 @@ -707,25 +707,6 @@ describe('Stop withdraws what is queued', () => { expect(acquire).toHaveBeenCalledTimes(1) }) - it('withdraws a message whose start holds the queue: nothing is handed over (W17b)', async () => { - await host.close(SESSION, 'evict') - const starting = Promise.withResolvers() - acquire.mockImplementationOnce(async (input) => { - await starting.promise - return spawnChild(input) - }) - const id = await accept('hello') - await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) - const stopped = stop() - - starting.resolve() - expect(await stopped).toMatchObject({ ok: true }) - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) - expect(await submission(id)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) - expect((await submission(id))?.handedOverAt).toBeUndefined() - expect(dispatch).not.toHaveBeenCalled() - }) - it('stops a child still proving its start, withdrawing what was held for it, and the next send starts another (W17c)', async () => { const closeSession = vi.fn(async () => true) adapterExtras = { closeSession } @@ -832,7 +813,6 @@ describe('a close that stops the child and then a wind-down step fails', () => { it.each([{ end: 'evict', starting: true, kind: 'chatClosed', verdict: null }] as const)( 'rejects what is queued as $kind after a $end (during startup: $starting)', async ({ end, starting, kind, verdict }) => { - const started = Promise.withResolvers() adapterExtras = { // Once: the host's own teardown acknowledges again. acknowledgeSessionRelease: vi.fn().mockImplementationOnce(() => { @@ -841,17 +821,12 @@ describe('a close that stops the child and then a wind-down step fails', () => { } await host.close(SESSION, 'evict') await startHost() - acquire.mockImplementationOnce(async (input) => { - await started.promise - return (starting ? spawnStartingChild : spawnChild)(input) - }) + acquire.mockImplementationOnce(starting ? spawnStartingChild : spawnChild) const id = await accept('hello') - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + await eventually(() => expect(childPhase()).toBe('starting')) - // Asked for during the start step, the close runs ahead of the handover step. - const ending = END_CHILD[end]() - started.resolve() - await expect(ending).resolves.toBeUndefined() + // Asked for while the child is still starting, the close runs ahead of the handover step. + await expect(END_CHILD[end]()).resolves.toBeUndefined() await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) const rejected = (await submission(id))! diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index 5ef3064a0d21..6e419540d95f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -1,4 +1,5 @@ import { isDeepStrictEqual } from 'node:util' +import { abortSignalReason } from '../../../shared/abort-signal-reason' import type { AgentSessionProcessIdentity, AgentSessionRecord @@ -40,12 +41,14 @@ export async function acquireOwner( try { try { await input.onAcquiring?.() - // A close or Stop that landed while the attach was still reconciling launches nothing. - input.acquireSignal?.throwIfAborted() } catch (error) { throw new AgentSessionPreSpawnError(error) } const permissionMode = await input.launchPermissionMode?.(record) + // Right before the spawn: a close, Stop or quit that landed during any wait above launches nothing. + if (input.acquireSignal?.aborted) { + throw new AgentSessionPreSpawnError(abortSignalReason(input.acquireSignal)) + } const attempt = mintStructuredAgentSessionStartupAttempt({ record, identity: journalIdentityFor(record, input.params), @@ -54,7 +57,8 @@ export async function acquireOwner( ...(permissionMode ? { permissionMode } : {}), ...(input.eventSink ? { events: input.eventSink } : {}), ...(input.acquireSignal ? { signal: input.acquireSignal } : {}), - ...(input.startupOptions ? { startupOptions: input.startupOptions } : {}) + ...(input.startupOptions ? { startupOptions: input.startupOptions } : {}), + ...(input.startupAttemptId ? { attemptId: input.startupAttemptId } : {}) }) const progress = input.onStartupAttempt?.(attempt) const acquired = await input.adapter.acquire({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index 64f6ab2bc82f..50162213bfb2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -1,9 +1,9 @@ // Giving a session its provider child back. // // Only work starts one: the delivery loop for a queued message, and the few operations that need -// the provider itself — never a view, and never the app launching. It runs inside the session's -// serialize, with the attach it is given, so the eligibility it reads is the one the attach acts -// on. +// the provider itself — never a view, and never the app launching. It runs outside the session's +// serialize as one registered flight (`structured-agent-session-start-flight`), so a message sent +// meanwhile is accepted at once; each of its writes is a fenced commit under the serialize. import { providerDiagnosticOf, @@ -14,10 +14,7 @@ import { argumentProblemOf } from '../structured-agent-arguments-error' import { agentSessionRefusalFromReference, readAgentSessionRefusalReference, - refuse, - refuseUnclassified, - type AgentSessionRefusalDetailsByCode, - type AgentSessionWireRefusalCode + refuseUnclassified } from '../../../shared/agent-session-wire-refusals' import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' import type { @@ -25,20 +22,22 @@ import type { AgentSessionMutationResult, AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import { terminalOwnerRefusalMessage } from '../../../shared/agent-session-legacy-handoff-lease' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' -import { attachStructuredAgentSessionUnderSerialize } from './structured-agent-session-attach-orchestration' +import { randomUUID } from 'node:crypto' +import { attachStructuredAgentSessionForStart } from './structured-agent-session-attach-orchestration' +import type { StructuredAgentSessionAttachLock } from './structured-agent-session-attach-lock' +import { structuredAgentSessionStartFlightLock } from './structured-agent-session-start-flight' +import { isStructuredAgentSessionStartupExpired } from './structured-agent-session-startup-attempt-contract' import { failedCreateRefusal } from './structured-agent-session-failed-create-refusal' -import { hostCanStartRecord } from './structured-agent-session-provider-support' import { joinClosingStructuredAgentSessionChild, releaseLeaseOfEndedStructuredAgentSessionChild } from './structured-agent-session-child-close' export { joinClosingStructuredAgentSessionChild } import { - structuredAgentSessionResumeOperationId, - structuredAgentSessionResumeParams -} from './structured-agent-session-resume-eligibility' + refusedStructuredAgentSessionStart, + structuredAgentSessionStartParams +} from './structured-agent-session-start-eligibility' /** A resume answers with the attach's own refusal, verdict and all, so the asker can tell a lease * someone else is settling from an owner that will not come back. */ @@ -61,125 +60,132 @@ export type StructuredAgentSessionResumeOutcome = const AGENT_START_CALLER_KEY = 'trusted-local:agent-start' /** - * Gives the session a provider child if it has none, for a caller inside its serialize — which is - * what makes "if it has none" exact: two askers run this in turn, and the second finds the first - * one's child. A failed start leaves the next asker to make its own. + * Gives the session a provider child if it has none, for a caller OUTSIDE its serialize. The short + * step that registers the start is what makes "if it has none" exact: a second asker finds the + * first one's child, or joins its start. A failed start leaves the next asker to make its own. */ -export async function ensureStructuredAgentSessionAgent( +export async function startStructuredAgentSessionAgent( context: StructuredAgentSessionAttachContext, sessionId: string, startedFor?: string ): Promise { - // A child a stop began closing takes no input, and none may start beside it: the start waits on - // that close, and is refused while its exit stays unverifiable. - const closed = await joinClosingStructuredAgentSessionChild(context, sessionId) - if (!closed.ok) { - return closed - } - if (context.sessions.get(sessionId)?.child) { - return { ok: true } - } - const started = await startStructuredAgentSessionAgent(context, sessionId, startedFor) - if (!started.ok || context.sessions.get(sessionId)?.child) { - return started - } - return refuseResume( - 'agent_session_ownership_unknown', - { reason: 'noProviderChild' }, - 'The session attached without a provider child to write to.' - ) + // Wrapped, so the step settles at registration and never waits on the start it registered. + const registered = await context.serialize<{ + outcome: Promise + }>(sessionId, async () => { + const child = context.sessions.get(sessionId)?.child + if (child && !child.close) { + return { outcome: Promise.resolve({ ok: true }) } + } + return { + outcome: + context.runtimeState.startFlights.get(sessionId)?.outcome ?? + registerStart(context, sessionId, startedFor) + } + }) + return registered.outcome } /** The same, for an operation's admission: a start that throws is that operation's refusal. */ -export function ensureStructuredAgentSessionAgentForOperation( +export function startStructuredAgentSessionAgentForOperation( context: StructuredAgentSessionAttachContext, sessionId: string ): Promise { - return ensureStructuredAgentSessionAgent(context, sessionId).catch((error: unknown) => { + return startStructuredAgentSessionAgent(context, sessionId).catch((error: unknown) => { // The error is Orca's own and goes to the log; the refusal says only that the start failed. context.deps.logger.warn('starting the agent for an operation failed', { scope: 'operation-agent-start', sessionId, error }) - return { - ok: false, - refusal: refuseUnclassified( - 'agent_session_owner_restart_failed', - agentSessionWriteNoticeEnglish(['restartFailed']) - ) - } + return { ok: false, refusal: startFailedRefusal() } }) } -async function startStructuredAgentSessionAgent( +/** Inside the register step, before any wait: the controller a Stop, close or quit aborts, the + * startup clock, and quit's drain all cover the start from here. */ +function registerStart( context: StructuredAgentSessionAttachContext, sessionId: string, startedFor: string | undefined ): Promise { + const { runtimeState } = context + const attemptId = randomUUID() + const acquire = runtimeState.acquireAborts.begin(sessionId) + runtimeState.startupAttempts.begin(sessionId, attemptId) + const lock = structuredAgentSessionStartFlightLock({ + sessionId, + attemptId, + signal: acquire.signal, + flights: runtimeState.startFlights, + serialize: context.serialize + }) + const outcome = context.tasks.trackAttach( + Promise.resolve() + .then(() => runStart(context, sessionId, startedFor, lock, attemptId)) + .catch((error: unknown) => { + if (!acquire.signal.aborted) { + throw error + } + const expired = isStructuredAgentSessionStartupExpired(acquire.signal.reason) + return { + ok: false as const, + refusal: startFailedRefusal(), + ...(expired ? { expired: true as const } : { aborted: true as const }) + } + }) + .finally(() => { + acquire.end() + runtimeState.startFlights.end(sessionId, attemptId) + runtimeState.startupAttempts.abandon(sessionId, attemptId) + }) + ) + runtimeState.startFlights.register(sessionId, { attemptId, outcome }) + return outcome +} + +async function runStart( + context: StructuredAgentSessionAttachContext, + sessionId: string, + startedFor: string | undefined, + lock: StructuredAgentSessionAttachLock, + attemptId: string +): Promise { + // A child a stop began closing takes no input, and none may start beside it: the start waits on + // that close, and is refused while its exit stays unverifiable. + const closed = await joinClosingStructuredAgentSessionChild(context, sessionId, lock) + if (!closed.ok) { + return closed + } const callerKey = AGENT_START_CALLER_KEY // The record is read only once this host has adjudicated it and recovery resolution has // concluded about any owner a failed attempt left in `recovering`, so the eligibility below sees - // the lease the resolver handed back. - const unreconciled = await context.reconcileLeases(sessionId) + // the lease the resolver handed back. Both probe outside; each commits in the store's own + // transaction, against the owner identity it probed. + const unreconciled = await lock.wait(context.reconcileLeases(sessionId)) if (unreconciled) { return { ok: false, refusal: unreconciled } } - await context.runtimeState.resolveRecovery(sessionId) - // A start needs the lease released; a release the exit's own wind-down could not write is - // re-derived from this host's proof of that exit, never refused on. - await releaseLeaseOfEndedStructuredAgentSessionChild(context, sessionId) - const record = context.deps.store.getRecord(sessionId) - if (!record) { - return refuseResume( - 'agent_session_identity_required', - { reason: 'recordMissing' }, - 'No structured session exists by that id.' - ) - } - if (!hostCanStartRecord(context.deps, record)) { - return refuseResume( - 'structured_agent_session_unsupported', - { reason: 'hostUnsupported' }, - 'This execution host cannot resume the requested structured agent session.' - ) - } - const params = structuredAgentSessionResumeParams( - record, - structuredAgentSessionResumeOperationId(context.now()), - context.sessions.get(sessionId)?.lastEndedChild - ) - if (!params) { - return record.lease.unreconciled - ? refuseResume( - 'execution_owner_reconciling', - { reason: 'hostReconciling' }, - 'This host has not yet adjudicated the session lease.' - ) - : record.lease.claimStatus === 'conflicted' - ? refuseResume( - 'agent_session_conflict', - { reason: 'claimConflicted' }, - terminalOwnerRefusalMessage(record.lease) - ) - : refuseResume( - 'agent_session_ownership_unknown', - { reason: 'notResumable' }, - 'The session lease is not one this host may resume.' - ) + await lock.wait(context.runtimeState.resolveRecovery(sessionId)) + const eligible = await lock.commit(async () => { + // A start needs the lease released; a release the exit's own wind-down could not write is + // re-derived from this host's proof of that exit, never refused on. + await releaseLeaseOfEndedStructuredAgentSessionChild(context, sessionId) + return structuredAgentSessionStartParams(context, sessionId, startedFor) + }) + if (!eligible.ok) { + return eligible } + const { params, startupOptions } = eligible let attached: AgentSessionMutationResult let acquisitionError: unknown let aborted = false let expired = false - const startupOptions = startedFor - ? context.sessions - .get(sessionId) - ?.journal.submissions() - .find((submission) => submission.clientMessageId === startedFor)?.options - : undefined + let published = false try { - attached = await attachStructuredAgentSessionUnderSerialize(context, callerKey, params, { + attached = await attachStructuredAgentSessionForStart(context, callerKey, params, { + lock, + startupAttemptId: attemptId, ...(startedFor === undefined ? {} : { startedFor }), ...(startupOptions ? { startupOptions } : {}), onAcquisitionFailed: (error) => { @@ -190,6 +196,9 @@ async function startStructuredAgentSessionAgent( }, onStartupExpired: () => { expired = true + }, + onPublished: () => { + published = true } }) } catch (error) { @@ -208,9 +217,23 @@ async function startStructuredAgentSessionAgent( } throw error } - return attached.ok + if (!attached.ok) { + return withDiagnostic(attached.refusal, acquisitionError, { aborted, expired }) + } + return published ? { ok: true } - : withDiagnostic(attached.refusal, acquisitionError, { aborted, expired }) + : refusedStructuredAgentSessionStart( + 'agent_session_ownership_unknown', + { reason: 'noProviderChild' }, + 'The session attached without a provider child to write to.' + ) +} + +function startFailedRefusal(): AgentSessionWireRefusal { + return refuseUnclassified( + 'agent_session_owner_restart_failed', + agentSessionWriteNoticeEnglish(['restartFailed']) + ) } function withDiagnostic( @@ -254,11 +277,3 @@ function settledResumeRefusal( context.deps.store.getRecord(sessionId) ) } - -function refuseResume( - code: C, - details: NoInfer, - message: string -): StructuredAgentSessionResumeOutcome { - return { ok: false, refusal: refuse(code, details, message) } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts index a8368d067893..2dfbf69f923e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-failure.ts @@ -87,3 +87,33 @@ export function endStructuredAgentSessionReleasedChild( // The released acquisition was the live generation: readers and the queued-card drain re-derive. context.generationEnded?.(sessionId) } + +/** A reservation that support drift invalidated, released as processless. */ +export async function settleUnsupportedReservation( + input: AttachFlowInput, + record: AgentSessionRecord +): Promise { + const spawnToken = record.lease.reservedSpawnToken + if (!spawnToken) { + return + } + try { + await input.store.settleFailedAcquisition({ + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + spawnToken, + callerKey: input.callerKey, + operationId: input.params.envelope.clientOperationId, + outcome: { + status: 'failed', + code: 'structured_agent_session_unsupported', + details: { reason: 'hostUnsupported' }, + message: 'Structured session support changed before the provider could start.' + }, + exitProof: 'processless', + now: input.now() + }) + } catch (error) { + throw new AggregateError([error], 'agent session unsupported reservation settlement failed') + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 82487474e415..20706683112c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -1,5 +1,8 @@ import { refuse } from '../../../shared/agent-session-wire-refusals' -import { settlePostAcquisitionAttachFailure } from './structured-agent-session-attach-failure' +import { + settlePostAcquisitionAttachFailure, + settleUnsupportedReservation +} from './structured-agent-session-attach-failure' import { failedAcquisitionRefusal, failedAcquisitionSettlement, @@ -51,6 +54,10 @@ 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 { + heldStructuredAgentSessionAttachLock, + type StructuredAgentSessionAttachLock +} from './structured-agent-session-attach-lock' export type AttachedOwner = 'acquired' | 'kept' | 'none' @@ -67,6 +74,10 @@ export type AttachFlowInput = { recordPhase?: AgentSessionCreatePhaseRecorder /** Aborted when a close, or a Stop admitted now, must not wait behind this attach's acquire. */ acquireSignal?: AbortSignal + /** How the steps below meet the session's serialize; absent, the caller holds it. */ + lock?: StructuredAgentSessionAttachLock + /** The id the host registered this start under; see `StructuredAgentSessionStartupAttempts`. */ + startupAttemptId?: string /** The attempt this attach's acquire runs under, minted before the adapter is called; what it * answers learns of the child's spawn and output. */ onStartupAttempt?: ( @@ -84,6 +95,11 @@ export type AttachFlowInput = { acquisitionGeneration: string | null, owner: AttachedOwner ) => Promise | void + /** The waits the publish needs (the sink's drain, a provider read), outside any commit. */ + beforePublish?: (owner: AttachedOwner) => Promise + /** The commit that makes the attach's child the session's; it records the attach's outcome, which + * comes before the child is indexed. */ + onPublish?: (attached: AttachedJournal, recordOutcome: () => Promise) => Promise /** Host-owned provider sink, bound to the journal inside `onAttached`. */ eventSink?: StructuredAgentSessionEventSink /** Stops acquisition-window events targeting the superseded journal. */ @@ -136,6 +152,7 @@ export async function performAttach( let unsupportedReservationSettlementAttempted = false let replayed = false let providerHistoryWindow: ProviderHistoryWindow | null = null + const lock = input.lock ?? heldStructuredAgentSessionAttachLock(input.acquireSignal) const preparedTranscript = store.getRecord(sessionId) ? { ok: true as const, items: null } : await prepareAdoptedTranscript(params) @@ -144,15 +161,17 @@ export async function performAttach( } try { const reserved = await withAgentSessionCreatePhase('reserve_owner', input.recordPhase, () => - store.reserveOwner( - reserveRequestFor({ - sessionId, - params, - authority: input.authority, - callerKey: input.callerKey, - fingerprint: admitted.fingerprint, - now: input.now() - }) + lock.commit(() => + store.reserveOwner( + reserveRequestFor({ + sessionId, + params, + authority: input.authority, + callerKey: input.callerKey, + fingerprint: admitted.fingerprint, + now: input.now() + }) + ) ) ) record = reserved.record @@ -189,12 +208,14 @@ export async function performAttach( // Sample provider history before a new child is acquired. Once acquireOwner // starts the child, the adapter's liveness signal intentionally becomes // conservative and an absent prompt can no longer prove non-delivery. - providerHistoryWindow = await readProviderHistoryWindow({ - adapter: input.adapter, - identity: journalIdentityFor(record, params), - accountHome: record.accountHome, - ownerAlreadyAdmitted: agentSessionLeaseAdmitsWriter(record.lease) - }) + providerHistoryWindow = await lock.wait( + readProviderHistoryWindow({ + adapter: input.adapter, + identity: journalIdentityFor(record, params), + accountHome: record.accountHome, + ownerAlreadyAdmitted: agentSessionLeaseAdmitsWriter(record.lease) + }) + ) if (agentSessionLeaseAdmitsWriter(record.lease)) { // Kept: re-attached to the live child. } else if (replayed && reserved.operationRow.outcome.status === 'succeeded') { @@ -259,21 +280,30 @@ export async function performAttach( let attached: AttachedJournal try { - await input.beforeJournalOpen?.() - attached = await attachJournal({ - record, - params, - adapter: input.adapter, - openConversation: input.openConversation, - providerHistoryWindow - }) - await importAdoptedTranscript(params, attached, record, preparedTranscript.items) - await input.onAttached(attached, acquisitionGeneration, owner) - await store.recordOperationOutcome({ - callerKey: input.callerKey, - operationId: params.envelope.clientOperationId, - outcome: { status: 'succeeded', sessionId } + const acquired = record + attached = await lock.commit(async () => { + await input.beforeJournalOpen?.() + const journal = await attachJournal({ + record: acquired, + params, + adapter: input.adapter, + openConversation: input.openConversation, + providerHistoryWindow + }) + await importAdoptedTranscript(params, journal, acquired, preparedTranscript.items) + await input.onAttached(journal, acquisitionGeneration, owner) + return journal }) + await lock.wait(input.beforePublish?.(owner) ?? Promise.resolve()) + const recordOutcome = () => + store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId } + }) + await lock.commit(() => + input.onPublish ? input.onPublish(attached, recordOutcome) : recordOutcome() + ) } catch (error) { return settlePostAcquisitionAttachFailure(input, record, error) } @@ -298,32 +328,3 @@ export async function performAttach( } } } - -async function settleUnsupportedReservation( - input: AttachFlowInput, - record: AgentSessionRecord -): Promise { - const spawnToken = record.lease.reservedSpawnToken - if (!spawnToken) { - return - } - try { - await input.store.settleFailedAcquisition({ - sessionId: record.sessionId, - fence: record.lease.runtimeFence, - spawnToken, - callerKey: input.callerKey, - operationId: input.params.envelope.clientOperationId, - outcome: { - status: 'failed', - code: 'structured_agent_session_unsupported', - details: { reason: 'hostUnsupported' }, - message: 'Structured session support changed before the provider could start.' - }, - exitProof: 'processless', - now: input.now() - }) - } catch (error) { - throw new AggregateError([error], 'agent session unsupported reservation settlement failed') - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-lock.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-lock.ts new file mode 100644 index 000000000000..c01e9a7dd335 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-lock.ts @@ -0,0 +1,18 @@ +// How an attach's steps meet the session's serialize. A client attach already holds it, so every +// step runs in place; an agent start runs outside it, races each wait with its abort signal, and +// commits each write under it after re-checking its fence (`structured-agent-session-start-flight`). + +export type StructuredAgentSessionAttachLock = { + readonly signal: AbortSignal + /** A wait on something outside Orca's state: a probe, a resolver, a provider read. */ + wait: (work: Promise) => Promise + /** A write to the session's state, which never waits on a process. */ + commit: (step: () => Promise) => Promise +} + +/** For a caller already inside the session's serialize. */ +export function heldStructuredAgentSessionAttachLock( + signal: AbortSignal = new AbortController().signal +): StructuredAgentSessionAttachLock { + return { signal, wait: (work) => work, commit: (step) => step() } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 2a6bcb7570c8..8c3fc32e0b69 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -1,4 +1,12 @@ -import { recoverStructuredRewind } from './structured-rewind-recovery' +import { + readStructuredRewindRecovery, + type StructuredRewindRecoveryEvidence +} from './structured-rewind-recovery' +import { + publishAttachedChild, + type AttachCandidate, + type BoundAttach +} from './structured-agent-session-attach-publish' // The host's attach, lifted out of the host class. // // Attach is the one operation that touches every collaborator the host owns — the lease @@ -10,8 +18,7 @@ import { randomUUID } from 'node:crypto' import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree' import type { AgentSessionAttachResult, - AgentSessionMutationResult, - AgentSessionTurnActivity + AgentSessionMutationResult } from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' @@ -25,23 +32,22 @@ import { refuseAgentSessionMutation } from './structured-agent-session-mutation- import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' -import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' -import { - indexProviderChild, - structuredAgentSessionConversationFence -} from './structured-agent-session-provider-child' -import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { noteStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { isStructuredAgentSessionStartupExpired } from './structured-agent-session-startup-attempt' -import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' +import { + isStructuredAgentSessionStartupExpired, + type StructuredAgentSessionStartupAttempt +} from './structured-agent-session-startup-attempt-contract' import { addAgentSessionCreatePhaseAttributes, withAgentSessionCreatePhase, withAgentSessionSpan, type AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' +import { + heldStructuredAgentSessionAttachLock, + type StructuredAgentSessionAttachLock +} from './structured-agent-session-attach-lock' export type StructuredAgentSessionAttachOptions = { hostLaunchDirectory?: string @@ -54,23 +60,22 @@ export type StructuredAgentSessionAttachOptions = { onAborted?: () => void /** The startup limit aborted this attach's acquire: it failed, nobody stopped it. */ onStartupExpired?: () => void + /** An agent start's: its steps run outside the session's serialize and commit under it. */ + lock?: StructuredAgentSessionAttachLock + startupAttemptId?: string + /** The attach made its child the session's. */ + onPublished?: () => void } -/** - * The attach itself, for a caller already inside the session's serialize. - * - * That is every caller that has to know what the session looks like RIGHT NOW: the delivery loop, - * and an operation that needs the provider. They run their check and this attach in one serialized - * step, so "the session has no child" is still true when - * the attach starts. `attachStructuredAgentSession` is this under `serialize`, for a client. - */ -export function attachStructuredAgentSessionUnderSerialize( +/** The attach an agent start runs under its own lock (`structured-agent-session-start-flight`); + * `attachStructuredAgentSession` runs the same steps inside one `serialize`, for a client. */ +export function attachStructuredAgentSessionForStart( context: StructuredAgentSessionAttachContext, callerKey: string, params: AgentSessionAttachParams, - options: StructuredAgentSessionAttachOptions = {} + options: StructuredAgentSessionAttachOptions & { lock: StructuredAgentSessionAttachLock } ): Promise> { - return context.tasks.trackAttach(runAttach(context, callerKey, params, options)) + return runAttach(context, callerKey, params, options) } export function attachStructuredAgentSession( @@ -111,15 +116,19 @@ async function runAttach( params: AgentSessionAttachParams, options: StructuredAgentSessionAttachOptions ): Promise> { - // Begun first, so a close or Stop during any phase below still stops the start. - const acquire = context.runtimeState.acquireAborts.begin(params.envelope.sessionId) + // Begun first, so a close or Stop during any phase below still stops the start. A start's lock + // carries the controller it registered before its first wait. + const acquire = options.lock + ? null + : context.runtimeState.acquireAborts.begin(params.envelope.sessionId) + const lock = options.lock ?? heldStructuredAgentSessionAttachLock(acquire?.signal) try { - return await runAttachUnderAbort(context, callerKey, params, options, acquire.signal) + return await runAttachUnderAbort(context, callerKey, params, options, lock) } finally { - acquire.end() - if (isStructuredAgentSessionStartupExpired(acquire.signal.reason)) { + acquire?.end() + if (isStructuredAgentSessionStartupExpired(lock.signal.reason)) { options.onStartupExpired?.() - } else if (acquire.signal.aborted) { + } else if (lock.signal.aborted) { options.onAborted?.() } } @@ -130,7 +139,7 @@ async function runAttachUnderAbort( callerKey: string, params: AgentSessionAttachParams, options: StructuredAgentSessionAttachOptions, - acquireSignal: AbortSignal + lock: StructuredAgentSessionAttachLock ): Promise> { const sessionId = params.envelope.sessionId const recordPhase = options.recordPhase @@ -138,17 +147,18 @@ async function runAttachUnderAbort( const fenceBefore = context.sessions.has(sessionId) ? structuredAgentSessionConversationFence(context.deps.store, sessionId) : null + // Each verdict commits in the store's own transaction, against the owner identity it probed. const unreconciled = await withAgentSessionCreatePhase('reconcile_leases', recordPhase, () => - context.reconcileLeases(sessionId) + lock.wait(context.reconcileLeases(sessionId)) ) if (unreconciled) { return refuseAgentSessionMutation(unreconciled) } await withAgentSessionCreatePhase('resolve_recovery', recordPhase, () => - context.runtimeState.resolveRecovery(sessionId) + lock.wait(context.runtimeState.resolveRecovery(sessionId)) ) const probe = await withAgentSessionCreatePhase('probe_owner', recordPhase, () => - context.runtimeState.probeOwner(sessionId) + lock.wait(context.runtimeState.probeOwner(sessionId)) ) // Read before the reserve clears it: how the previous generation ended decides how whatever it // left running is settled. @@ -157,7 +167,9 @@ async function runAttachUnderAbort( const launchDirectory = !priorRecord && isFloatingWorkspaceId(params.location.workspaceId) ? (options.hostLaunchDirectory ?? - (await context.deps.resolveWorkspacePath?.(params.location.workspaceId))) + (await lock.wait( + Promise.resolve(context.deps.resolveWorkspacePath?.(params.location.workspaceId)) + ))) : undefined if (!priorRecord && isFloatingWorkspaceId(params.location.workspaceId) && !launchDirectory) { throw new Error('floating_agent_session_launch_directory_unavailable') @@ -170,7 +182,9 @@ async function runAttachUnderAbort( candidate: AttachCandidate | null committed: boolean startup: StructuredAgentSessionStartupAttempt | null - } = { candidate: null, committed: false, startup: null } + bound: BoundAttach | null + rewind: StructuredRewindRecoveryEvidence + } = { candidate: null, committed: false, startup: null, bound: null, rewind: null } const { startupAttempts } = context.runtimeState try { const attached = await performAttach({ @@ -181,7 +195,9 @@ async function runAttachUnderAbort( eventSink: attemptSink.sink, // The superseded child's writes settle into its own journal before a new child starts. onAcquiring: async () => { - const barrier = await context.runtimeState.currentEventSink(sessionId)?.drained() + const barrier = await lock.wait( + Promise.resolve(context.runtimeState.currentEventSink(sessionId)?.drained()) + ) if (barrier && !barrier.ok) { throw barrier.error } @@ -192,21 +208,24 @@ async function runAttachUnderAbort( claimKeyId: context.deps.claimKeyId, handoffOperationId: params.envelope.clientOperationId, probe, - ...(await pinnedAgentSessionLaunchArgs(context.deps.resolveLaunchArgs, params)), - ...(await pinnedAgentSessionLaunchEnv(context.deps.resolveLaunchEnv, params)) + ...(await lock.wait(pinnedAgentSessionLaunchArgs(context.deps.resolveLaunchArgs, params))), + ...(await lock.wait(pinnedAgentSessionLaunchEnv(context.deps.resolveLaunchEnv, params))) }, callerKey, params, now: () => context.now(), recordPhase, - acquireSignal, + acquireSignal: lock.signal, + lock, + ...(options.startupAttemptId ? { startupAttemptId: options.startupAttemptId } : {}), onStartupAttempt: (startup) => { attempt.startup = startup return startupAttempts.track(sessionId, startup) }, ...(options.startupOptions ? { startupOptions: options.startupOptions } : {}), launchPermissionMode: async (record) => - (await structuredAgentSessionLaunchPermission(context.deps, record)) ?? undefined, + (await lock.wait(structuredAgentSessionLaunchPermission(context.deps, record))) ?? + undefined, ...(options.onAcquisitionFailed ? { onAcquisitionFailed: options.onAcquisitionFailed } : {}), openConversation: async (record) => { const conversation = await context.openConversation(record.sessionId, { acquisition: true }) @@ -218,13 +237,10 @@ async function runAttachUnderAbort( // The cleanup released the acquisition, which for a re-attach is the live child itself. onAcquisitionReleased: (cause, verdict) => endStructuredAgentSessionReleasedChild(context, sessionId, cause, verdict), + // The commit before the drain: stale rows settle, and the sink binds to the journal. onAttached: async (attached, acquisitionGeneration, owner) => { const fence = structuredAgentSessionConversationFence(context.deps.store, sessionId) const current = context.sessions.get(sessionId)?.child ?? null - const startedFor = owner === 'acquired' ? options.startedFor : current?.startedFor - // A re-attach to a live child keeps the mode that child was launched with. - const permissionMode = - owner === 'acquired' ? attempt.startup?.permissionMode : current?.permissionMode // A re-attach to a live child keeps the sink that child already writes through. const eventSink = owner === 'acquired' @@ -242,58 +258,43 @@ async function runAttachUnderAbort( failureTextContext: structuredAgentSessionFailureWordsContext(priorRecord) }) } - await bindAndDrain(eventSink, attached.journal, fence, (activity) => - context.subscribers.publish(sessionId, attached.journal, activity) - ) - // 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, - // A spawned child starts unready; a re-attach keeps what its child already proved. - phase: owner === 'acquired' ? 'starting' : (current?.phase ?? 'ready'), - ...(startedFor === undefined ? {} : { startedFor }), - ...(permissionMode === undefined ? {} : { permissionMode }) - } - } + eventSink.bind({ + journal: attached.journal, + fence, + publish: (activity) => context.subscribers.publish(sessionId, attached.journal, activity) + }) + attempt.bound = { acquisitionGeneration, owner, fence, current, eventSink } + }, + // Outside the commits: the barrier the host publishes behind, and for a proven child the + // provider's word on a rewind in doubt (a starting one recovers in its open step). + beforePublish: async (owner) => { + const barrier = await attempt.bound?.eventSink.drained() + if (barrier && !barrier.ok) { + throw barrier.error } - // Only a proven child has the protocol session recovery asks; a starting one recovers in - // its open step, before it is handed anything. - if (attempt.candidate?.child.phase === 'ready') { - await recoverStructuredRewind( + if (owner === 'kept' && (attempt.bound?.current?.phase ?? 'ready') === 'ready') { + attempt.rewind = await readStructuredRewindRecovery( context.deps, sessionId, - attached.journal, - fence, - context.deps.adapter, - context.now + attempt.bound?.fence ?? 0, + context.deps.adapter ) } - if (fenceBefore !== null && fence !== fenceBefore) { - context.subscribers.snapshot(sessionId, attached.journal, fence) - } else { - context.subscribers.publish(sessionId, attached.journal) + }, + onPublish: async (attached, recordOutcome) => { + const published = await publishAttachedChild(context, sessionId, attached, { + bound: attempt.bound, + attempt, + attemptSink, + fenceBefore, + startedFor: options.startedFor, + recordOutcome + }) + if (published) { + options.onPublished?.() } } }) - const { candidate } = attempt - const conversation = context.sessions.get(sessionId) - if (attached.ok && candidate && conversation) { - context.runtimeState.adoptEventSink(sessionId, candidate.sink) - attempt.committed = candidate.sink === attemptSink - indexProviderChild(conversation, candidate.child) - if (attempt.startup) { - startupAttempts.published(sessionId, attempt.startup.attemptId, candidate.child) - if (candidate.child.phase === 'ready') { - noteStructuredAgentSessionProviderStarted(context.deps, sessionId) - } else { - context.openProviderChild?.(sessionId, candidate.child) - } - } - context.publishStatus?.(sessionId) - } return stampFailedCreateOwnerVerdict(context.deps.store, callerKey, params.envelope, attached) } finally { if (!attempt.committed) { @@ -304,23 +305,3 @@ async function runAttachUnderAbort( } } } - -type AttachCandidate = { - child: StructuredAgentSessionProviderChild - sink: DeferredStructuredAgentSessionEventSink -} - -/** Binds the sink to the journal and waits for the barrier the host publishes - * behind. It throws by design when a sink barrier fails. */ -async function bindAndDrain( - eventSink: DeferredStructuredAgentSessionEventSink, - journal: AgentSessionJournal, - fence: number, - publish: (activity?: AgentSessionTurnActivity | null) => void -): Promise { - eventSink.bind({ journal, fence, publish }) - const barrier = await eventSink.drained() - if (!barrier.ok) { - throw barrier.error - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-publish.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-publish.ts new file mode 100644 index 000000000000..acf745ed9e10 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-publish.ts @@ -0,0 +1,117 @@ +// The attach's last commit: the child it spawned or kept becomes the session's, its sink the one +// the session's events go through, and its open begins. + +import type { AttachedJournal } from './structured-agent-session-attach' +import type { AttachedOwner } from './structured-agent-session-attach-flow' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' +import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { indexProviderChild } from './structured-agent-session-provider-child' +import { noteStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' +import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' +import { structuredAgentSessionEndedEventExit } from './structured-agent-session-child-exit' +import { + applyStructuredRewindRecovery, + type StructuredRewindRecoveryEvidence +} from './structured-rewind-recovery' + +export type AttachCandidate = { + child: StructuredAgentSessionProviderChild + sink: DeferredStructuredAgentSessionEventSink +} + +export type BoundAttach = { + acquisitionGeneration: string | null + owner: AttachedOwner + fence: number + current: StructuredAgentSessionProviderChild | null + eventSink: DeferredStructuredAgentSessionEventSink +} + +/** The attach's last commit: its child, if it has one, becomes the session's. */ +export async function publishAttachedChild( + context: StructuredAgentSessionAttachContext, + sessionId: string, + attached: AttachedJournal, + input: { + bound: BoundAttach | null + attempt: { + candidate: AttachCandidate | null + committed: boolean + startup: StructuredAgentSessionStartupAttempt | null + rewind: StructuredRewindRecoveryEvidence + } + attemptSink: DeferredStructuredAgentSessionEventSink + fenceBefore: number | null + startedFor: string | undefined + recordOutcome: () => Promise + } +): Promise { + const { attempt, bound } = input + if (!bound) { + await input.recordOutcome() + return false + } + const { acquisitionGeneration, owner, fence, current, eventSink } = bound + // A re-attach to a live child keeps the mode that child was launched with. + const permissionMode = + owner === 'acquired' ? attempt.startup?.permissionMode : current?.permissionMode + const startedFor = owner === 'acquired' ? input.startedFor : current?.startedFor + // A replayed create over a chat at rest has no child to index. + if (owner !== 'none') { + attempt.candidate = { + sink: eventSink, + child: { + generation: acquisitionGeneration ?? current?.generation ?? null, + fence, + // A spawned child starts unready; a re-attach keeps what its child already proved. + phase: owner === 'acquired' ? 'starting' : (current?.phase ?? 'ready'), + ...(startedFor === undefined ? {} : { startedFor }), + ...(permissionMode === undefined ? {} : { permissionMode }) + } + } + } + if (attempt.candidate?.child.phase === 'ready') { + await applyStructuredRewindRecovery( + context.deps, + sessionId, + attached.journal, + fence, + attempt.rewind, + context.now + ) + } + if (input.fenceBefore !== null && fence !== input.fenceBefore) { + context.subscribers.snapshot(sessionId, attached.journal, fence) + } else { + context.subscribers.publish(sessionId, attached.journal) + } + await input.recordOutcome() + const { candidate } = attempt + const conversation = context.sessions.get(sessionId) + if (!candidate || !conversation) { + return false + } + context.runtimeState.adoptEventSink(sessionId, candidate.sink) + attempt.committed = candidate.sink === input.attemptSink + indexProviderChild(conversation, candidate.child) + // An exit that landed while the start ran outside the serialize ends the child it now indexed. + const exited = context.runtimeState.startFlights.takeExit(sessionId, candidate.child) + const indexed = conversation.child + if (exited && indexed) { + await context.endExitedChild(sessionId, indexed, structuredAgentSessionEndedEventExit(exited)) + } else if (attempt.startup) { + context.runtimeState.startupAttempts.published( + sessionId, + attempt.startup.attemptId, + candidate.child + ) + if (candidate.child.phase === 'ready') { + noteStructuredAgentSessionProviderStarted(context.deps, sessionId) + } else { + context.openProviderChild?.(sessionId, candidate.child) + } + } + context.publishStatus?.(sessionId) + return true +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts index c799ba1a161b..93007994de77 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts @@ -14,6 +14,7 @@ import type { StructuredAgentSessionProviderChild } from './structured-agent-ses import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' import { releaseStoredStructuredAgentSessionOwnerAfterExit } from './structured-agent-session-lease-release' import { isSurfaceReleasableAgentSessionRecord } from '../../runtime/agent-session-surface-release-transition' +import type { StructuredAgentSessionAttachLock } from './structured-agent-session-attach-lock' /** What a caller learned about the child's exit: proven, or not. A root still there after the * close's kill reads `unverifiable`, never `exited`. */ @@ -30,6 +31,16 @@ export async function joinStructuredAgentSessionChildClose( if (!(await closeProviderRoot(context, sessionId))) { return 'unverifiable' } + await endClosedStructuredAgentSessionChild(context, sessionId, child, unanswered) + return 'exited' +} + +async function endClosedStructuredAgentSessionChild( + context: StructuredAgentSessionLifetimeContext, + sessionId: string, + child: StructuredAgentSessionProviderChild, + unanswered: boolean +): Promise { await context.endExitedChild(sessionId, child, { expected: true, reason: 'closed by Orca', @@ -37,7 +48,6 @@ export async function joinStructuredAgentSessionChildClose( ...(unanswered ? { startupUnanswered: true } : {}) }) context.restartWitness?.stopped(sessionId) - return 'exited' } /** The refusal of an operation that met a child whose close is still unproven. */ @@ -51,16 +61,28 @@ export function previousExitUnverifiableRefusal(): AgentSessionWireRefusal { /** For an operation that reaches the provider: a child a stop began closing takes no input and * none may start beside it, so the operation joins that close and is refused while it is still - * unproven. */ + * unproven. A start passes its own lock: the close is awaited outside the session's serialize and + * the record ends in a commit, only if the child is still the one closed. */ export async function joinClosingStructuredAgentSessionChild( context: StructuredAgentSessionLifetimeContext, - sessionId: string + sessionId: string, + lock?: StructuredAgentSessionAttachLock ): Promise<{ ok: true } | { ok: false; refusal: AgentSessionWireRefusal }> { const child = context.sessions.get(sessionId)?.child if (!child?.close) { return { ok: true } } - if ((await joinStructuredAgentSessionChildClose(context, sessionId, child)) === 'exited') { + const unanswered = child.phase === 'starting' + const closing = closeProviderRoot(context, sessionId) + if (await (lock ? lock.wait(closing) : closing)) { + const end = () => endClosedStructuredAgentSessionChild(context, sessionId, child, unanswered) + await (lock + ? lock.commit(async () => { + if (context.sessions.get(sessionId)?.child === child) { + await end() + } + }) + : end()) return { ok: true } } // A stop reports this through its own failure; here the refusal is the only trace. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index ae3590126721..bf25282e1ead 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 @@ -34,6 +34,7 @@ import { import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import { evictStructuredAgentSession } from './structured-agent-session-eviction' import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import type { StructuredAgentSessionStartFlights } from './structured-agent-session-start-flight' import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' /** How the child's root went: the close this host asked for, or a death of its own. */ @@ -73,6 +74,8 @@ export type StructuredAgentSessionChildExitContext< wakeDelivery?: (sessionId: string) => void /** An ended child's start, if still open, has nothing left to time. */ startupAttempts?: Pick + /** The start in flight outside the serialize, whose child is not on record until it publishes. */ + startFlights?: Pick abortOpen?: (sessionId: string) => void serialize: (sessionId: string, task: () => Promise) => Promise now: () => number @@ -100,19 +103,35 @@ export function settleStructuredAgentSessionChildExit< ): Promise { return context.serialize(event.sessionId, async () => { const child = context.sessions.get(event.sessionId)?.child + // An exit can land between a start's spawn and its publish: the publish settles it. + if (!child && context.startFlights?.deferExit(event)) { + return + } if (!child || child.fence !== event.fence || child.generation !== event.acquisitionGeneration) { return } - await endExitedStructuredAgentSessionChildUnderSerialize(context, event.sessionId, child, { - expected: event.cause === 'requested-close', - reason: event.reason, - ...(event.failure ? { failure: event.failure } : {}), - ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), - ...(event.startupUnanswered ? { startupUnanswered: event.startupUnanswered } : {}) - }) + await endExitedStructuredAgentSessionChildUnderSerialize( + context, + event.sessionId, + child, + structuredAgentSessionEndedEventExit(event) + ) }) } +/** The exit an adapter's report of it proves. */ +export function structuredAgentSessionEndedEventExit( + event: StructuredAgentSessionEndedEvent +): StructuredAgentSessionChildExit { + return { + expected: event.cause === 'requested-close', + reason: event.reason, + ...(event.failure ? { failure: event.failure } : {}), + ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), + ...(event.startupUnanswered ? { startupUnanswered: event.startupUnanswered } : {}) + } +} + /** Ends the record of a child whose exit this host saw or proved; a no-op once it has left. */ export async function endExitedStructuredAgentSessionChildUnderSerialize< TSession extends StructuredAgentSessionChildExitSession 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 eb599d1167b7..88e3321ea41f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -5,15 +5,17 @@ // for a session exactly while a message is queued there — accepted, not yet handed over — and no // child is running a conversation command: a command's turn takes no input, and the commit that // ends it wakes the loop again. Every step re-reads the journal and the conversation's child -// record to decide, so there is no loop state to disagree with them. Each step is its own serialized task. That is what lets a Stop -// that arrives while a start holds the queue withdraw the queued messages before the handover that -// would have written them. Stop and the conversation's close are the only other writers of a +// record to decide, so there is no loop state to disagree with them. Each step is its own +// serialized task, and the waits between them — the agent's start, its readiness, the provider +// call — run outside the serialize, so a send is accepted while they last, and a Stop that arrives +// meanwhile withdraws the queued messages before the handover that would have written them. Stop +// and the conversation's close are the only other writers of a // queued message: a child's exit only ends the child, and this loop reads why. A message an // earlier host process left queued is never handed over: the open, or this loop's first step, // settles it first (`journal-unsent-send-hold.ts`). import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -30,12 +32,16 @@ import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' import { + acceptedNoLaterThan, oldestQueuedSubmission, recordStructuredAgentSessionStartFailure, refusedStructuredAgentSessionStart, startThatFailedWhileQueued } from './structured-agent-session-start-failure-row' -import { handOverSubmission } from './structured-agent-session-turns' +import { + beginSubmissionHandover, + type StructuredAgentSessionPendingHandover +} from './structured-agent-session-handover' import { structuredAgentSessionNextHandover } from './structured-agent-session-opening-send' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -44,6 +50,7 @@ import { holdRestartedStructuredAgentSessionSends } from './structured-agent-ses import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' import { waitsForCodexTurnOptions } from './structured-agent-session-message-options' +import { sameProviderChild } from './structured-agent-session-provider-child' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' export type StructuredAgentSessionDeliveryLoopDeps = { @@ -54,23 +61,24 @@ export type StructuredAgentSessionDeliveryLoopDeps = { serialize: (sessionId: string, task: () => Promise) => Promise /** A start step, tracked from enqueue so quit waits for the child it may produce. */ trackStart: (start: Promise) => Promise - /** Gives the session a provider child if it has none; for a caller inside `serialize`. */ - /** Starts a child for `startedFor`, the queued message at the head, if the session has none. */ - ensureProviderChild: ( + /** Starts a child for `startedFor`, the queued message at the head, if the session has none; + * outside `serialize`. */ + startProviderChild: ( sessionId: string, startedFor: string ) => Promise + /** Stops a child the chat outgrew (a new login or permission mode); inside `serialize`. */ + retireOutgrownChild: (sessionId: string) => Promise readiness: ( sessionId: string, child: StructuredAgentSessionProviderChildIdentity ) => StructuredAgentSessionStartReadiness | null /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ conversationFence: (sessionId: string) => number - /** Settles queued messages as a completed close of the chat does; false when that failed. */ - holdClosed: ( - sessionId: string, - which: (submission: AgentJournalSubmission) => boolean - ) => Promise + /** A close of this chat that stopped its child and then did not complete still closed what was + * queued before it, so no child starts for those: settled as a completed close does. Ordered, + * not latched: a later send goes on. False when those could not be closed. */ + closeWhatTheUserClosed: (sessionId: string) => Promise /** Who the chat's failure sentences name. */ failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext logger: StructuredAgentSessionLogger @@ -84,14 +92,12 @@ export type StructuredAgentSessionDeliveryLoopDeps = { acquireAborts: Pick } -type RefusedStart = Extract - type Prepared = | 'stop' - | RefusedStart + | { start: string; acceptedBy: AgentJournalCursor } | { ok: true - awaited: StructuredAgentSessionProviderChildIdentity | null + awaited: StructuredAgentSessionProviderChildIdentity readiness: StructuredAgentSessionStartReadiness | null before: Promise } @@ -149,16 +155,27 @@ export class StructuredAgentSessionDeliveryLoop { if (prepared === 'stop') { return } - if (!prepared.ok) { - if (prepared.aborted) { - // A close, Stop or quit stopped this start on purpose, and settled what it was for; a - // message accepted since gets its own start, and quit's next step stops the loop. + if ('start' in prepared) { + // Outside the serialize: sends are accepted while the agent starts. + const started = await this.deps.startProviderChild(sessionId, prepared.start) + if (started.ok || started.aborted) { + // Aborted: a close, Stop or quit stopped this start on purpose, and settled what it + // was for; a message accepted since gets its own start, and quit's next step stops the + // loop. Either way the next step re-derives from what the session holds now. continue } + // The start's refusal, as the failure every queued message it was for is rejected with. + const failure = refusedStructuredAgentSessionStart(this.deps.record(sessionId), started) + if (started.refusal.details?.reason !== 'previousExitUnverifiable') { + await this.deps.serialize(sessionId, () => this.fail(sessionId, failure)) + return + } + // An unproven close fails only what was queued when the start was asked for: a later + // ask runs the close again, so its verdict no longer holds for what came after. await this.deps.serialize(sessionId, () => - this.fail(sessionId, this.refusedStart(sessionId, prepared)) + this.recordFailure(sessionId, failure, prepared.acceptedBy) ) - return + continue } if (prepared.readiness) { await prepared.readiness.wait() @@ -170,6 +187,13 @@ export class StructuredAgentSessionDeliveryLoop { if (handed === 'stop') { return } + if (handed !== 'continue') { + // The provider call runs outside; its resolution is the next commit. Tracked, so quit's + // drain waits for both after its abort ends the call. + await this.deps.trackStart( + handed.dispatch().then((resolve) => this.deps.serialize(sessionId, resolve)) + ) + } } } catch (error) { // The error is Orca's own and goes to the log; the chat says only that Orca failed. @@ -208,7 +232,7 @@ export class StructuredAgentSessionDeliveryLoop { session.journal, this.deps.conversationFence(sessionId) ) - if (!(await this.closeWhatTheUserClosed(sessionId, session))) { + if (!(await this.deps.closeWhatTheUserClosed(sessionId))) { // Never start an agent for a message the user closed; the next wake re-derives and retries. return this.stop(sessionId) } @@ -223,30 +247,25 @@ export class StructuredAgentSessionDeliveryLoop { if (failedStart) { return this.fail(sessionId, failedStart) } - const ready = await this.deps.ensureProviderChild(sessionId, oldest.clientMessageId) - if (!ready.ok && ready.refusal.details?.reason === 'previousExitUnverifiable') { - // Failed in the step that was refused: a message accepted, or an exit proven, after it must - // not be failed for a verdict that no longer holds. - return this.fail(sessionId, this.refusedStart(sessionId, ready)) - } - if (!ready.ok) { - return ready - } + await this.deps.retireOutgrownChild(sessionId) const child = this.deps.sessions.get(sessionId)?.child + if (!child || child.close) { + return { start: oldest.clientMessageId, acceptedBy: session.journal.cursor() } + } // The child this run waits on; handover checks it is still the one there. return { ok: true, - awaited: child ? { generation: child.generation, fence: child.fence } : null, - readiness: child?.phase === 'starting' ? this.deps.readiness(sessionId, child) : null, + awaited: { generation: child.generation, fence: child.fence }, + readiness: child.phase === 'starting' ? this.deps.readiness(sessionId, child) : null, before: this.deps.deliveryOrder.beforeSend(sessionId, oldest.clientMessageId) } } private async handOver( sessionId: string, - awaited: StructuredAgentSessionProviderChildIdentity | null, + awaited: StructuredAgentSessionProviderChildIdentity, waited: boolean - ): Promise<'continue' | 'stop'> { + ): Promise<'continue' | 'stop' | StructuredAgentSessionPendingHandover> { const session = this.deps.sessions.get(sessionId) if (!session || this.disposed) { return this.stop(sessionId) @@ -254,13 +273,10 @@ export class StructuredAgentSessionDeliveryLoop { // Re-derived here, not carried from the start: the child may have ended, or another may have // taken its place, since. const { child } = session - const awaitedChild = - child && awaited && child.generation === awaited.generation && child.fence === awaited.fence - ? child - : null + const awaitedChild = child && sameProviderChild(child, awaited) ? child : null if (!awaitedChild) { // The child started for this message is gone or replaced by another. - const ended = awaitedChild ? undefined : session.lastEndedChild + const ended = session.lastEndedChild const endedFailure = ended ? structuredAgentSessionEndedChildFailure(ended) : undefined // A user's Stop or close is not a failure: the next step starts, or waits on, a child for // what is queued, after closing what a close of the chat closed. @@ -268,7 +284,7 @@ export class StructuredAgentSessionDeliveryLoop { return 'continue' } return this.fail(sessionId, { - startKey: awaited?.generation ?? null, + startKey: awaited.generation, cause: endedFailure ?? // Gone with no end observed: nothing says the provider stopped. { failure: agentSessionFailureFact('startFailed') } @@ -287,10 +303,7 @@ export class StructuredAgentSessionDeliveryLoop { } if (waited) { // Re-check launch permission only after proving the awaited child still exists. - const ready = await this.deps.ensureProviderChild(sessionId, next.clientMessageId) - if (!ready.ok) { - return this.fail(sessionId, this.refusedStart(sessionId, ready)) - } + await this.deps.retireOutgrownChild(sessionId) if (session.child !== awaitedChild) { return 'continue' } @@ -301,7 +314,7 @@ export class StructuredAgentSessionDeliveryLoop { ) { return this.stop(sessionId) } - await handOverSubmission( + const pending = await beginSubmissionHandover( { sessionId, journal: session.journal, @@ -314,17 +327,23 @@ export class StructuredAgentSessionDeliveryLoop { now: this.deps.now, acquireAborts: this.deps.acquireAborts }, - next + next, + // Re-read right before the provider call, which runs outside the serialize. + () => this.deps.sessions.get(sessionId)?.child === awaitedChild && !awaitedChild.close ) - return 'continue' + return pending ?? 'continue' } - /** A start the session refused, as the failure every queued message it was for is rejected with. */ - private refusedStart(sessionId: string, refused: RefusedStart): StartFailure { - return refusedStructuredAgentSessionStart(this.deps.record(sessionId), refused) + private async fail(sessionId: string, failure: StartFailure): Promise<'stop'> { + await this.recordFailure(sessionId, failure) + return this.stop(sessionId) } - private async fail(sessionId: string, failure: StartFailure): Promise<'stop'> { + private async recordFailure( + sessionId: string, + failure: StartFailure, + acceptedBy?: AgentJournalCursor + ): Promise { const session = this.deps.sessions.get(sessionId) if (session) { await recordStructuredAgentSessionStartFailure( @@ -335,31 +354,10 @@ export class StructuredAgentSessionDeliveryLoop { failure.cause, this.deps.failureTextContext(sessionId) ) - } + }, + acceptedBy && acceptedNoLaterThan(session.journal, acceptedBy) ) } - return this.stop(sessionId) - } - - /** A close of this chat that stopped its child and then did not complete still closed what was - * queued before it, so no child starts for those. Ordered, not latched: a later send goes on. - * False when those could not be closed. */ - private async closeWhatTheUserClosed( - sessionId: string, - session: StructuredAgentSessionHostSession - ): Promise { - const ended = session.lastEndedChild - if (session.child || ended?.cause !== 'user-close') { - return true - } - const { epoch } = session.journal.cursor() - return this.deps.holdClosed( - sessionId, - (submission) => - ended.endedAt.epoch === epoch && - submission.acceptedSequence !== undefined && - submission.acceptedSequence <= ended.endedAt.sequence - ) } /** Inside the serialized step that found nothing to do, so an accept after it wakes anew. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 085005581c79..36ff991a425a 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 @@ -64,6 +64,7 @@ export class StructuredAgentSessionEventRecovery { ...this.context, logger: deps.logger, startupAttempts: runtimeState.startupAttempts, + startFlights: runtimeState.startFlights, holdUnrunSends: async (sessionId, fence, cause) => { const journal = this.context.sessions.get(sessionId)?.journal if (journal) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts new file mode 100644 index 000000000000..156f8910947b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts @@ -0,0 +1,203 @@ +// Handing one accepted message to the provider: two commits around a provider call that runs +// outside the session's serialize, so a send is accepted while the call is in flight. + +import { + agentSessionFailureFact, + providerDiagnosticOf +} from '../../../shared/agent-session-failure' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' +import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' +import { abortSignalReason } from '../../../shared/abort-signal-reason' +import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import type { + AgentJournalMessageItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' +import type { AgentSessionDispatchOutcome } from './structured-agent-session-adapter' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + handOverStructuredAgentSessionCommand, + structuredAgentSessionHandoverOrigin, + type StructuredAgentSessionCommandHandoverContext +} from './structured-agent-session-command-turn' +import { carriedStoppedHistory } from './structured-agent-session-stopped-history-selection' +import { withStoppedHistory } from '../../../shared/agent-session-stopped-history' + +export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverContext & { + acquireAborts?: Pick +} + +/** A handover whose pending row and abort handle are committed: the provider call runs outside the + * session's serialize and answers with the commit that resolves it. */ +export type StructuredAgentSessionPendingHandover = { + dispatch: () => Promise<() => Promise> +} + +/** + * Hands one queued submission to the provider. The `dispatch{pending}` row goes first: a crash + * after it leaves a message in doubt, never one that reads as queued and so provably unwritten. + * The abort handle a Stop reaches the call by is begun before that row, in the same step, so no + * Stop falls between them. Null when the handover finished in this step (a command, a lost body). + */ +export async function beginSubmissionHandover( + ctx: AgentSessionHandoverContext, + submission: AgentJournalSubmission, + /** Whether the child the row was written for is still the one to call. */ + stillCurrent: () => boolean +): Promise { + const { clientMessageId } = submission + const body = ctx.journal.itemBody(agentJournalSubmissionKey(clientMessageId)) + if (body?.kind !== 'message') { + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('hostFault'), { surface: 'rejection' }), + fence: ctx.fence + }) + return null + } + if (body.command) { + await handOverStructuredAgentSessionCommand(ctx, submission, body) + return null + } + // The message joins the turn the child it goes to is running, a steer, or opens its own; never a + // turn an ended generation left. A new turn carries the stopped messages owed to the agent, named + // on this same row before anything is written. + const turnScope = contextStructuredAgentSessionCurrentWork(ctx).turnScope() + const carried = carriedStoppedHistory(ctx, turnScope, submission, body) + const attempt = ctx.acquireAborts?.begin(ctx.sessionId, true) + try { + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'pending', + fence: ctx.fence, + turnScope, + ...(carried ? { carries: carried.covers } : {}) + }) + } catch (error) { + attempt?.end() + throw error + } + // The handover row's instant on the host clock; the turn this dispatch opens records it so the + // live counter never re-anchors at turn-open. + const requestedAt = structuredAgentSessionHandoverOrigin(ctx.journal, submission) + return { + dispatch: async () => { + let outcome: AgentSessionDispatchOutcome | null + try { + outcome = attempt?.signal.aborted + ? cancelledDispatch() + : // A child gone since the row: its end settles the row, and nothing reaches the wire. + stillCurrent() + ? await dispatchSafely( + ctx, + clientMessageId, + carried ? withStoppedHistory(body, carried.messages) : body, + requestedAt, + submission.options, + attempt?.signal + ) + : null + } finally { + attempt?.end() + } + return () => resolveHandover(ctx, clientMessageId, outcome) + } + } +} + +async function resolveHandover( + ctx: AgentSessionHandoverContext, + clientMessageId: string, + outcome: AgentSessionDispatchOutcome | null +): Promise { + // An admission needs no dispatch row: the submission is already pending. + if (!outcome || outcome.state === 'admitted') { + return + } + try { + await ctx.journal.resolveDispatch( + outcome.state === 'accepted' + ? { + clientMessageId, + state: 'accepted', + providerIdentity: outcome.providerIdentity, + fence: ctx.fence + } + : outcome.state === 'rejected' + ? { + clientMessageId, + state: 'rejected', + reason: outcome.reason, + rejection: outcome.rejection, + ...(outcome.answeredInTurn ? { answeredInTurn: outcome.answeredInTurn } : {}), + fence: ctx.fence + } + : { clientMessageId, state: 'unknown', reason: outcome.reason, fence: ctx.fence } + ) + } catch (error) { + // A failed resolution must not strand a pending row; an unknown result is + // explicitly replayable. + try { + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'unknown', + reason: DISPATCH_DOUBT_PERSISTENCE_FAILED, + fence: ctx.fence + }) + } catch { + // Nothing further to record; the pending row is settled on the next open. + } + throw error + } +} + +/** A thrown adapter error is indistinguishable from a lost reply, so it settles as `unknown`, never + * replayed. Only a child that proved its start is handed anything, so a start that fails leaves its + * messages unsent instead (the delivery loop's barrier); an adapter that knows a write never + * happened answers `rejected`. */ +async function dispatchSafely( + ctx: AgentSessionHandoverContext, + clientMessageId: string, + body: AgentJournalMessageItem, + requestedAt: number, + executionOptions: AgentSessionExecutionOptions | undefined, + signal?: AbortSignal +): Promise { + try { + const outcome = await ctx.adapter.dispatch({ + sessionId: ctx.sessionId, + clientMessageId, + body, + ...(executionOptions ? { executionOptions } : {}), + ...(signal ? { signal } : {}), + fence: ctx.fence, + requestedAt + }) + return signal?.aborted && outcome.state === 'rejected' ? cancelledDispatch() : outcome + } catch (error) { + if (signal?.aborted && error === abortSignalReason(signal)) { + return cancelledDispatch() + } + if (isAgentSessionOptionRejectedError(error)) { + return { + state: 'rejected', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { detail: providerDiagnosticOf(error) }), + { surface: 'rejection' } + ) + } + } + return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } + } +} + +function cancelledDispatch(): AgentSessionDispatchOutcome { + return { + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 3b9e27f29700..bf45ee939429 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 @@ -28,6 +28,7 @@ import { structuredAgentSessionFailureWordsContext } from './structured-agent-se import { recoverStructuredRewind } from './structured-rewind-recovery' import { retireOutgrownStructuredAgentSessionChild } from './structured-agent-session-outgrown-child' import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' +import { acceptedNoLaterThan } from './structured-agent-session-start-failure-row' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' export type StructuredAgentSessionConversationDelivery = { @@ -59,8 +60,9 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessions: Map serialize: (sessionId: string, task: () => Promise) => Promise trackStart: (start: Promise) => Promise - /** Starts a child for `startedFor`, the queued message at the head, if the session has none. */ - ensureProviderChild: ( + /** Starts a child for `startedFor`, the queued message at the head, if the session has none; + * for a caller outside the session's serialize. */ + startProviderChild: ( sessionId: string, startedFor: string ) => Promise @@ -83,7 +85,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { agents: deps.agents, serialize: input.serialize, trackStart: input.trackStart, - ensureProviderChild: async (sessionId, startedFor) => { + startProviderChild: input.startProviderChild, + retireOutgrownChild: async (sessionId) => { // A send waiting on a person's Stop is not handed over yet; the step after the Stop decides. const session = input.clientDelivery.readStopping(sessionId) ? undefined @@ -106,20 +109,20 @@ export function createStructuredAgentSessionConversationDelivery(input: { stopAgent: input.stopOutgrownAgent, logger: deps.logger }) - return input.ensureProviderChild(sessionId, startedFor) }, readiness: input.readiness, deliveryOrder: input.deliveryOrder, conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), - holdClosed: async (sessionId, which) => { + closeWhatTheUserClosed: async (sessionId) => { const session = sessions.get(sessionId) - return session - ? holdClosedStructuredAgentSessionSends(deps, sessionId, session.journal, { + const ended = session?.lastEndedChild + return !session || session.child || ended?.cause !== 'user-close' + ? true + : holdClosedStructuredAgentSessionSends(deps, sessionId, session.journal, { mark: 'settled', - which + which: acceptedNoLaterThan(session.journal, ended.endedAt) }) - : true }, failureTextContext: (sessionId) => structuredAgentSessionFailureWordsContext( 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 04a1ade9f101..4b776f1dcf35 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -8,6 +8,7 @@ import { } from './structured-agent-session-event-sink' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' import { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' +import { StructuredAgentSessionStartFlights } from './structured-agent-session-start-flight' import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' import { heldProviderChildReader, @@ -17,14 +18,15 @@ import { resolveStructuredSessionRecovery } from './structured-agent-session-rec import { recordAgentSessionStartup } from '../../observability/agent-session-instrumentation' import { StructuredAgentSessionStartupAttempts, - StructuredAgentSessionStartupExpiredError, type StructuredAgentSessionExpiredStartup } from './structured-agent-session-startup-attempt' +import { StructuredAgentSessionStartupExpiredError } from './structured-agent-session-startup-attempt-contract' export class StructuredAgentSessionHostRuntimeState { private readonly eventSinks = new Map() readonly deliveryOrder = new StructuredAgentSessionDeliveryOrder() readonly acquireAborts = new StructuredAgentSessionAcquireAborts() + readonly startFlights = new StructuredAgentSessionStartFlights() readonly startupAttempts: StructuredAgentSessionStartupAttempts private readonly leaseRenewer: StructuredAgentSessionLeaseRenewer private readonly onEventSinkFailure?: (sessionId: string, error: unknown) => void diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 974ed46379be..aa31a0d6a0d7 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 @@ -119,8 +119,8 @@ export class StructuredAgentSessionHost { serialize: (sessionId, task) => this.serialize(sessionId, task), // Quit drains a delivery start before it evicts, so the child it produces is stopped. trackStart: (start) => this.tasks.trackAttach(start), - ensureProviderChild: (sessionId, startedFor) => - agentStart.ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), + startProviderChild: (sessionId, startedFor) => + agentStart.startStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), deliveryOrder: this.runtimeState.deliveryOrder, readiness: (id, child) => this.runtimeState.startupAttempts.readiness(id, child), // Put to rest like an idle agent: the stop writes nothing, and the next start is new. @@ -163,7 +163,10 @@ export class StructuredAgentSessionHost { sessions: this.sessions, serialize: (sessionId, task) => this.serialize(sessionId, task), open: (sessionId) => this.conversationDelivery.open(sessionId), - deliveryActive: (sessionId) => this.conversationDelivery.loop.isRunning(sessionId), + // A start in flight is execution under way, even before the loop or an operation sees a child. + deliveryActive: (sessionId) => + this.conversationDelivery.loop.isRunning(sessionId) || + this.runtimeState.startFlights.get(sessionId) !== undefined, closeStatus: (sessionId, options) => this.clientDelivery.closeSession(sessionId, options), readChildWork: this.clientDelivery.readChildWork }) @@ -279,8 +282,8 @@ export class StructuredAgentSessionHost { childWorkIncomplete: this.clientDelivery.childWorkIncomplete, serialize: (sessionId, task) => this.serialize(sessionId, task), openConversation: this.conversationDelivery.open, - ensureAgent: (sessionId) => - agentStart.ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), + startAgent: (sessionId) => + agentStart.startStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), startupAttempts: this.runtimeState.startupAttempts, deliveryOrder: this.runtimeState.deliveryOrder, joinChildClose: (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 6422b4b8437e..be1b6cc0ca49 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts @@ -214,7 +214,7 @@ it.each([false, true])( } })) } - const ensureAgent = vi.spyOn(ctx, 'ensureAgent') + const startAgent = vi.spyOn(ctx, 'startAgent') const error = new Error('receipt read failed') const warn = vi.spyOn(ctx.deps.logger, 'warn') vi.spyOn(rig.store, 'readCommandReceipt').mockImplementationOnce(() => { @@ -234,7 +234,7 @@ it.each([false, true])( ) expect(rig.host['sessions'].get(SESSION)?.journal.submissions()).toHaveLength(0) expect(ctx.wakeDelivery).not.toHaveBeenCalled() - expect(ensureAgent).not.toHaveBeenCalled() + expect(startAgent).not.toHaveBeenCalled() } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index d763018b8743..b6f0b5e1902f 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 @@ -38,8 +38,9 @@ export type StructuredAgentSessionMutationContext = { serialize: (sessionId: string, task: () => Promise) => Promise /** The session's conversation, opened when closed; inside the caller's serialize. */ openConversation: (sessionId: string) => Promise - /** Gives the session a provider child; inside the caller's serialize. */ - ensureAgent: (sessionId: string) => Promise + /** Gives the session a provider child; OUTSIDE the caller's serialize, which the start's own + * commits take. */ + startAgent: (sessionId: string) => Promise startupAttempts: Pick deliveryOrder: StructuredAgentSessionDeliveryOrder /** Joins a close a stop began on the session's child, for an operation that starts no child; diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-child.ts new file mode 100644 index 000000000000..d37fe7b4f854 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-child.ts @@ -0,0 +1,91 @@ +// The child an operation that needs the provider runs on, and the refusal of one that did not run. + +import { refuse, refuseUnclassified } from '../../../shared/agent-session-wire-refusals' +import type { + AgentSessionMutationEnvelope, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' +import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' +import { sameProviderChild } from './structured-agent-session-provider-child' +import { openForProviderWrite } from './structured-agent-session-send-preparation' +import { agentSessionOperationOutcomeUnknown } from './structured-agent-session-replay-outcome' +import type { AgentSessionMutationSessionPreparation } from './structured-agent-session-mutation-admission' +import type { StructuredAgentSessionStartReadiness } from './structured-agent-session-startup-attempt' + +export type ReadyOperationChild = StructuredAgentSessionProviderChildIdentity | null +export type ReadyOperationSelection = { + ok: true + child: ReadyOperationChild + replay?: true + deadline?: number +} + +export function operationDidNotRun(expired = false): { + ok: false + refusal: AgentSessionWireRefusal +} { + return { + ok: false, + refusal: expired + ? refuse( + 'agent_session_operation_expired', + { reason: 'operationExpired' }, + 'The operation did not run because its wait expired. Try again.' + ) + : refuseUnclassified( + 'agent_session_owner_restart_failed', + 'The operation did not run because the agent is not ready.' + ) + } +} + +/** The child an operation runs on, and its readiness while it starts; a null child means none is + * there to run on, and one must start first. */ +export function selectStructuredAgentSessionOperationChild( + context: StructuredAgentSessionMutationContext, + sessionId: string +): + | { ok: false; refusal: AgentSessionWireRefusal } + | { + ok: true + child: StructuredAgentSessionProviderChildIdentity | null + readiness: StructuredAgentSessionStartReadiness | null + } { + const child = context.sessions.get(sessionId)?.child + if (!child || child.close) { + return { ok: true, child: null, readiness: null } + } + const identity = { generation: child.generation, fence: child.fence } + if (child.phase === 'ready') { + return { ok: true, child: identity, readiness: null } + } + const readiness = context.startupAttempts.readiness(sessionId, identity) + return readiness ? { ok: true, child: identity, readiness } : operationDidNotRun() +} + +/** Replay uses the ledger answer; a new operation uses only its selected ready child. */ +export function openWithReadyStructuredAgentSessionChild( + context: StructuredAgentSessionMutationContext, + envelope: AgentSessionMutationEnvelope, + selection: ReadyOperationSelection +): (ledger: 'admit' | 'replay') => Promise { + return async (ledger) => { + const opened = await openForProviderWrite(context, envelope)() + if (!opened.ok || ledger === 'replay') { + return opened + } + if (!selection.child) { + return selection.replay + ? { ok: false, refusal: agentSessionOperationOutcomeUnknown(envelope.clientOperationId) } + : operationDidNotRun() + } + if (selection.deadline !== undefined && Date.now() >= selection.deadline) { + return operationDidNotRun(true) + } + const child = context.sessions.get(envelope.sessionId)?.child + return child?.phase === 'ready' && !child.close && sameProviderChild(child, selection.child) + ? opened + : operationDidNotRun() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts index 6a2e1c55f088..332106747791 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-start-refusal.test.ts @@ -4,7 +4,9 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' -import { ensureStructuredAgentSessionAgentForOperation } from './structured-agent-session-agent-start' +import { startStructuredAgentSessionAgentForOperation } from './structured-agent-session-agent-start' +import { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' +import { StructuredAgentSessionStartFlights } from './structured-agent-session-start-flight' import { recordStructuredAgentSessionOptionIntent } from './structured-agent-session-options-read' import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' @@ -18,11 +20,18 @@ describe('an operation whose agent start throws', () => { const context = { deps: { logger: log.logger }, sessions: new Map(), + serialize: (_sessionId: string, task: () => Promise) => task(), + tasks: { trackAttach: (start: Promise) => start }, + runtimeState: { + acquireAborts: new StructuredAgentSessionAcquireAborts(), + startFlights: new StructuredAgentSessionStartFlights(), + startupAttempts: { begin: () => {}, abandon: () => {} } + }, reconcileLeases: () => Promise.reject(cause) } - const refused = await ensureStructuredAgentSessionAgentForOperation( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial context double; the start throws at `reconcileLeases`, before any other member is read. + const refused = await startStructuredAgentSessionAgentForOperation( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial context double; the start throws at `reconcileLeases`, before any member past its registration is read. context as unknown as StructuredAgentSessionAttachContext, SESSION ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts index 09f1b8d382e3..6d4a15a7e303 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts @@ -2,22 +2,15 @@ import { agentSessionFingerprintConflict, computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { refuse, refuseUnclassified } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionMutationEnvelope, - AgentSessionMutationResult, - AgentSessionWireRefusal + AgentSessionMutationResult } from '../../../shared/agent-session-wire' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types' -import { sameProviderChild } from './structured-agent-session-provider-child' import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from './structured-agent-session-send-settlement' -import { - openForWrite, - openForProviderWrite, - rewindInDoubt -} from './structured-agent-session-send-preparation' +import { openForWrite, rewindInDoubt } from './structured-agent-session-send-preparation' import { agentSessionOperationOutcomeUnknown } from './structured-agent-session-replay-outcome' import { replayRecordedOperation, @@ -31,6 +24,18 @@ import { waitForStructuredAgentSessionStart } from './structured-agent-session-s import type { StructuredAgentSessionDeliveryPlace } from './structured-agent-session-delivery-order' import type { StructuredAgentSessionStartReadiness } from './structured-agent-session-startup-attempt' +import { + operationDidNotRun, + selectStructuredAgentSessionOperationChild, + type ReadyOperationSelection +} from './structured-agent-session-operation-child' +export { + openWithReadyStructuredAgentSessionChild, + operationDidNotRun, + type ReadyOperationChild, + type ReadyOperationSelection +} from './structured-agent-session-operation-child' + type ReadyOperationPlan = Pick< MutationPlan, 'method' | 'fields' | 'operationIdScope' | 'commandReceipt' @@ -41,19 +46,12 @@ type ReadyOperationPlan = Pick< type QueuedReadyOperation = | { result: AgentSessionMutationResult } | { - child: StructuredAgentSessionProviderChildIdentity - readiness: StructuredAgentSessionStartReadiness + /** Null: the session has no child to run on yet, so the operation starts one outside. */ + child: StructuredAgentSessionProviderChildIdentity | null + readiness: StructuredAgentSessionStartReadiness | null place: StructuredAgentSessionDeliveryPlace } -export type ReadyOperationChild = StructuredAgentSessionProviderChildIdentity | null -export type ReadyOperationSelection = { - ok: true - child: ReadyOperationChild - replay?: true - deadline?: number -} - /** A send or conversation command waits only when an earlier rewind needs its provider. */ export async function runAfterRewindStart( context: StructuredAgentSessionMutationContext, @@ -67,26 +65,8 @@ export async function runAfterRewindStart( }) } -export function operationDidNotRun(expired = false): { - ok: false - refusal: AgentSessionWireRefusal -} { - return { - ok: false, - refusal: expired - ? refuse( - 'agent_session_operation_expired', - { reason: 'operationExpired' }, - 'The operation did not run because its wait expired. Try again.' - ) - : refuseUnclassified( - 'agent_session_owner_restart_failed', - 'The operation did not run because the agent is not ready.' - ) - } -} - -/** A ready child runs in this queue turn; only a starting child releases it to wait. */ +/** A ready child runs in this queue turn; a starting child, or none, releases it to wait, and a + * start the operation needs runs outside the queue, as the delivery loop's does. */ export async function runWithReadyStructuredAgentSessionOperation( context: StructuredAgentSessionMutationContext, callerKey: string, @@ -211,20 +191,7 @@ export async function runWithReadyStructuredAgentSessionOperation( if (checked && !checked.ok) { return checked } - const ensured = await context.ensureAgent(envelope.sessionId) - if (!ensured.ok) { - return ensured - } - const child = context.sessions.get(envelope.sessionId)?.child - if (!child || child.close) { - return operationDidNotRun() - } - const identity = { generation: child.generation, fence: child.fence } - if (child.phase === 'ready') { - return { ok: true as const, child: identity, deadline } - } - const readiness = context.startupAttempts.readiness(envelope.sessionId, identity) - return readiness ? { ok: true as const, child: identity, readiness } : operationDidNotRun() + return selectStructuredAgentSessionOperationChild(context, envelope.sessionId) } const child = await select().catch((error: unknown) => { context.deps.logger.warn('selecting an agent for an operation failed', { @@ -237,8 +204,8 @@ export async function runWithReadyStructuredAgentSessionOperation( if (!child.ok) { return { result: child } } - if (!child.readiness) { - return { result: await run(child) } + if (child.child && !child.readiness) { + return { result: await run({ ok: true, child: child.child, deadline }) } } context.deliveryOrder.reconcile( envelope.sessionId, @@ -256,7 +223,20 @@ export async function runWithReadyStructuredAgentSessionOperation( return selected.result } try { - const ready = selected.readiness.wait() + let { child, readiness } = selected + if (!child) { + const started = await context.startAgent(envelope.sessionId) + if (!started.ok) { + return started + } + const selection = selectStructuredAgentSessionOperationChild(context, envelope.sessionId) + if (!selection.ok || !selection.child) { + return selection.ok ? operationDidNotRun() : selection + } + child = selection.child + readiness = selection.readiness + } + const ready = readiness?.wait() ?? Promise.resolve('ready' as const) const outcome = await waitForStructuredAgentSessionStart( Promise.all([ready, selected.place.before]).then(([outcome]) => outcome === 'ready' ? 'ready' : 'ended' @@ -269,37 +249,12 @@ export async function runWithReadyStructuredAgentSessionOperation( if (Date.now() >= deadline) { return operationDidNotRun(true) } + const ran = child return await context.serialize(envelope.sessionId, () => // Admission re-reads the journal here after readiness and answers a competing settlement. - run({ ok: true, child: selected.child, deadline }) + run({ ok: true, child: ran, deadline }) ) } finally { selected.place.release() } } - -/** Replay uses the ledger answer; a new operation uses only its selected ready child. */ -export function openWithReadyStructuredAgentSessionChild( - context: StructuredAgentSessionMutationContext, - envelope: AgentSessionMutationEnvelope, - selection: ReadyOperationSelection -): (ledger: 'admit' | 'replay') => Promise { - return async (ledger) => { - const opened = await openForProviderWrite(context, envelope)() - if (!opened.ok || ledger === 'replay') { - return opened - } - if (!selection.child) { - return selection.replay - ? { ok: false, refusal: agentSessionOperationOutcomeUnknown(envelope.clientOperationId) } - : operationDidNotRun() - } - if (selection.deadline !== undefined && Date.now() >= selection.deadline) { - return operationDidNotRun(true) - } - const child = context.sessions.get(envelope.sessionId)?.child - return child?.phase === 'ready' && !child.close && sameProviderChild(child, selection.child) - ? opened - : operationDidNotRun() - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts index 257072f7c23b..7ba443d55e63 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts @@ -104,6 +104,10 @@ async function stopOwnerAndReprobe( const delay = deps.delay ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) let probe: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'owner stop requested' } for (const signal of ['SIGTERM', 'SIGKILL'] as const) { + // Resolution runs outside the session's serialize: a lease that moved names a pid not ours. + if (!leaseStillNamesOwner(deps.store.getRecord(record.sessionId), record)) { + return probe + } stop(pid, signal) for (let attempt = 0; attempt < STOP_PROBES[signal]; attempt += 1) { probe = await deps.probeRecord(record) @@ -116,6 +120,19 @@ async function stopOwnerAndReprobe( return probe } +function leaseStillNamesOwner( + current: AgentSessionRecord | null, + probed: AgentSessionRecord +): boolean { + const owner = current?.lease.ownerProcess + const expected = probed.lease.ownerProcess + return ( + current?.lease.runtimeFence === probed.lease.runtimeFence && + owner?.pid === expected?.pid && + owner?.processStartTimeMs === expected?.processStartTimeMs + ) +} + function defaultStopOwnerProcess(pid: number, signal: StructuredSessionRecoveryStopSignal): void { try { process.kill(pid, signal) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index 3658b3df146d..c7c56b150c3d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -21,7 +21,7 @@ import type { StructuredAgentSessionHostSession } from './structured-agent-sessi import { operationDidNotRun, type ReadyOperationSelection -} from './structured-agent-session-ready-operation' +} from './structured-agent-session-operation-child' import { AGENT_SESSION_NOT_ATTACHED, type AgentSessionMutationSessionPreparation diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-eligibility.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-eligibility.ts new file mode 100644 index 000000000000..0812ae0bb0af --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-eligibility.ts @@ -0,0 +1,85 @@ +// Whether a session's record may start its agent on this host, read in the commit after the +// lease is adjudicated and recovery concluded, so it sees the lease the resolver handed back. + +import { + refuse, + type AgentSessionRefusalDetailsByCode, + type AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire-refusals' +import { terminalOwnerRefusalMessage } from '../../../shared/agent-session-legacy-handoff-lease' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' +import { hostCanStartRecord } from './structured-agent-session-provider-support' +import { + structuredAgentSessionResumeOperationId, + structuredAgentSessionResumeParams +} from './structured-agent-session-resume-eligibility' + +/** The eligibility read, in the commit after recovery: the params the attach acts on, or why the + * record may not start here. */ +export function structuredAgentSessionStartParams( + context: StructuredAgentSessionAttachContext, + sessionId: string, + startedFor: string | undefined +): + | Extract + | { + ok: true + params: NonNullable> + startupOptions?: Readonly> + } { + const record = context.deps.store.getRecord(sessionId) + if (!record) { + return refusedStructuredAgentSessionStart( + 'agent_session_identity_required', + { reason: 'recordMissing' }, + 'No structured session exists by that id.' + ) + } + if (!hostCanStartRecord(context.deps, record)) { + return refusedStructuredAgentSessionStart( + 'structured_agent_session_unsupported', + { reason: 'hostUnsupported' }, + 'This execution host cannot resume the requested structured agent session.' + ) + } + const params = structuredAgentSessionResumeParams( + record, + structuredAgentSessionResumeOperationId(context.now()), + context.sessions.get(sessionId)?.lastEndedChild + ) + if (!params) { + return record.lease.unreconciled + ? refusedStructuredAgentSessionStart( + 'execution_owner_reconciling', + { reason: 'hostReconciling' }, + 'This host has not yet adjudicated the session lease.' + ) + : record.lease.claimStatus === 'conflicted' + ? refusedStructuredAgentSessionStart( + 'agent_session_conflict', + { reason: 'claimConflicted' }, + terminalOwnerRefusalMessage(record.lease) + ) + : refusedStructuredAgentSessionStart( + 'agent_session_ownership_unknown', + { reason: 'notResumable' }, + 'The session lease is not one this host may resume.' + ) + } + const startupOptions = startedFor + ? context.sessions + .get(sessionId) + ?.journal.submissions() + .find((submission) => submission.clientMessageId === startedFor)?.options + : undefined + return { ok: true, params, ...(startupOptions ? { startupOptions } : {}) } +} + +export function refusedStructuredAgentSessionStart( + code: C, + details: NoInfer, + message: string +): Extract { + return { ok: false, refusal: refuse(code, details, message) } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index d90943123b01..4c7e0d271cdb 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 @@ -11,7 +11,10 @@ import type { import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalCursor, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' import { failedProviderChildStart } from './structured-agent-session-provider-child' import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' @@ -97,11 +100,19 @@ export function hasStructuredAgentSessionStartFailureRow( */ export async function recordStructuredAgentSessionStartFailure( session: Pick & { fence: number }, - failure: StructuredAgentSessionStartFailure + failure: StructuredAgentSessionStartFailure, + /** Only these of what is queued: the rest was accepted after the start was asked for. */ + which?: (submission: AgentJournalSubmission) => boolean ): Promise { const oldest = oldestQueuedSubmission(session) const startKey = failure.startKey ?? oldest?.clientMessageId - if (startKey === undefined) { + const rejection = { reason: failure.reason, rejection: failure.rejection } + const chosen = which + ? session.journal + .submissions() + .filter((entry) => isQueuedAgentJournalSubmission(entry) && which(entry)) + : null + if (startKey === undefined || chosen?.length === 0) { return } await session.journal.appendLifecycleBatch({ @@ -109,10 +120,33 @@ export async function recordStructuredAgentSessionStartFailure( fence: session.fence, recovered: true, mutations: [structuredAgentSessionStartFailureRow(startKey, failure)], - ...(oldest ? { rejectsQueued: { reason: failure.reason, rejection: failure.rejection } } : {}) + ...(chosen + ? { + dispatches: chosen.map(({ clientMessageId }) => ({ + clientMessageId, + fence: session.fence, + state: 'rejected' as const, + recovered: true as const, + ...rejection + })) + } + : oldest + ? { rejectsQueued: rejection } + : {}) }) } +/** Accepted no later than `cursor`, in the journal epoch it names. */ +export function acceptedNoLaterThan( + journal: Pick, + cursor: AgentJournalCursor +): (submission: AgentJournalSubmission) => boolean { + return (submission) => + journal.cursor().epoch === cursor.epoch && + submission.acceptedSequence !== undefined && + submission.acceptedSequence <= cursor.sequence +} + export function oldestQueuedSubmission( session: Pick ): ReturnType[number] | undefined { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-flight.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-flight.ts new file mode 100644 index 000000000000..9a29fa819a97 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-flight.ts @@ -0,0 +1,99 @@ +// A session's agent start in flight, outside its serialize. +// +// The serialize is where messages are admitted, so a start that held it for its probes, resolvers, +// spawn and drains made every send wait for the agent. A start instead registers here, in one short +// step before its first wait; every wait after runs outside the lock, raced with the abort signal a +// Stop, close or quit reaches it by, and every write is a commit that first re-checks the start is +// still the session's. A start that loses that check is late: what it spawned is closed, never +// handed anything. + +import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' +import type { StructuredAgentSessionAttachLock } from './structured-agent-session-attach-lock' +import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' +import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' + +export type StructuredAgentSessionStartFlight = { + /** The start's generation: the startup attempt it mints carries the same id. */ + readonly attemptId: string + /** Settles once the start is over: its child published, refused, or aborted. */ + readonly outcome: Promise +} + +/** One start per session: a second asker joins the one in flight. */ +export class StructuredAgentSessionStartFlights { + private readonly flying = new Map() + /** Exits reported for a start's child before its publish put it on record. */ + private readonly exits = new Map() + + get(sessionId: string): StructuredAgentSessionStartFlight | undefined { + return this.flying.get(sessionId) + } + + isCurrent(sessionId: string, attemptId: string): boolean { + return this.flying.get(sessionId)?.attemptId === attemptId + } + + register(sessionId: string, flight: StructuredAgentSessionStartFlight): void { + this.flying.set(sessionId, flight) + } + + end(sessionId: string, attemptId: string): void { + if (this.isCurrent(sessionId, attemptId)) { + this.flying.delete(sessionId) + this.exits.delete(sessionId) + } + } + + /** Held for the publish when a start is in flight; false when none is. */ + deferExit(event: StructuredAgentSessionEndedEvent): boolean { + if (!this.flying.has(event.sessionId)) { + return false + } + this.exits.set(event.sessionId, [...(this.exits.get(event.sessionId) ?? []), event]) + return true + } + + /** The exit reported for the child a start is publishing, if any. */ + takeExit( + sessionId: string, + child: { generation: string | null; fence: number } + ): StructuredAgentSessionEndedEvent | undefined { + const exits = this.exits.get(sessionId) ?? [] + this.exits.delete(sessionId) + return exits.find( + (exit) => exit.fence === child.fence && exit.acquisitionGeneration === child.generation + ) + } +} + +/** The start was not the session's by the time it committed: its result is discarded. */ +export class StructuredAgentSessionLateStartError extends Error { + constructor() { + super('the agent start was superseded before it committed') + this.name = 'StructuredAgentSessionLateStartError' + } +} + +/** The lock a start runs its attach under; its fence is the start's own registration. */ +export function structuredAgentSessionStartFlightLock(input: { + sessionId: string + attemptId: string + signal: AbortSignal + flights: StructuredAgentSessionStartFlights + serialize: (sessionId: string, task: () => Promise) => Promise +}): StructuredAgentSessionAttachLock { + const { sessionId, attemptId, signal } = input + return { + signal, + wait: (work) => waitForPromiseWithSignal(work, signal), + commit: (step) => + input.serialize(sessionId, async () => { + // A close of the conversation, like a Stop or quit, aborts the start first. + throwIfSignalAborted(signal) + if (!input.flights.isCurrent(sessionId, attemptId)) { + throw new StructuredAgentSessionLateStartError() + } + return step() + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts index 274ffb6f208a..0b1274784332 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 @@ -42,3 +42,15 @@ export type StructuredAgentSessionStartupAttempt = { /** Any stdout frame or stderr line from the child: proof the start is still moving. */ readonly onOutput?: () => void } + +/** The startup limit's own reason, so an expired start is told from a close, Stop or quit. */ +export class StructuredAgentSessionStartupExpiredError extends Error { + constructor() { + super('the agent stopped making progress before it finished starting') + this.name = 'StructuredAgentSessionStartupExpiredError' + } +} + +export function isStructuredAgentSessionStartupExpired(reason: unknown): boolean { + return reason instanceof Error && reason.name === 'StructuredAgentSessionStartupExpiredError' +} 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 90bbb3680299..49381d97e8f1 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 @@ -26,11 +26,13 @@ export function mintStructuredAgentSessionStartupAttempt(input: { events?: StructuredAgentSessionEventSink signal?: AbortSignal startupOptions?: Readonly> + /** The id the host registered the start under, before any of its waits. */ + attemptId?: string }): StructuredAgentSessionStartupAttempt { const { record } = input const options = input.startupOptions ?? record.options return { - attemptId: randomUUID(), + attemptId: input.attemptId ?? randomUUID(), identity: input.identity, fence: record.lease.runtimeFence, spawnToken: input.spawnToken, @@ -46,18 +48,6 @@ export function mintStructuredAgentSessionStartupAttempt(input: { } } -/** The startup limit's own reason, so an expired start is told from a close, Stop or quit. */ -export class StructuredAgentSessionStartupExpiredError extends Error { - constructor() { - super('the agent stopped making progress before it finished starting') - this.name = 'StructuredAgentSessionStartupExpiredError' - } -} - -export function isStructuredAgentSessionStartupExpired(reason: unknown): boolean { - return reason instanceof Error && reason.name === 'StructuredAgentSessionStartupExpiredError' -} - /** Where an expired attempt stood: still spawning, or published as a starting child. */ export type StructuredAgentSessionExpiredStartup = { sessionId: string @@ -83,8 +73,12 @@ export type StructuredAgentSessionStartupProgress = { } type Tracked = { - attempt: StructuredAgentSessionStartupAttempt + attemptId: string + /** Null until the start reaches its acquire. */ + attempt: StructuredAgentSessionStartupAttempt | null child: StructuredAgentSessionProviderChildIdentity | null + /** The ceiling counts from here: the start's registration, before any of its waits. */ + registeredAt: number spawnedAt: number | null lastOutputAt: number timer: ReturnType | null @@ -118,14 +112,18 @@ export class StructuredAgentSessionStartupAttempts { this.now = deps.now ?? Date.now } - track( - sessionId: string, - attempt: StructuredAgentSessionStartupAttempt - ): StructuredAgentSessionStartupProgress { + /** A start registered before its first wait: its ceiling runs from now. */ + begin(sessionId: string, attemptId: string): void { + this.register(sessionId, attemptId) + } + + private register(sessionId: string, attemptId: string): Tracked { this.end(sessionId) const tracked: Tracked = { - attempt, + attemptId, + attempt: null, child: null, + registeredAt: this.now(), spawnedAt: null, lastOutputAt: 0, timer: null, @@ -134,6 +132,22 @@ export class StructuredAgentSessionStartupAttempts { readiness: Promise.withResolvers() } this.open.set(sessionId, tracked) + if (!this.disposed) { + this.schedule(sessionId, tracked) + } + return tracked + } + + track( + sessionId: string, + attempt: StructuredAgentSessionStartupAttempt + ): StructuredAgentSessionStartupProgress { + const begun = this.open.get(sessionId) + const tracked = + begun?.attemptId === attempt.attemptId && !begun.attempt + ? begun + : this.register(sessionId, attempt.attemptId) + tracked.attempt = attempt const current = (): boolean => this.open.get(sessionId) === tracked && !this.disposed return { spawned: () => { @@ -177,7 +191,7 @@ export class StructuredAgentSessionStartupAttempts { child: StructuredAgentSessionProviderChildIdentity & { phase?: 'starting' | 'ready' } ): void { const tracked = this.open.get(sessionId) - if (tracked?.attempt.attemptId !== attemptId) { + if (tracked?.attemptId !== attemptId) { return } if (child.phase === 'ready') { @@ -223,10 +237,11 @@ export class StructuredAgentSessionStartupAttempts { } } - /** The attempt failed, or its attach did: nothing it started is left to time out. */ + /** The attempt failed, or its attach did: nothing it started is left to time out. A published + * child stays on its clock until it proves its start or ends. */ abandon(sessionId: string, attemptId: string): void { const tracked = this.open.get(sessionId) - if (tracked?.attempt.attemptId === attemptId) { + if (tracked?.attemptId === attemptId && !tracked.child) { this.settle(sessionId, tracked, 'ended') } } @@ -258,12 +273,12 @@ export class StructuredAgentSessionStartupAttempts { } private schedule(sessionId: string, tracked: Tracked): void { - if (tracked.spawnedAt === null) { - return + if (tracked.timer) { + clearTimeout(tracked.timer) } const due = Math.min( - tracked.lastOutputAt + this.limits.silenceMs, - tracked.spawnedAt + this.limits.ceilingMs + tracked.spawnedAt === null ? Infinity : tracked.lastOutputAt + this.limits.silenceMs, + tracked.registeredAt + this.limits.ceilingMs ) tracked.timer = setTimeout(() => this.check(sessionId, tracked), Math.max(0, due - this.now())) // A start's clock must never be the reason a process stays alive at quit. @@ -272,13 +287,13 @@ export class StructuredAgentSessionStartupAttempts { private check(sessionId: string, tracked: Tracked): void { tracked.timer = null - if (this.open.get(sessionId) !== tracked || this.disposed || tracked.spawnedAt === null) { + if (this.open.get(sessionId) !== tracked || this.disposed) { return } const now = this.now() - if (now - tracked.spawnedAt >= this.limits.ceilingMs) { + if (now - tracked.registeredAt >= this.limits.ceilingMs) { this.expire(sessionId, tracked, 'ceiling') - } else if (now - tracked.lastOutputAt >= this.limits.silenceMs) { + } else if (tracked.spawnedAt !== null && now - tracked.lastOutputAt >= this.limits.silenceMs) { this.expire(sessionId, tracked, 'silent') } else { this.schedule(sessionId, tracked) @@ -300,7 +315,7 @@ export class StructuredAgentSessionStartupAttempts { this.report(tracked.expiry) this.end(sessionId) } - this.deps.expire({ sessionId, attemptId: tracked.attempt.attemptId, child: tracked.child }) + this.deps.expire({ sessionId, attemptId: tracked.attemptId, child: tracked.child }) } private settle(sessionId: string, tracked: Tracked, outcome: 'ready' | 'ended'): void { @@ -315,7 +330,7 @@ export class StructuredAgentSessionStartupAttempts { tracked: Tracked, outcome: StructuredAgentSessionStartupSettled['outcome'] ): StructuredAgentSessionStartupSettled | null { - return tracked.spawnedAt === null + return tracked.spawnedAt === null || !tracked.attempt ? null : { agent: tracked.attempt.identity.agent, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index c32a43597490..c386e3188edf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -6,19 +6,8 @@ // row the next attach settles as `unknown`, whereas the reverse would lose a // turn the provider already accepted. -import { - agentSessionFailureFact, - providerDiagnosticOf -} from '../../../shared/agent-session-failure' -import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { captureStructuredExecutionOptions } from './structured-agent-session-execution-options' -import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' -import { abortSignalReason } from '../../../shared/abort-signal-reason' -import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' -import { - agentSessionFailureWords, - type AgentSessionFailureWordsContext -} from '../../../shared/agent-session-failure-words' +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import type { AgentJournalMessageItem, AgentJournalSubmission @@ -31,23 +20,10 @@ import { type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' -import { - contextStructuredAgentSessionCurrentWork, - type StructuredAgentSessionCurrentWork -} from './structured-agent-session-current-work' +import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' -import type { - AgentSessionDispatchOutcome, - StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import { - handOverStructuredAgentSessionCommand, - structuredAgentSessionHandoverOrigin, - type StructuredAgentSessionCommandHandoverContext -} from './structured-agent-session-command-turn' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { classifyJournalOpenFailure, isJournalWrittenByNewerOrca, @@ -57,8 +33,6 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo import { isAgentSessionAttachmentExpiredError } from '../agent-session-attachments/agent-session-attachment-claims' import { CommandReceiptExistsError } from '../agent-session-journal/command-receipt-transaction' import type { StructuredAgentRegistry } from './structured-agent-registry' -import { carriedStoppedHistory } from './structured-agent-session-stopped-history-selection' -import { withStoppedHistory } from '../../../shared/agent-session-stopped-history' export { performPrompt } from './structured-agent-session-turns-prompt' export { performCancel } from './structured-agent-session-turns-cancel' @@ -119,56 +93,6 @@ export function agentSessionAttachmentExpiredRefusal(): { ) } -/** A thrown adapter error is indistinguishable from a lost reply, so it settles as `unknown`, never - * replayed. Only a child that proved its start is handed anything, so a start that fails leaves its - * messages unsent instead (the delivery loop's barrier); an adapter that knows a write never - * happened answers `rejected`. */ -async function dispatchSafely( - ctx: AgentSessionHandoverContext, - clientMessageId: string, - body: AgentJournalMessageItem, - requestedAt: number, - executionOptions: AgentSessionExecutionOptions | undefined, - signal?: AbortSignal -): Promise { - try { - const outcome = await ctx.adapter.dispatch({ - sessionId: ctx.sessionId, - clientMessageId, - body, - ...(executionOptions ? { executionOptions } : {}), - ...(signal ? { signal } : {}), - fence: ctx.fence, - requestedAt - }) - return signal?.aborted && outcome.state === 'rejected' - ? { - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { - surface: 'rejection' - }) - } - : outcome - } catch (error) { - if (signal?.aborted && error === abortSignalReason(signal)) { - return { - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) - } - } - if (isAgentSessionOptionRejectedError(error)) { - return { - state: 'rejected', - ...agentSessionFailureWords( - agentSessionFailureFact('providerRejected', { detail: providerDiagnosticOf(error) }), - { surface: 'rejection' } - ) - } - } - return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } - } -} - /** * One id, one delivery. A submission that already exists replays its recorded * outcome and NEVER goes back on the wire, whatever state it is in and whatever @@ -251,102 +175,6 @@ export async function performSend( } } -export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverContext & { - acquireAborts?: Pick -} - -/** - * Hands one queued submission to the provider. The `dispatch{pending}` row goes first: a crash - * after it leaves a message in doubt, never one that reads as queued and so provably unwritten. - */ -export async function handOverSubmission( - ctx: AgentSessionHandoverContext, - submission: AgentJournalSubmission -): Promise { - const { clientMessageId } = submission - const body = ctx.journal.itemBody(agentJournalSubmissionKey(clientMessageId)) - if (body?.kind !== 'message') { - await ctx.journal.resolveDispatch({ - clientMessageId, - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('hostFault'), { surface: 'rejection' }), - fence: ctx.fence - }) - return - } - if (body.command) { - await handOverStructuredAgentSessionCommand(ctx, submission, body) - return - } - // The message joins the turn the child it goes to is running, a steer, or opens its own; never a - // turn an ended generation left. A new turn carries the stopped messages owed to the agent, named - // on this same row before anything is written. - const turnScope = contextStructuredAgentSessionCurrentWork(ctx).turnScope() - const carried = carriedStoppedHistory(ctx, turnScope, submission, body) - await ctx.journal.resolveDispatch({ - clientMessageId, - state: 'pending', - fence: ctx.fence, - turnScope, - ...(carried ? { carries: carried.covers } : {}) - }) - // The handover row's instant on the host clock; the turn this dispatch opens records it so the - // live counter never re-anchors at turn-open. - const attempt = ctx.acquireAborts?.begin(ctx.sessionId, true) - let outcome: AgentSessionDispatchOutcome - try { - outcome = await dispatchSafely( - ctx, - clientMessageId, - carried ? withStoppedHistory(body, carried.messages) : body, - structuredAgentSessionHandoverOrigin(ctx.journal, submission), - submission.options, - attempt?.signal - ) - } finally { - attempt?.end() - } - // An admission needs no dispatch row: the submission is already pending. - if (outcome.state === 'admitted') { - return - } - try { - await ctx.journal.resolveDispatch( - outcome.state === 'accepted' - ? { - clientMessageId, - state: 'accepted', - providerIdentity: outcome.providerIdentity, - fence: ctx.fence - } - : outcome.state === 'rejected' - ? { - clientMessageId, - state: 'rejected', - reason: outcome.reason, - rejection: outcome.rejection, - ...(outcome.answeredInTurn ? { answeredInTurn: outcome.answeredInTurn } : {}), - fence: ctx.fence - } - : { clientMessageId, state: 'unknown', reason: outcome.reason, fence: ctx.fence } - ) - } catch (error) { - // A failed resolution must not strand a pending row; an unknown result is - // explicitly replayable. - try { - await ctx.journal.resolveDispatch({ - clientMessageId, - state: 'unknown', - reason: DISPATCH_DOUBT_PERSISTENCE_FAILED, - fence: ctx.fence - }) - } catch { - // Nothing further to record; the pending row is settled on the next open. - } - throw error - } -} - function requireSubmission( ctx: AgentSessionTurnContext, clientMessageId: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 7d3f02654942..2f45c8cc4a00 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -155,7 +155,7 @@ function openHost(overrides: Partial = {}): void /** The host starting the agent with no message to deliver, as an operation that needs it does. */ function startAgent(): Promise { - return host['serialize'](SESSION, () => host['mutationContext']().ensureAgent(SESSION)) + return host['mutationContext']().startAgent(SESSION) } beforeEach(async () => { From 0e0a7f9b111c0ff272f8c108d448e7854bf1be7c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:17:46 -0700 Subject: [PATCH 102/127] Test that a send is accepted at every wait an agent start makes One whole-flow test holds the start at each wait in turn (closing-child join, reconcile probe, recovery re-probe, resolver, spawn, sink drain, open, started recovery, pending row, provider call): a second send is accepted at once, a Stop is recorded without waiting and nothing reaches the provider, and what was sent goes to the published child once, in order. Also quit before spawn, concurrent sends, a lease left recovering, and an idle sweep racing a send. --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + ...ured-agent-session-start-admission.test.ts | 417 ++++++++++++++++++ 2 files changed, 418 insertions(+) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index b377e5a986bc..523b23ffb770 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -192,6 +192,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts new file mode 100644 index 000000000000..b58d541ca727 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts @@ -0,0 +1,417 @@ +// A chat's agent starts outside the session's serialize: each wait a start makes is held in turn +// here, against the real host, store and journal with a scripted adapter. While it is held, a send +// is accepted at once, a Stop or quit ends the start without waiting on it, and what was sent is +// handed over once, in order, to the child the start published. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { persistRewindRecord } from './structured-rewind-recovery' + +const CALLER = { callerKey: 'client-1' } +const PID_ABSENT: AgentSessionOwnerProbe = { outcome: 'pid-absent' } +const eventually = (assertion: () => void | Promise) => + vi.waitFor(assertion, { timeout: 10_000 }) + +type Point = + | 'closing' + | 'probe' + | 'reprobe' + | 'resolver' + | 'spawn' + | 'drain' + | 'open' + | 'recovery' + | 'pending-row' + | 'dispatch' +const holds = new Map>() +const reached = new Set() + +/** Each hold stops the first start step that reaches it, once. */ +async function at(point: Point): Promise { + const held = holds.get(point) + if (held) { + holds.delete(point) + reached.add(point) + await held.promise + } +} + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let closeSession: Mock> +let releaseAcquisition: Mock> +let probeOwner: Mock<(record: unknown) => Promise> +let delivered: string[] +let idleSweep: { intervalMs: number; idleMs: number } + +function startHost(): void { + host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, + logger: createStructuredAgentSessionLogger(), + store, + adapter: { + acquire, + closeSession, + releaseAcquisition, + open: async () => { + await at('open') + return { reportedOptions: { model: 'default' } } + }, + dispatch: async (input) => { + await at('dispatch') + input.signal?.throwIfAborted() + delivered.push(input.clientMessageId) + const ordinal = delivered.length + return { + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: `t-${ordinal}`, ordinal } + } + }, + recoverRewind: async () => { + await at('recovery') + return { ok: true, items: [] } + }, + cancelTurn: vi.fn(async () => ({ cancelled: false })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + probeOwner, + stopOwnerProcess: vi.fn(), + resolveLaunchArgs: async () => { + await at('resolver') + return undefined + }, + idleSweep, + now: () => NOW + }) + // The attempt's sink drains before its child is published. + const { runtimeState } = host.collaboratorsForTests() + const mint = runtimeState.mintEventSink.bind(runtimeState) + vi.spyOn(runtimeState, 'mintEventSink').mockImplementation((sessionId) => { + const sink = mint(sessionId) + const drained = sink.drained.bind(sink) + sink.drained = async () => { + await at('drain') + return drained() + } + return sink + }) +} + +/** A new host with no child, so the next send starts one; `reopen` loads the store anew, so its + * lease is unreconciled. */ +async function restart(reopen = false): Promise { + await host.close(SESSION, 'evict') + await host.flushAllStreamedEvents() + if (reopen) { + store = await openTestAgentSessionRecordStore(root) + } + startHost() +} + +/** How each point is reached: everything before its hold has already happened. */ +const SETUP: Record Promise> = { + closing: async () => { + // A stop whose close stayed unproven leaves the child closing; the next start joins it. + closeSession.mockResolvedValueOnce(false) + const { serialize, lifetime } = host.collaboratorsForTests() + await serialize(SESSION, () => lifetime.stopAgent(SESSION, { cause: 'evict', resting: true })) + .then(() => undefined) + .catch(() => undefined) + closeSession.mockImplementationOnce(async () => { + await at('closing') + return true + }) + }, + probe: () => restart(true).then(() => holdProbes([PID_ABSENT], 0)), + reprobe: async () => { + await leaveRecovering() + holdProbes([{ outcome: 'identity-matched', matchedOn: ['spawn-token'] }, PID_ABSENT], 1) + }, + resolver: () => restart(), + spawn: () => restart(), + drain: () => restart(), + open: () => restart(), + recovery: async () => { + await restart() + await persistRewindRecord(store, SESSION, store.getRecord(SESSION)!.lease.runtimeFence, { + operationId: 'rewind-op', + callerKey: 'client-1', + itemId: 'orca:rewound', + providerItemId: `codex:${THREAD}:turn-1:0`, + expectedEpoch: 'epoch-before', + phase: 'prepared', + retained: [] + }) + }, + 'pending-row': async () => { + await restart() + const write = AgentSessionJournal.prototype.resolveDispatch + vi.spyOn(AgentSessionJournal.prototype, 'resolveDispatch').mockImplementation(async function ( + this: AgentSessionJournal, + ...args + ) { + const written = await write.apply(this, args) + if (args[0].state === 'pending') { + await at('pending-row') + } + return written + }) + }, + dispatch: () => restart() +} + +/** The lease as a failed attempt leaves it: still naming its owner, in the recovering stage. */ +async function leaveRecovering(): Promise { + const owner = store.getRecord(SESSION)!.lease.ownerProcess + await restart() + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { ...record.lease, ownerProcess: owner, handoffStage: 'recovering' } + })) +} + +/** Answers the owner probes in turn; the one at `heldAt` waits on the probe hold for its point. */ +function holdProbes(answers: AgentSessionOwnerProbe[], heldAt: number): void { + answers.forEach((answer, index) => + probeOwner.mockImplementationOnce(async () => { + if (index === heldAt) { + await at(heldAt === 0 ? 'probe' : 'reprobe') + } + return answer + }) + ) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-start-admission-')) + resetHostTestOperationIds() + holds.clear() + reached.clear() + delivered = [] + idleSweep = { intervalMs: 60 * 60_000, idleMs: 60 * 60_000 } + acquire = vi.fn(async (input) => { + await at('spawn') + return { + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: input.spawnToken }, + acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + link: { + linkId: `link-${input.fence}`, + handle: codexProviderHandle(THREAD), + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: input.fence, + observedAt: NOW + } + } + }) + closeSession = vi.fn(async () => true) + releaseAcquisition = vi.fn(async () => true) + probeOwner = vi.fn(async () => PID_ABSENT) + store = await openTestAgentSessionRecordStore(root) + startHost() + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + for (const held of holds.values()) { + held.resolve() + } + vi.restoreAllMocks() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function envelope(method: string, fields: Record) { + const clientOperationId = hostTestOperationId() + const payloadFingerprint = computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + return { sessionId: SESSION, clientOperationId, expectedRuntimeFence: null, payloadFingerprint } +} + +function send(text: string) { + const fields = { body: hostTestMessage(text) } + const sent = envelope('agentSession.send', fields) + return { id: sent.clientOperationId, sent: host.send(CALLER, { envelope: sent, ...fields }) } +} + +async function submission(id: string) { + const { submissions } = await host.journalSnapshot(SESSION) + return submissions.find((entry) => entry.clientMessageId === id) +} + +/** A Stop naming no turn: it stops whatever the chat has in flight, a start included. */ +const stop = () => host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + +/** Holds `point`, sends the first message and waits for its start to reach the hold. */ +async function heldAt(point: Point) { + await SETUP[point]() + const held = Promise.withResolvers() + holds.set(point, held) + const spawned = acquire.mock.calls.length + const first = send('first') + await eventually(() => expect(reached.has(point)).toBe(true)) + return { first, release: () => held.resolve(), spawned } +} + +const child = () => host.collaboratorsForTests().sessions.get(SESSION)?.child ?? null +const PRE_PUBLISH: readonly Point[] = ['probe', 'reprobe', 'resolver', 'spawn', 'drain'] +// The owner probe's signal and re-probe are POSIX-only: Windows never signals a saved pid. +const POSIX = process.platform !== 'win32' + +describe('a send while the agent start is held', () => { + it.each<{ point: Point; acceptedWhileHeld?: false }>([ + { point: 'closing' }, + { point: 'probe' }, + ...(POSIX ? [{ point: 'reprobe' as const }] : []), + { point: 'resolver' }, + { point: 'spawn' }, + { point: 'drain' }, + { point: 'open' }, + // A send waits on a rewind in doubt by design; it waits outside the lock, behind the start. + { point: 'recovery', acceptedWhileHeld: false }, + { point: 'dispatch' } + ])('at $point is accepted at once and handed over once, in order', async (row) => { + const { first, release, spawned } = await heldAt(row.point) + + const second = send('second') + if (row.acceptedWhileHeld !== false) { + expect(await first.sent).toMatchObject({ ok: true }) + expect(await second.sent).toMatchObject({ ok: true }) + expect(delivered).toEqual([]) + } + release() + + await eventually(async () => + expect((await submission(second.id))?.dispatchState).toBe('accepted') + ) + expect(delivered).toEqual([first.id, second.id]) + expect(acquire).toHaveBeenCalledTimes(spawned + 1) + expect(child()).toMatchObject({ phase: 'ready', generation: `generation-${spawned + 1}` }) + }) +}) + +describe('a Stop while the agent start is held', () => { + it.each([ + // The first await after the start registers: nothing is probed, resolved or spawned yet. + 'probe', + ...(POSIX ? ['reprobe' as const] : []), + 'resolver', + 'spawn', + 'drain', + 'open', + 'recovery', + 'pending-row', + 'dispatch' + ])('at %s is recorded without waiting, and nothing reaches the provider', async (point) => { + const { first, release } = await heldAt(point) + + const stopped = stop() + // The pending row is a commit, which the Stop's own step queues behind; its abort does not. + if (point !== 'pending-row') { + expect(await stopped).toMatchObject({ ok: true }) + } + release() + expect(await stopped).toMatchObject({ ok: true }) + + await eventually(() => + expect(host.collaboratorsForTests().runtimeState.startFlights.get(SESSION)).toBeUndefined() + ) + if (point === 'recovery') { + // A send waiting on a rewind in doubt was never accepted: the Stop ended what it waited on. + expect(await first.sent).toMatchObject({ ok: false }) + } else { + await eventually(async () => + expect(await submission(first.id)).toMatchObject({ + dispatchState: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED + }) + ) + } + expect(delivered).toEqual([]) + // A child spawned for the stopped start is closed, never published as the session's. + if (PRE_PUBLISH.includes(point)) { + expect(child()).toBeNull() + } + if (point === 'spawn' || point === 'drain') { + await eventually(() => expect(releaseAcquisition).toHaveBeenCalled()) + } + }) +}) + +describe('a start racing the host', () => { + it('spawns nothing when quit lands before the spawn', async () => { + const { first, release, spawned } = await heldAt('resolver') + await first.sent + + await host.flushAllStreamedEvents() + release() + + expect(acquire).toHaveBeenCalledTimes(spawned) + expect(child()).toBeNull() + }) + + it('starts exactly one child for sends made at once', async () => { + await restart() + const spawned = acquire.mock.calls.length + const sends = ['a', 'b', 'c'].map(send) + + await eventually(async () => + expect((await submission(sends[2]!.id))?.dispatchState).toBe('accepted') + ) + expect(acquire).toHaveBeenCalledTimes(spawned + 1) + expect(delivered).toEqual(sends.map(({ id }) => id)) + }) + + it('clears a lease a failed attempt left recovering, and the start goes ahead', async () => { + await leaveRecovering() + + const { id } = send('after a failed start') + + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) + expect(store.getRecord(SESSION)?.lease.handoffStage).toBeNull() + }) + + it('dispatches a send an idle-sweep close races, without deadlock', async () => { + idleSweep = { intervalMs: 5, idleMs: 0 } + const { first, release } = await heldAt('spawn') + await first.sent + await new Promise((resolve) => setTimeout(resolve, 30)) + release() + + await eventually(async () => + expect((await submission(first.id))?.dispatchState).toBe('accepted') + ) + expect(delivered).toEqual([first.id]) + }) +}) From 64f640fa18af51cde8745d5392a0cea9a0e94458 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:43:42 -0700 Subject: [PATCH 103/127] Bound each startup step on its own and drop the startup silence rule A start no longer ends after 60 s without output: output is not protocol progress. Each protocol step now has its own bound where its handshake lives, and one 10-minute ceiling from registration stays as the outer bound. - Codex: initialize 15 s and thread start/resume on the connection's 30 s default again, instead of the startup ceiling. - ACP: initialize 60 s, authenticate 120 s, session new/load/resume/fork 300 s; turns stay unbounded and option writes keep their caller's bound. - Claude: the initialize answer has a 60 s bound; past it the open fails with the ordinary startup-failure words and the host ends the child. - The onOutput plumbing that only fed the silence rule is removed. --- src/main/acp/acp-agent-connection.ts | 5 +- src/main/acp/acp-peer-limits.ts | 14 +++++ src/main/acp/acp-session-lifecycle.test.ts | 14 +++-- src/main/acp/acp-session-runtime.ts | 3 +- src/main/acp/acp-structured-acquire.ts | 1 - .../claude/claude-agent-sdk-process-spawn.ts | 6 +- .../claude/claude-stream-json-connection.ts | 4 +- .../claude-structured-session-acquisition.ts | 1 - .../claude-structured-session-startup.test.ts | 14 +++++ .../claude-structured-session-startup.ts | 25 +++++++-- .../codex-app-server-connection-types.ts | 2 - src/main/codex/codex-app-server-connection.ts | 3 +- src/main/codex/codex-app-server-handshake.ts | 7 +-- .../codex/codex-structured-session-acquire.ts | 1 - .../codex/codex-structured-session-start.ts | 13 +---- src/main/jsonl-rpc/agent-connection.ts | 5 +- .../structured-agent-session-acquisition.ts | 1 - .../structured-agent-session-attach-flow.ts | 2 +- ...-agent-session-startup-attempt-contract.ts | 9 +-- ...agent-session-startup-attempt-host.test.ts | 41 +++----------- ...sion-startup-attempt-registrations.test.ts | 3 +- ...tructured-agent-session-startup-attempt.ts | 49 +++------------- src/main/pi/rpc-session.ts | 1 - .../managed-provider-process-output.test.ts | 56 ------------------- .../managed-provider-process.ts | 23 -------- ...agent-session-codex-start-deadline.test.ts | 26 +++++++-- 26 files changed, 113 insertions(+), 216 deletions(-) delete mode 100644 src/main/provider-process/managed-provider-process-output.test.ts diff --git a/src/main/acp/acp-agent-connection.ts b/src/main/acp/acp-agent-connection.ts index 5eb804a1921b..8214e77c3668 100644 --- a/src/main/acp/acp-agent-connection.ts +++ b/src/main/acp/acp-agent-connection.ts @@ -14,8 +14,6 @@ export type AcpAgentConnectionOptions = Omit & peer?: Omit /** Process exit evidence, including expected closes and processless spawn failures. */ onExit?: (error: Error, context: { expected: boolean; exit: ProviderProcessExit }) => void - /** Any stdout or stderr chunk from the child. */ - onOutput?: () => void } /** The execution host owns the child and protocol lifetime; the adapter owns turns and Stop. */ @@ -42,8 +40,7 @@ export class AcpAgentConnection extends AcpSessionRuntime { const peer = resolveAcpPeerOptions({ ...options.peer, closeOnInputEnd: false }) const managed = spawnManagedProviderProcess(launch, { spawnImpl, - site: 'acp-agent-teardown', - ...(options.onOutput ? { onOutput: options.onOutput } : {}) + site: 'acp-agent-teardown' }) const lifecycle: { closing: boolean; error?: Error } = { closing: false } const diagnose = (message: string): void => { diff --git a/src/main/acp/acp-peer-limits.ts b/src/main/acp/acp-peer-limits.ts index 51963e4791aa..a816531cb977 100644 --- a/src/main/acp/acp-peer-limits.ts +++ b/src/main/acp/acp-peer-limits.ts @@ -40,3 +40,17 @@ export function requestTimeout(value: number | null | undefined): number | null } return value } + +/** Each start step's own bound; turns are unbounded and option writes carry their caller's. */ +const ACP_START_STEP_TIMEOUT_MS: Readonly>> = { + initialize: 60_000, + authenticate: 120_000, + 'session/new': 300_000, + 'session/load': 300_000, + 'session/resume': 300_000, + 'session/fork': 300_000 +} + +export function acpStepBound(method: string): { timeoutMs: number | null } { + return { timeoutMs: ACP_START_STEP_TIMEOUT_MS[method] ?? null } +} diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts index a3020620c1e0..c253a95f0888 100644 --- a/src/main/acp/acp-session-lifecycle.test.ts +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AcpJsonRpcPeer } from './acp-json-rpc-peer' import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' import { AcpScriptedAgent, deferred, tick } from './acp-scripted-agent.test-support' -import { AcpConnectionClosedError, AcpRpcError } from './acp-errors' +import { AcpConnectionClosedError, AcpRequestTimeoutError, AcpRpcError } from './acp-errors' import type { RequestPermissionResponse } from './generated/acp-protocol.generated' const opened: { close: () => void }[] = [] @@ -219,18 +219,24 @@ describe('ACP caller-owned waits', () => { await rejected }) - it('lets initialize and explicit authentication wait for slow first-run startup and login', async () => { + it('bounds each start step on its own, never by the peer default', async () => { vi.useFakeTimers() const { agent, runtime } = fixture({ peer: { requestTimeoutMs: 20 } }) agent.on('initialize', () => {}) const initialized = runtime.initialize() - await vi.advanceTimersByTimeAsync(120_001) + await vi.advanceTimersByTimeAsync(59_000) agent.reply(agent.frames[0], { protocolVersion: 1 }) await initialized const authenticated = runtime.authenticate('caller-chosen') - await vi.advanceTimersByTimeAsync(120_001) + await vi.advanceTimersByTimeAsync(119_000) agent.reply(agent.frames[1], {}) await expect(authenticated).resolves.toEqual({}) + agent.on('session/new', () => {}) + const started = expect(runtime.start(startOptions)).rejects.toBeInstanceOf( + AcpRequestTimeoutError + ) + await vi.advanceTimersByTimeAsync(300_000) + await started }) it('lets request owners withdraw vendor hooks after cancel', async () => { diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 8cfd4bbaf425..8343cb92297f 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -7,6 +7,7 @@ import { AcpRpcError } from './acp-errors' import { AcpJsonRpcPeer, type AcpPeerOptions, type AcpRequestContext } from './acp-json-rpc-peer' +import { acpStepBound } from './acp-peer-limits' import { answerAcpPermission, readAcpPermissionRequest, @@ -263,7 +264,7 @@ export class AcpSessionRuntime { } private async call(method: string, params: unknown, schema: z.ZodType): Promise { - const result = await this.peer.request(method, params, { timeoutMs: null }).catch((error) => { + const result = await this.peer.request(method, params, acpStepBound(method)).catch((error) => { if (error instanceof AcpAgentError && error.code === -32000) { throw new AcpAuthRequiredError(error.message, error.data) } diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index 2fc69df4662a..ef24a0c5fe79 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -112,7 +112,6 @@ export async function acquireAcpStructuredSession(input: { }, { clientInfo: { name: 'orca', version: '1' }, - ...(acquire.onOutput ? { onOutput: acquire.onOutput } : {}), onPermission: (request, context) => { if (!isAcpReadySession(session) || !session.turns?.acceptsRequests) { // No prompt of Orca's runs (a turn the agent began itself included), or a Stop or steer diff --git a/src/main/claude/claude-agent-sdk-process-spawn.ts b/src/main/claude/claude-agent-sdk-process-spawn.ts index a48b3e8a2107..0cf0523eb9a1 100644 --- a/src/main/claude/claude-agent-sdk-process-spawn.ts +++ b/src/main/claude/claude-agent-sdk-process-spawn.ts @@ -44,8 +44,7 @@ function definedEnv(env: Record): Record void + platform: NodeJS.Platform = process.platform ): ClaudeCodeProcessSpawn { let managed: ManagedProviderProcess | null = null return { @@ -63,8 +62,7 @@ export function createClaudeCodeProcessSpawn( inheritedEnv: definedEnv(options.env), site: 'claude-stream-json-teardown', policy: (supervised) => claudeChildClosePolicy(supervised, platform), - acceptClose: claudeChildCloseProven, - ...(onOutput ? { onOutput } : {}) + acceptClose: claudeChildCloseProven } ) // The SDK drains stderr only for its own local spawn; the managed process drains it here. diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index 828a58e043ab..dc446b3b1b3a 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -81,8 +81,6 @@ export type ClaudeStreamJsonConnectionHandlers = { /** The root process exited, reported once. `expected`: a close had begun, so it is that close's * end, even one that ran out of its own escalation first and came back unproven. */ onExit?: (error: Error, exit?: { expected: boolean }) => void - /** Any stdout or stderr chunk from the child. */ - onOutput?: () => void } /** @@ -139,7 +137,7 @@ export async function openClaudeStreamJsonConnection( queryImpl?: typeof ClaudeAgentSdk.query ): Promise { const { query } = await loadClaudeAgentSdk() - const spawner = createClaudeCodeProcessSpawn(spawnImpl, process.platform, handlers.onOutput) + const spawner = createClaudeCodeProcessSpawn(spawnImpl, process.platform) const inbox = createClaudeUserMessageQueue() const session = (queryImpl ?? query)({ prompt: inbox.messages, diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index f8386137e8f9..07dcf0950459 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -189,7 +189,6 @@ export async function acquireClaudeSession({ onMessage, canUseTool, onUserDialog, - ...(input.onOutput ? { onOutput: input.onOutput } : {}), onFault: (error) => { childEnded ??= error initProof.reject(error) diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index bdec578302cf..d7b099eb0153 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -7,6 +7,7 @@ import { USER_MESSAGE } from './claude-structured-session-test-support' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { CLAUDE_INITIALIZE_TIMEOUT_MS } from './claude-structured-session-startup' const ACQUIRE = { identity: identityFor(), fence: 7, spawnToken: 'spawn-9' } @@ -121,6 +122,19 @@ describe('Claude host-awaited open', () => { expect(claude.connections[0].closeCount).toBe(1) }) + it('fails open when initialize does not answer within its own bound', async () => { + const claude = fakeClaude({ initDelayMs: 10 * 60_000 }) + const { adapter } = adapterFor(claude) + await adapter.acquire(ACQUIRE) + const open = expect(claudeOpened(adapter, 'session-1')).rejects.toThrow( + 'did not answer initialize' + ) + await vi.advanceTimersByTimeAsync(CLAUDE_INITIALIZE_TIMEOUT_MS) + await open + await adapter.closeSession('session-1') + expect(claude.connections[0].closeCount).toBe(1) + }) + it('lets the host abort a silent initialize wait and close the owned process', async () => { const claude = fakeClaude({ initDelayMs: 12_000 }) const { adapter } = adapterFor(claude) diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index b4c9c3924d08..80c94dfdebc1 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -1,5 +1,5 @@ -// Claude initialize is awaited by the host after process publication. Settings enrichment follows -// independently; the host owns a failed open and its single close. +// Claude initialize is awaited by the host after process publication, under its own bound. Settings +// enrichment follows independently; the host owns a failed open and its single close. import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' @@ -56,7 +56,10 @@ export type ClaudeStartupFacts = ClaudeInitializeFacts & { prepared: ReturnType } -/** Settles on the CLI's initialize answer, or on its exit or a refused proof. */ +/** The CLI's initialize answer is a start step like any other: past this the start fails. */ +export const CLAUDE_INITIALIZE_TIMEOUT_MS = 60_000 + +/** Settles on the CLI's initialize answer, or on its exit, a refused proof or the bound. */ export async function readClaudeStartupFacts(input: { connection: ClaudeStreamJsonConnection initProof: ClaudeInitProof @@ -67,7 +70,7 @@ export async function readClaudeStartupFacts(input: { requestTimeoutMs: number | undefined emit: (event: ClaudeStructuredSessionEvent) => void }): Promise { - // The CLI's first answer has no request deadline of its own; the reads after it do. + let timer: ReturnType | undefined const initialization = await Promise.race([ input.connection .initializationResult() @@ -83,8 +86,18 @@ export async function readClaudeStartupFacts(input: { } return result }), - input.initProof.promise.then(() => new Promise(() => {})) - ]) + input.initProof.promise.then(() => new Promise(() => {})), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => + reject( + new Error(`claude did not answer initialize within ${CLAUDE_INITIALIZE_TIMEOUT_MS}ms`) + ), + CLAUDE_INITIALIZE_TIMEOUT_MS + ) + timer.unref?.() + }) + ]).finally(() => clearTimeout(timer)) if (input.connection.closed) { throw new Error('claude session closed before startup completed') } diff --git a/src/main/codex/codex-app-server-connection-types.ts b/src/main/codex/codex-app-server-connection-types.ts index 9e50a5233133..bf33bc5d0b80 100644 --- a/src/main/codex/codex-app-server-connection-types.ts +++ b/src/main/codex/codex-app-server-connection-types.ts @@ -13,8 +13,6 @@ export type CodexAppServerConnectionHandlers = { onExit?: (error: Error, exit?: { expected: boolean }) => void /** Awaited once the child has a pid and before the handshake; a rejection reaps the child. */ onSpawned?: (pid: number) => Promise - /** Any stdout or stderr chunk from the child. */ - onOutput?: () => void } export type CodexAppServerConnection = { diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index fcabeea5f6b1..0f475a71a693 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -45,8 +45,7 @@ async function connectCodexAppServer( ): Promise { const managed = spawnManagedProviderProcess(launch, { spawnImpl, - site: 'codex-app-server-teardown', - ...(handlers.onOutput ? { onOutput: handlers.onOutput } : {}) + site: 'codex-app-server-teardown' }) const { child, terminateTree: terminateProcessTree } = managed diff --git a/src/main/codex/codex-app-server-handshake.ts b/src/main/codex/codex-app-server-handshake.ts index 27e2ce6c71bc..9c89d653baa6 100644 --- a/src/main/codex/codex-app-server-handshake.ts +++ b/src/main/codex/codex-app-server-handshake.ts @@ -2,11 +2,8 @@ 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, - timeoutMs = HANDSHAKE_TIMEOUT_MS + connection: CodexAppServerConnection ): Promise { await connection.request( 'initialize', @@ -19,7 +16,7 @@ export async function initializeCodexAppServerConnection( extensions: {} } }, - { timeoutMs } + { timeoutMs: HANDSHAKE_TIMEOUT_MS } ) connection.notify('initialized') } diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 872e0a153cc2..326845271cfb 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -154,7 +154,6 @@ export async function acquireCodexStructuredSession(input: { Buffer.byteLength(JSON.stringify(payload ?? null), 'utf8') ), onSpawned: spawnIdentity.onSpawned, - ...(acquireInput.onOutput ? { onOutput: acquireInput.onOutput } : {}), onExit: (error, exit) => { try { if (attempt.acquisitionGeneration && !sessions.has(sessionId)) { diff --git a/src/main/codex/codex-structured-session-start.ts b/src/main/codex/codex-structured-session-start.ts index 77b133219344..6ee37cbf8597 100644 --- a/src/main/codex/codex-structured-session-start.ts +++ b/src/main/codex/codex-structured-session-start.ts @@ -32,7 +32,6 @@ import { type CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' import type { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' -import { STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' type CodexOpenContext = { input: StructuredAgentSessionAcquireInput @@ -76,10 +75,7 @@ export async function openCodexStructuredSession( assertCodexConnectionOpen(connection, sessionId) } current() - await waitForPromiseWithSignal( - initializeCodexAppServerConnection(connection, STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS), - openInput.signal - ) + await waitForPromiseWithSignal(initializeCodexAppServerConnection(connection), openInput.signal) current() const threadLaunch = await waitForPromiseWithSignal( withCodexVisualsThreadConfig(connection, launch, { @@ -90,12 +86,7 @@ export async function openCodexStructuredSession( ) current() const opened = await waitForPromiseWithSignal( - openCodexThread( - connection, - threadLaunch, - STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS, - openInput.signal - ), + openCodexThread(connection, threadLaunch, deps.requestTimeoutMs, openInput.signal), openInput.signal ) current() diff --git a/src/main/jsonl-rpc/agent-connection.ts b/src/main/jsonl-rpc/agent-connection.ts index a194660f7b70..d9c5667d9617 100644 --- a/src/main/jsonl-rpc/agent-connection.ts +++ b/src/main/jsonl-rpc/agent-connection.ts @@ -19,8 +19,6 @@ import { resolveJsonlRpcPeerOptions, type JsonlRpcPeerOptions } from './peer-lim export type JsonlRpcAgentConnectionOptions = JsonlRpcPeerHandlers & { peer?: JsonlRpcPeerOptions onExit?: (error: Error, context: { expected: boolean; exit: ProviderProcessExit }) => void - /** Any stdout or stderr chunk from the child. */ - onOutput?: () => void } /** The owning runtime keeps process exit evidence separate from transport closure. */ @@ -39,8 +37,7 @@ export class JsonlRpcAgentConnection { const peerOptions = resolveJsonlRpcPeerOptions(options.peer) this.managed = spawnManagedProviderProcess(launch, { spawnImpl, - site: 'jsonl-rpc-agent-teardown', - ...(options.onOutput ? { onOutput: options.onOutput } : {}) + site: 'jsonl-rpc-agent-teardown' }) const managed = this.managed this.peer = new JsonlRpcPeer( 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 6e419540d95f..dac32c5e34ec 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 @@ -63,7 +63,6 @@ export async function acquireOwner( const progress = input.onStartupAttempt?.(attempt) const acquired = await input.adapter.acquire({ ...attempt, - ...(progress ? { onOutput: progress.output } : {}), ...(input.recordPhase ? { recordPhase: input.recordPhase } : {}), onSpawned: async (process) => { progress?.spawned() 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 20706683112c..f45d9bdddfc7 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 @@ -79,7 +79,7 @@ export type AttachFlowInput = { /** The id the host registered this start under; see `StructuredAgentSessionStartupAttempts`. */ startupAttemptId?: string /** The attempt this attach's acquire runs under, minted before the adapter is called; what it - * answers learns of the child's spawn and output. */ + * answers learns of the child's spawn. */ onStartupAttempt?: ( attempt: StructuredAgentSessionStartupAttempt ) => StructuredAgentSessionStartupProgress 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 0b1274784332..f822c33be9e0 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 @@ -7,15 +7,12 @@ import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child- import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' -/** A spawned child with no stdout frame or stderr line for this long has stopped starting. */ -export const STRUCTURED_AGENT_SESSION_STARTUP_SILENCE_MS = 60_000 -/** No start, however chatty, outlives this from its spawn. */ +/** No start outlives this from its registration; each protocol step has its own shorter bound. */ export const STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS = 10 * 60_000 -export type StructuredAgentSessionStartupLimits = { silenceMs: number; ceilingMs: number } +export type StructuredAgentSessionStartupLimits = { ceilingMs: number } export const STRUCTURED_AGENT_SESSION_STARTUP_LIMITS: StructuredAgentSessionStartupLimits = { - silenceMs: STRUCTURED_AGENT_SESSION_STARTUP_SILENCE_MS, ceilingMs: STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } @@ -39,8 +36,6 @@ export type StructuredAgentSessionStartupAttempt = { /** Aborted by a close, a Stop admitted now, quit, or the startup limit: the adapter stops what it * started and spawn fails. */ readonly signal?: AbortSignal - /** Any stdout frame or stderr line from the child: proof the start is still moving. */ - readonly onOutput?: () => void } /** The startup limit's own reason, so an expired start is told from a close, Stop or quit. */ 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 3e558d1c32d7..797de829d3fb 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 @@ -318,10 +318,13 @@ describe('a start that ends before it proves itself', () => { expect(startupAttemptOpen()).toBe(false) }) - it('stops a published start that goes silent and fails what it held', async () => { - startupLimits = { silenceMs: 50 } + it('stops a published start that never readies at the ceiling, not before, and fails what it held', async () => { + startupLimits = { ceilingMs: 400 } await restartWith(publishFirst) const id = await heldBehindStart('hello') + await vi.advanceTimersByTimeAsync(200) + expect(closeSession).not.toHaveBeenCalled() + expect(startupAttemptOpen()).toBe(true) await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected'), 50) expect(await submission(id)).toMatchObject(HOST_STOPPED) @@ -331,33 +334,8 @@ describe('a start that ends before it proves itself', () => { expect(startupAttemptOpen()).toBe(false) }) - it('keeps a slow start that is still talking, and stops one that never readies at the ceiling', async () => { - startupLimits = { silenceMs: 50, ceilingMs: 400 } - const chatter: ReturnType[] = [] - await restartWith(async (input) => { - await input.onSpawned?.(acquisition(input).process) - chatter.push(setInterval(() => input.onOutput?.(), 10)) - return acquisition(input) - }) - try { - const id = await heldBehindStart('hello') - await vi.advanceTimersByTimeAsync(200) - expect(closeSession).not.toHaveBeenCalled() - expect(startupAttemptOpen()).toBe(true) - - await eventually( - async () => expect((await submission(id))?.dispatchState).toBe('rejected'), - 50 - ) - expect(await submission(id)).toMatchObject(HOST_STOPPED) - expect(dispatch).not.toHaveBeenCalled() - } finally { - chatter.forEach(clearInterval) - } - }) - - it('aborts an acquire still in its handshake once it goes silent and fails what it held', async () => { - startupLimits = { silenceMs: 50 } + it('aborts an acquire still in its handshake at the ceiling and fails what it held', async () => { + startupLimits = { ceilingMs: 50 } await restartWith( (input) => new Promise((_resolve, reject) => { @@ -395,7 +373,7 @@ describe('a start that ends before it proves itself', () => { }) it('is not ended by the limit once it proved itself', async () => { - startupLimits = { silenceMs: 50 } + startupLimits = { ceilingMs: 50 } await restartWith(publishFirst) const id = await heldBehindStart('hello') await proveOpen() @@ -473,7 +451,7 @@ function startupAttemptOpen(): boolean { } describe('the attempt an acquire runs under', () => { - it('is minted by the host with the lease, the pinned launch and its progress report', async () => { + it('is minted by the host with the lease and the pinned launch', async () => { await restartWith(publishFirst) await heldBehindStart('hello') @@ -483,7 +461,6 @@ describe('the attempt an acquire runs under', () => { fence: record.lease.runtimeFence, launch: { location: record.location, accountHome: record.accountHome } }) - expect(input.onOutput).toEqual(expect.any(Function)) expect(input.attemptId).toEqual(expect.any(String)) expect(input.signal).toBeInstanceOf(AbortSignal) }) 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 cd1bf63a09c8..f2eb584eb08d 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 @@ -138,8 +138,7 @@ describe.each( expect(acquire.mock.calls[0]?.[0]).toMatchObject({ attemptId: expect.any(String), fence: record.lease.runtimeFence, - launch: { location: record.location, accountHome: record.accountHome }, - onOutput: expect.any(Function) + launch: { location: record.location, accountHome: record.accountHome } }) const body = hostTestMessage('hello') 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 49381d97e8f1..b5154de4580d 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 @@ -59,17 +59,15 @@ export type StructuredAgentSessionExpiredStartup = { /** How one spawned start ended, for tuning the limits from what agents really take. */ export type StructuredAgentSessionStartupSettled = { agent: AgentSessionJournalIdentity['agent'] - outcome: 'ready' | 'silent' | 'ceiling' | 'ended' + outcome: 'ready' | 'ceiling' | 'ended' /** From spawn to the outcome. */ durationMs: number } /** What the host learns about a start while spawn runs. */ export type StructuredAgentSessionStartupProgress = { - /** The process exists: the clock starts. */ + /** The process exists: its start is measured from here. */ spawned: () => void - /** Any output; output before a spawn report also starts the clock. */ - output: () => void } type Tracked = { @@ -80,7 +78,6 @@ type Tracked = { /** The ceiling counts from here: the start's registration, before any of its waits. */ registeredAt: number spawnedAt: number | null - lastOutputAt: number timer: ReturnType | null expired: boolean /** The limit's measure, held until an aborted spawn settles: one that is ready anyway @@ -125,7 +122,6 @@ export class StructuredAgentSessionStartupAttempts { child: null, registeredAt: this.now(), spawnedAt: null, - lastOutputAt: 0, timer: null, expired: false, expiry: null, @@ -152,19 +148,8 @@ export class StructuredAgentSessionStartupAttempts { return { spawned: () => { if (current()) { - this.startClock(sessionId, tracked) + this.markSpawned(tracked) } - }, - output: () => { - if (!current()) { - return - } - if (tracked.spawnedAt === null) { - this.startClock(sessionId, tracked) - return - } - // Read when the timer fires, so a chatty child costs no timer churn. - tracked.lastOutputAt = this.now() } } } @@ -204,9 +189,7 @@ export class StructuredAgentSessionStartupAttempts { return } // A child published without a spawn report has existed since at least now. - if (tracked.spawnedAt === null && !this.disposed) { - this.startClock(sessionId, tracked) - } + this.markSpawned(tracked) } /** Whether `child`'s start is still on its clock: false once its limit passed, so a `started` @@ -263,23 +246,12 @@ export class StructuredAgentSessionStartupAttempts { this.open.clear() } - private startClock(sessionId: string, tracked: Tracked): void { - if (tracked.spawnedAt !== null) { - return - } - tracked.spawnedAt = this.now() - tracked.lastOutputAt = tracked.spawnedAt - this.schedule(sessionId, tracked) + private markSpawned(tracked: Tracked): void { + tracked.spawnedAt ??= this.now() } private schedule(sessionId: string, tracked: Tracked): void { - if (tracked.timer) { - clearTimeout(tracked.timer) - } - const due = Math.min( - tracked.spawnedAt === null ? Infinity : tracked.lastOutputAt + this.limits.silenceMs, - tracked.registeredAt + this.limits.ceilingMs - ) + const due = tracked.registeredAt + this.limits.ceilingMs tracked.timer = setTimeout(() => this.check(sessionId, tracked), Math.max(0, due - this.now())) // A start's clock must never be the reason a process stays alive at quit. tracked.timer.unref?.() @@ -290,18 +262,15 @@ export class StructuredAgentSessionStartupAttempts { if (this.open.get(sessionId) !== tracked || this.disposed) { return } - const now = this.now() - if (now - tracked.registeredAt >= this.limits.ceilingMs) { + if (this.now() - tracked.registeredAt >= this.limits.ceilingMs) { this.expire(sessionId, tracked, 'ceiling') - } else if (tracked.spawnedAt !== null && now - tracked.lastOutputAt >= this.limits.silenceMs) { - this.expire(sessionId, tracked, 'silent') } else { this.schedule(sessionId, tracked) } } /** `reason` null: a start whose limit already passed inside spawn was published now. */ - private expire(sessionId: string, tracked: Tracked, reason: 'silent' | 'ceiling' | null): void { + private expire(sessionId: string, tracked: Tracked, reason: 'ceiling' | null): void { if (this.open.get(sessionId) !== tracked || this.disposed) { return } diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts index ca335b156a47..ca00673b5ae3 100644 --- a/src/main/pi/rpc-session.ts +++ b/src/main/pi/rpc-session.ts @@ -126,7 +126,6 @@ export class PiRpcSession { this.connection = ( deps.openConnection ?? ((spec, handlers) => new JsonlRpcAgentConnection(spec, handlers)) )(launch, { - ...(input.onOutput ? { onOutput: input.onOutput } : {}), onRecord: (frame) => frame.type === 'extension_ui_request' ? this.dialogs.receive(frame) diff --git a/src/main/provider-process/managed-provider-process-output.test.ts b/src/main/provider-process/managed-provider-process-output.test.ts deleted file mode 100644 index 8a140eec29ea..000000000000 --- a/src/main/provider-process/managed-provider-process-output.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { PassThrough } from 'node:stream' -import { describe, expect, it, vi } from 'vitest' -import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' -import { spawnManagedProviderProcess } from './managed-provider-process' - -function launchWithOutput(onOutput: () => void) { - const child = Object.assign(createFakePipedChild(), { - pid: 9_999_999, - stdin: new PassThrough(), - stdout: new PassThrough(), - stderr: new PassThrough(), - kill: vi.fn(() => true) - }) - const managed = spawnManagedProviderProcess( - { command: 'fixture-provider', args: [] }, - { - spawnImpl: () => child, - platform: 'win32', - site: 'fixture-provider-teardown', - onOutput - } - ) - return { child, managed } -} - -const tick = (): Promise => new Promise((resolve) => setImmediate(resolve)) - -describe('managed provider process output', () => { - it('reports stderr and stdout chunks as output', async () => { - const onOutput = vi.fn() - const { child } = launchWithOutput(onOutput) - child.stdout.on('data', () => {}) - await tick() - - child.stderr.write('migrating\n') - child.stdout.write('{"id":1}\n') - await tick() - - expect(onOutput).toHaveBeenCalledTimes(2) - }) - - it('never starts stdout before its reader subscribes, so a late reader loses nothing', async () => { - const onOutput = vi.fn() - const { child } = launchWithOutput(onOutput) - child.stdout.write('early frame\n') - await tick() - expect(onOutput).not.toHaveBeenCalled() - - const read: string[] = [] - child.stdout.setEncoding('utf8').on('data', (chunk: string) => read.push(chunk)) - await tick() - - expect(read.join('')).toBe('early frame\n') - expect(onOutput).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/provider-process/managed-provider-process.ts b/src/main/provider-process/managed-provider-process.ts index c37783f1bb37..9ec42e81a56b 100644 --- a/src/main/provider-process/managed-provider-process.ts +++ b/src/main/provider-process/managed-provider-process.ts @@ -42,8 +42,6 @@ type ManagedProviderProcessOptions = { acceptClose?: (result: ProviderProcessCloseResult) => boolean /** Defaults to `session`; a one-shot's stdin end completes its request instead of stopping it. */ lifetime?: ProviderSupervisorLifetime - /** Any stdout or stderr chunk: the child is doing something. */ - onOutput?: () => void } export type ManagedProviderProcess = { @@ -104,11 +102,7 @@ export function spawnManagedProviderProcess( // An undrained stderr pipe blocks the child once it fills. child.stderr.setEncoding('utf8').on('data', (chunk: string) => { stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_CHARS) - options.onOutput?.() }) - if (options.onOutput) { - observeReaderOutput(child.stdout, options.onOutput) - } let resolveExit = (): void => {} const exitPromise = new Promise((resolve) => { resolveExit = resolve @@ -194,20 +188,3 @@ export function spawnManagedProviderProcess( } } } - -/** Watches stdout only once its reader subscribes: a listener of our own would start the stream - * flowing and drop whatever arrived before a reader that subscribes late. */ -function observeReaderOutput( - stdout: Pick, - onOutput: () => void -): void { - const onSubscribe = (event: string | symbol): void => { - if (event !== 'data' && event !== 'readable') { - return - } - stdout.removeListener('newListener', onSubscribe) - // After the reader's own listener lands; no chunk can arrive before a microtask runs. - queueMicrotask(() => stdout.on('data', onOutput)) - } - stdout.on('newListener', onSubscribe) -} 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 99bf996bc874..03b58ea6af23 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 @@ -1,5 +1,6 @@ -// 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. +// Each step of a Codex start has its own bound: an `initialize` that never answers fails the start +// at 15 s with the startup-failure words, and the host ends the child; one that answers in time +// starts as usual. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -14,6 +15,7 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { recordAgentSessionStartup } from '../observability/agent-session-instrumentation' +import { readWholeAgentSessionFailureFact } from '../../shared/agent-session-failure' import type { ScriptedAgentChild } from './structured-agent-scripted-child.test-fixture' import { ensureStructuredAgentSessionHost, @@ -90,10 +92,10 @@ 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 () => { +it('delivers the held message to a Codex that answers initialize within its bound', async () => { const child = codexScriptedChild() await chatHoldingAMessage(child) - await vi.advanceTimersByTimeAsync(20_000) + await vi.advanceTimersByTimeAsync(14_000) expect(child.closes()).toBe(0) child.releaseHandshake() @@ -101,3 +103,19 @@ 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('fails the held message with the startup-failure words when initialize passes its bound', async () => { + const child = codexScriptedChild() + const host = await chatHoldingAMessage(child) + await vi.advanceTimersByTimeAsync(15_000) + + await vi.waitFor(async () => { + await host.flushStreamedEvents(HOST_TEST_SESSION) + const [held] = (await host.journalSnapshot(HOST_TEST_SESSION)).submissions + expect(held?.dispatchState).toBe('rejected') + expect(readWholeAgentSessionFailureFact(held?.rejection)).toMatchObject({ kind: 'startFailed' }) + }) + await vi.waitFor(() => expect(child.closes()).toBe(1)) + expect(child.prompts()).toEqual([]) + expect(child.spawns()).toBe(1) +}) From db963f4a3b122fd051286fef396ca043a1c19e46 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:50:18 -0700 Subject: [PATCH 104/127] Count Pi ready once it reports its state Pi's start no longer waits for its model listing and slash commands. The open returns after get_state and the unknown-provider check; the listing then arrives as an options report (with the catalog listing the start resolved) and the commands as a later publish. An empty model list no longer fails the start as signed out: a signed-out Pi runs the unknown provider, which still fails it, and a prompt it cannot run is refused with the sign-in words by the existing prompt path. get_state keeps the connection's 30 s request bound instead of the startup ceiling. --- src/main/pi/pi-scripted-child.test-fixture.ts | 5 +- src/main/pi/rpc-session-adapter.test.ts | 2 +- src/main/pi/rpc-session-startup.test.ts | 67 +++++++++++++---- src/main/pi/rpc-session-startup.ts | 74 ++++++++----------- src/main/pi/rpc-session.ts | 7 ++ 5 files changed, 97 insertions(+), 58 deletions(-) diff --git a/src/main/pi/pi-scripted-child.test-fixture.ts b/src/main/pi/pi-scripted-child.test-fixture.ts index 5c6c4d38dec1..f1852ac95149 100644 --- a/src/main/pi/pi-scripted-child.test-fixture.ts +++ b/src/main/pi/pi-scripted-child.test-fixture.ts @@ -29,7 +29,7 @@ class ScriptedPiConnection implements PiRpcConnection { private readonly wire: string[], private readonly onClosed: () => void, number: number, - /** A Pi with no login lists no model. */ + /** A Pi with no login runs the unknown provider and lists no model. */ private readonly signedOut = false ) { this.pid = 42_000 + number @@ -183,7 +183,8 @@ class ScriptedPiConnection implements PiRpcConnection { private state() { return { sessionFile: this.file, - model: this.signedOut ? null : this.model, + // As a signed-out Pi reports it (capture `signed-out.jsonl`). + model: this.signedOut ? { provider: 'unknown', id: 'unknown', reasoning: false } : this.model, thinkingLevel: this.effort, isStreaming: this.streaming, isCompacting: false, diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 75f7966f139d..683c59460c5f 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -126,7 +126,7 @@ describe('Pi RPC session ownership and delivery', () => { mintedAtFence: 7 }) expect(h.connection.requests).not.toContain('set_thinking_level') - expect(h.connection.requests).toContain('get_commands') + await vi.waitFor(() => expect(h.connection.requests).toContain('get_commands')) }) it('admits a send before Pi confirms it and settles only after journal acceptance', async () => { diff --git a/src/main/pi/rpc-session-startup.test.ts b/src/main/pi/rpc-session-startup.test.ts index 1395a6f8a58d..584e8020e926 100644 --- a/src/main/pi/rpc-session-startup.test.ts +++ b/src/main/pi/rpc-session-startup.test.ts @@ -1,13 +1,23 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { closePiRpcAdapterFixtures, file, + reportedEvent, restart, sessionId, setup, state } from './rpc-session-adapter.test-fixture' +function openAt8(h: Awaited>, acquisitionGeneration: string) { + return h.adapter.open({ + sessionId, + fence: 8, + acquisitionGeneration, + signal: new AbortController().signal + }) +} + afterEach(closePiRpcAdapterFixtures) describe('Pi host awaited open', () => { @@ -54,22 +64,53 @@ describe('Pi host awaited open', () => { await expect(opening).rejects.toThrow('lane failed during startup') }) - it('teaches the account default only from a new session started with no pick', async () => { + it('is ready once Pi reports its state; the listing follows, and an empty one fails nothing', async () => { const h = await setup() - expect(h.started.catalogListing?.configuredDefault).toEqual({ - modelId: 'anthropic/model-1', - effort: null + const listing = Promise.withResolvers() + const { acquired, child } = await restart(h, (connection) => { + connection.requestOverride = (command) => + command === 'get_available_models' ? listing.promise : undefined + }) + await expect(openAt8(h, acquired.acquisitionGeneration)).resolves.toMatchObject({ + reportedOptions: { model: 'anthropic/model-1' } }) + expect(h.lifecycle).not.toHaveBeenCalled() + listing.resolve({ models: [] }) + await vi.waitFor(() => expect(reportedEvent(h.lifecycle, 'options-reported').fence).toBe(8)) + expect(child.closed).toBe(false) + await vi.waitFor(() => + expect(h.adapter.readCommands(sessionId)).toMatchObject([{ name: 'help' }]) + ) + }) + + it('fails the start when Pi runs the unknown provider', async () => { + const h = await setup() + const unknown = { ...state, model: { provider: 'unknown', id: 'unknown' } } + const { acquired } = await restart(h, (connection) => { + connection.requestOverride = (command) => + command === 'get_state' ? Promise.resolve(unknown) : undefined + }) + await expect(openAt8(h, acquired.acquisitionGeneration)).rejects.toMatchObject({ + reason: 'notSignedIn' + }) + }) + + it('teaches the account default only from a new session started with no pick', async () => { + const h = await setup() + await vi.waitFor(() => + expect( + reportedEvent(h.lifecycle, 'options-reported').catalogListing?.configuredDefault + ).toEqual({ modelId: 'anthropic/model-1', effort: null }) + ) h.resolveLaunch.mockResolvedValueOnce({ ...(await h.resolveLaunch()), sessionFile: file }) const { acquired } = await restart(h, () => {}) - const resumed = await h.adapter.open({ - sessionId, - fence: 8, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, - signal: new AbortController().signal - }) - expect(resumed.catalogListing).toBeDefined() - expect(resumed.catalogListing).not.toHaveProperty('configuredDefault') + await openAt8(h, acquired.acquisitionGeneration) + await vi.waitFor(() => + expect(reportedEvent(h.lifecycle, 'options-reported').catalogListing).toBeDefined() + ) + expect(reportedEvent(h.lifecycle, 'options-reported').catalogListing).not.toHaveProperty( + 'configuredDefault' + ) }) }) diff --git a/src/main/pi/rpc-session-startup.ts b/src/main/pi/rpc-session-startup.ts index e4db9740f20a..ae00361e90ba 100644 --- a/src/main/pi/rpc-session-startup.ts +++ b/src/main/pi/rpc-session-startup.ts @@ -12,23 +12,15 @@ import { import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import { unpickedSessionConfiguredChoice, - withLiveCatalogListing, - type AgentModelCatalogLiveListing + withLiveCatalogListing } from '../native-chat/agent-model-catalog/agent-model-catalog-entry' import { piRpcProviderLink, type PiRpcResolvedLaunch } from './rpc-launch-resolution' import { piModelOptionId, readPiRpcCommands, readPiRpcSessionOptions } from './rpc-options' import { piRpcStateSchema, type PiRpcState } from './rpc-protocol' import type { PiRpcConnection, PiRpcSessionDeps } from './rpc-session' -import { STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' - -/** What the start cannot go on without waits as long as the host's startup attempt does, its only - * deadline; the optional reads keep their own bound and never fail the start. */ -const START_REQUEST = { timeoutMs: STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } export class PiRpcSessionStartup { commands?: AgentSessionSlashCommand[] - /** What the start listed; a signed-out Pi lists no model. */ - options?: AgentSessionOptionsResult constructor( private readonly input: StructuredAgentSessionAcquireInput, @@ -40,11 +32,12 @@ export class PiRpcSessionStartup { private readonly failedCause: () => Error | undefined ) {} + /** Ready once Pi reports its state: only an unknown provider fails the start. */ async run( launch: PiRpcResolvedLaunch, signal: AbortSignal ): Promise { - const answer = await this.wait(this.connection.request('get_state', {}, START_REQUEST), signal) + const answer = await this.wait(this.connection.request('get_state', {}), signal) this.assertLive(signal) const state = piRpcStateSchema.parse(answer) if (state.model?.provider === 'unknown') { @@ -64,51 +57,48 @@ export class PiRpcSessionStartup { } this.assertLive(signal) current.confirmed = [...(current.model ? ['model'] : []), ...(current.effort ? ['effort'] : [])] - // Part of the start, as other agents' handshakes answer with theirs: a Pi that lists no model - // is signed out, and what it started on names its config's default. - const options = await this.readListing(signal) - this.assertLive(signal) - if (options && options.models.length === 0) { - throw this.notSignedIn() - } - // What it started on names its config's default once; a later switch teaches nothing. - let catalogListing: AgentModelCatalogLiveListing | undefined - if (options) { - this.options = options - catalogListing = withLiveCatalogListing( + return { link, reportedOptions: current } + } + + /** After the open: the listing and the `/` commands reach the host as later reports, and a read + * that fails only leaves the chat without them. */ + async readAfterOpen(generation: string): Promise { + const options = await this.readListing() + if (options && !this.failedCause() && !this.connection.closed) { + // What it started on names its config's default once; a later switch teaches nothing. + const { catalogListing } = withLiveCatalogListing( options, - unpickedSessionConfiguredChoice({ - resolvesConfig: this.resolvesConfig, - ...options - }) - ).catalogListing - } - await this.readCommands(signal) - this.assertLive(signal) - return { - link, - reportedOptions: options?.current ?? current, - ...(catalogListing ? { catalogListing } : {}) + unpickedSessionConfiguredChoice({ resolvesConfig: this.resolvesConfig, ...options }) + ) + this.deps.onLifecycle({ + type: 'options-reported', + sessionId: this.input.identity.sessionId, + fence: this.input.fence, + acquisitionGeneration: generation, + reportedOptions: options.current, + ...(catalogListing ? { catalogListing } : {}) + }) } + await this.readCommands() } - /** Never fails the start: without it the chat only lacks the listing and the sign-in verdict. */ - private async readListing(signal: AbortSignal): Promise { + private async readListing(): Promise { try { - return await this.wait(readPiRpcSessionOptions(this.connection), signal) + return await readPiRpcSessionOptions(this.connection) } catch (error) { - this.assertLive(signal) this.warn('Pi options could not be read', 'pi-options', error) return undefined } } - private async readCommands(signal: AbortSignal): Promise { + private async readCommands(): Promise { try { - this.assertLive(signal) - const result = await this.wait(readPiRpcCommands(this.connection), signal) - this.assertLive(signal) + const result = await readPiRpcCommands(this.connection) + if (this.failedCause() || this.connection.closed) { + return + } this.commands = result.commands + this.input.events?.publish() } catch (error) { this.warn('Pi commands could not be read', 'pi-commands', error) } diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts index ca00673b5ae3..9764821c0319 100644 --- a/src/main/pi/rpc-session.ts +++ b/src/main/pi/rpc-session.ts @@ -164,6 +164,13 @@ export class PiRpcSession { ...(result.reportedOptions.effort ? { effort: result.reportedOptions.effort } : {}) } this.openedLink = result.link ?? null + void this.startup.readAfterOpen(this.generation).catch((error: unknown) => + this.deps.logger.warn('Pi startup reports failed', { + scope: 'pi-startup-reports', + sessionId: this.input.identity.sessionId, + error + }) + ) return result } From c8b861fff23c964787299a8cb6076d881271c97b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:14:21 -0700 Subject: [PATCH 105/127] Test that model and effort picked while a chat is created ride its first message A slow host's create reply arrives after the user picked a model and effort. The picker's view layers the launch's held picks over its seed and the host's saved selection, and every send carries what the picker shows, so the first message carries the picks whether the host's status names its selection or not, and while the last pick is still on its way to the host. --- ...ured-agent-session-creation-picks.test.tsx | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-creation-picks.test.tsx diff --git a/src/renderer/src/components/native-chat/structured-agent-session-creation-picks.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-creation-picks.test.tsx new file mode 100644 index 000000000000..c9e418a57b9f --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-creation-picks.test.tsx @@ -0,0 +1,264 @@ +// @vitest-environment happy-dom + +import { act, renderHook, waitFor } from '@testing-library/react' +import { useSyncExternalStore } from 'react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { HostCreateSupport } from '@/lib/structured-agent-session-host-admission' + +const mocks = vi.hoisted(() => ({ + call: vi.fn<(target: unknown, method: string, params: unknown) => Promise>() +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + supportsStructuredAgentSessionCreateMessage: async () => true +})) +vi.mock('@/lib/structured-agent-session-host-admission', () => ({ + askHostCreateSupport: async (): Promise => ({ kind: 'admitted' }) +})) +vi.mock('@/runtime/structured-session-tab-inventory', () => ({ + readStructuredSessionTabInventory: async () => [] +})) +vi.mock('@/runtime/structured-agent-session-host-capability', async (importOriginal) => ({ + ...(await importOriginal()), + useStructuredAgentSessionHostCapability: () => true +})) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + // The account's saved pick, which create seeds. + settings: { nativeChatSessionOptions: { claude: { model: 'sonnet' } } }, + unifiedTabsByWorktree: {}, + seedNativeChatLaunchDraft: vi.fn(), + clearNativeChatLaunchDraft: vi.fn() + }), + subscribe: () => () => {} + } +})) +vi.mock('./native-chat-session-option-settings-write', () => ({ + enqueueSessionOptionSettingsWrite: vi.fn() +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +import { + CreateIntentParams, + SendParams, + SetOptionParams +} from '../../../../shared/rpc-contract/structured-agent-session-params' +import type { AgentSessionHistoryPage } from '../../../../shared/agent-session-wire' +import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' +import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' +import { resetStructuredAgentLaunchRegistryForTests } from '@/lib/structured-agent-session-launch-registry' +import { resetStructuredAgentLaunchPersistenceForTests } from '@/lib/structured-agent-session-launch-persistence' +import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' +import { + recordHostModelCatalogSnapshot, + resetHostModelCatalogSnapshotsForTests +} from '@/runtime/host-model-catalog-snapshots' +import { + getStructuredAgentSessionReadOwner, + resetStructuredAgentSessionReadOwnersForTests +} from './structured-agent-session-read-owner' +import { resetStructuredAgentSessionSendsForTests } from './structured-agent-session-message-sender' +import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' +import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' +import { useStructuredAgentSessionSends } from './use-structured-agent-session-sends' + +const target = { kind: 'local' } as const +const efforts = ['low', 'high'].map((value) => ({ value, label: value })) +const LISTING = { + origin: 'probe', + fetchedAt: 1, + models: [ + { id: 'sonnet', label: 'Sonnet', isDefault: true, defaultEffort: 'low', efforts }, + { id: 'opus', label: 'Opus', defaultEffort: 'low', efforts } + ] +} as const + +function shown(snapshot: readonly SessionOptionDescriptor[], id: string): string | undefined { + const kind = snapshot.find((entry) => entry.id === id)?.kind + return kind?.type === 'select' ? kind.currentValue : undefined +} + +function deferred() { + let resolve = (_value: T): void => {} + const promise = new Promise((yes) => { + resolve = yes + }) + return { promise, resolve } +} + +function createdReply(sessionId: string) { + const cursor = { epoch: 'epoch-1', sequence: 0 } + const page: AgentSessionHistoryPage = { + sessionId, + epoch: cursor.epoch, + fence: 1, + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { oldest: null, newest: null, nextCursor: cursor }, + liveCursor: cursor, + hasOlder: false, + hasNewer: false, + latestTurn: null + } + return { ok: true, replayed: false, value: { sessionId, fence: 1, page } } +} + +/** The new chat's pane: its launch, its picker and its composer, as the session view wires them. */ +function useNewChat( + sessionId: string, + optionSelection: Readonly> | undefined +) { + const { launch, transportEnabled } = useNativeChatProvisionalLaunch('workspace-1', sessionId) + const owner = getStructuredAgentSessionReadOwner(sessionId, target) + const fence = useSyncExternalStore(owner.subscribe, () => owner.getSnapshot().state.fence) + const options = useStructuredAgentSessionOptions({ + agent: 'claude', + sessionId, + target, + transportEnabled, + isVisible: true, + providerVisible: transportEnabled, + ...(optionSelection ? { optionSelection } : {}), + fence: transportEnabled ? fence : null, + turnId: null, + unloadedTurnRevisions: undefined, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every pick here is made before publish, which the launch holds; mutate is never called. + mutate: vi.fn() as unknown as StructuredAgentSessionMutate, + ...(launch ? { launch } : {}) + }) + const sends = useStructuredAgentSessionSends({ + sessionId, + target, + fence: transportEnabled ? fence : null, + submissions: [], + queuedMessageIds: [], + queue: { capability: 'supported', enabled: false }, + historyLoaded: false, + executionOptionSnapshot: options.takesSendOptions ? options.optionSnapshot : undefined + }) + return { transportEnabled, options, sends } +} + +beforeEach(() => { + localStorage.clear() + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + resetStructuredAgentSessionSendsForTests() + resetStructuredAgentSessionReadOwnersForTests() + resetHostModelCatalogSnapshotsForTests() + recordHostModelCatalogSnapshot(target, 'claude', null, LISTING) +}) +afterEach(() => { + resetStructuredAgentSessionSendsForTests() + resetStructuredAgentSessionReadOwnersForTests() + mocks.call.mockReset() +}) + +// A slow host: the chat exists, and its status shows the create's selection, before create replies. +// The real host saves a pick, publishes its status, then replies with every saved value. +it.each([ + { host: 'an older host', statusCarriesSelection: false, sendDuringReplay: false }, + { + host: 'a host whose status shows its selection', + statusCarriesSelection: true, + sendDuringReplay: false + }, + { + host: 'a host still taking the last pick', + statusCarriesSelection: true, + sendDuringReplay: true + } +])( + 'sends the model and effort picked while the chat was being created, on $host', + async (timing) => { + const create = deferred() + const lastPick = deferred() + const sent: unknown[] = [] + let saved: Record = {} + const status: { selection?: Readonly>; listeners: Set<() => void> } = { + listeners: new Set() + } + const publish = (): void => { + if (timing.statusCarriesSelection) { + status.selection = { ...saved } + } + status.listeners.forEach((listener) => listener()) + } + mocks.call.mockImplementation(async (_target, method, params) => { + if (method === 'agentSession.create') { + saved = { ...CreateIntentParams.parse(params).options } + publish() + return create.promise + } + if (method === 'agentSession.setOption') { + const { key, value } = SetOptionParams.parse(params) + if (key === 'effort' && timing.sendDuringReplay) { + await lastPick.promise + } + saved = { ...saved, [key]: value } + act(publish) + return { ok: true, value: { key, value, options: saved } } + } + if (method === 'agentSession.modelCatalog') { + return LISTING + } + if (method === 'agentSession.send') { + sent.push(params) + } + return new Promise(() => {}) + }) + const launch = startStructuredAgentLaunch('workspace-1', 'claude', { + requestId: newAgentLaunchRequestId(), + executionHostId: 'local' + }) + await waitFor(() => expect(saved).toEqual({ model: 'sonnet' })) + const { result, unmount } = renderHook(() => + useNewChat( + launch.sessionId, + useSyncExternalStore( + (listener) => { + status.listeners.add(listener) + return () => status.listeners.delete(listener) + }, + () => status.selection + ) + ) + ) + await waitFor(() => + expect(shown(result.current.options.optionSnapshot, 'model')).toBe('sonnet') + ) + + await act(async () => { + expect(await result.current.options.setStructuredOption('model', 'opus')).toBe(true) + }) + await act(async () => { + expect(await result.current.options.setStructuredOption('effort', 'high')).toBe(true) + }) + create.resolve(createdReply(launch.sessionId)) + await act(async () => { + await launch.launchResult + }) + await waitFor(() => expect(result.current.transportEnabled).toBe(true)) + await waitFor(() => + expect( + mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.setOption') + ).toHaveLength(2) + ) + await waitFor(() => expect(saved.effort === 'high').toBe(!timing.sendDuringReplay)) + + // Never re-seeded over: the picker still shows the picks, and the first message carries them. + expect(shown(result.current.options.optionSnapshot, 'model')).toBe('opus') + expect(shown(result.current.options.optionSnapshot, 'effort')).toBe('high') + act(() => { + expect(result.current.sends.send('first message')).toBe(true) + }) + await waitFor(() => expect(sent).toHaveLength(1)) + expect(SendParams.parse(sent[0]).options).toMatchObject({ model: 'opus', effort: 'high' }) + lastPick.resolve() + unmount() + } +) From 75902c422cb3b75a72b8b7ab5dcae446e599d1c5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:23:38 -0700 Subject: [PATCH 106/127] Start a phone's new empty chat on its first message The phone's new chat is an agent.launch with no prompt, which always created the chat with its agent started, while desktop chats start on the first message. The phone now advertises the create-without-start capability, and a launch with no prompt from a caller advertising it creates the chat at rest, keyed in the ledger by its create intent as a client's create is. Launches with a prompt, the CLI (which advertises nothing), orchestration workers and ensure still start at once; an older host ignores the capability and stays eager. --- .../mobile-runtime-client-capabilities.ts | 5 +- .../agent-launch/agent-launch-executor.ts | 3 +- .../agent-launch-surface-factories.ts | 3 + .../rpc/methods/agent-launch-at-rest.test.ts | 87 +++++++++++++++++++ .../rpc/methods/agent-launch-surfaces.ts | 10 ++- .../structured-agent-session-create.ts | 11 ++- 6 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.ts b/mobile/src/transport/mobile-runtime-client-capabilities.ts index 35543ac9476e..a5b8977a85f6 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.ts @@ -17,6 +17,7 @@ import { } from '../../../src/shared/agent-launch-runtime-capability' import { AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY } from '../../../src/shared/agent-session-optional-model-capability' import { remoteRuntimeClientCapabilities } from '../../../src/shared/remote-runtime-client-capabilities' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilities([ STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, @@ -42,7 +43,9 @@ export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilitie // Reads `agent_launch_tab_closed` (a user closed its tab, which stopped it) as a definite answer. AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY, // Native chat offers no Stop for a turn the host restored but could not confirm. - AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY + AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY, + // A new empty chat waits at rest and its first message starts the agent, as on desktop. + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY ]) export const MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD = diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index d2f08d1730d6..20671190bcd5 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -285,7 +285,8 @@ async function createSurface( agent: intent.agent, ...(intent.sessionOptions ? { options: intent.sessionOptions } : {}), ...(intent.sessionId ? { sessionId: intent.sessionId } : {}), - ...(reservedTabId ? { tabId: reservedTabId } : {}) + ...(reservedTabId ? { tabId: reservedTabId } : {}), + ...(intent.prompt ? {} : { withoutPrompt: true as const }) }) return { outcome: { diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index 93160d4b72a4..a5a0224122ab 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -22,6 +22,9 @@ export type AgentLaunchSurfaceFactory = { /** The caller-reserved tab id (the tab half of `paneKey`), so the host records where this chat * is shown; absent records the id clients derive. */ tabId?: string + /** The launch carries no prompt, so a caller that sends the first message itself may get the + * chat at rest, its agent started by that message. */ + withoutPrompt?: true }): Promise createTerminalAgent(args: { worktreeId: string diff --git a/src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts b/src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts new file mode 100644 index 000000000000..e7efee5de51a --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts @@ -0,0 +1,87 @@ +import '../unused-default-rpc-methods.test-fixture' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' +import { + createRestTestRig, + type RestTestRig +} from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { HOST_TEST_NOW } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { RpcContext } from '../core' +import { methodNamed, rpcContext, runtimeStub } from './agent-launch.test-fixture' + +const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') +const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') + +// The phone: it renders either surface and sends a new chat's first message itself. +const PHONE: Partial = { + clientKind: 'mobile', + pairedDeviceId: 'device-1', + clientCapabilities: [ + AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY + ] +} +// The `orca` CLI carries no capability list. +const CLI: Partial = {} +const SESSION = 'codex_9b1deb4d_3b7d_4bad_9bdd_2b0d7b3dcb6d' + +let rig: RestTestRig +beforeEach(async () => { + // The launch mints its operation id from the wall clock; the rig's host reads a fixed one. + vi.useFakeTimers({ now: HOST_TEST_NOW, toFake: ['Date'] }) + rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) + setStructuredAgentSessionHost(rig.host) +}) +afterEach(async () => { + setStructuredAgentSessionHost(null) + await rig.dispose() + vi.useRealTimers() +}) + +function launch(caller: Partial, prompt?: string) { + const runtime = { + ...runtimeStub(), + resolveStructuredAgentSessionCreateIntent: vi.fn(async (params: { envelope: unknown }) => ({ + envelope: params.envelope, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' }, + options: { model: 'gpt-5.6-sol', effort: 'medium' }, + runtimeKind: 'native' + })), + publishStructuredAgentSessionTab: vi.fn(async () => {}) + } + const params = AGENT_LAUNCH.params.parse({ + agent: 'codex', + target: { kind: 'existing', worktree: 'id:wt-7' }, + sessionId: SESSION, + ...(prompt ? { prompt: { text: prompt, delivery: 'submit' } } : {}) + }) + return AGENT_LAUNCH.handler(params, rpcContext(runtime, caller)) +} + +it.each([ + { name: 'a bare launch from the phone waits', caller: PHONE, startsAtCreation: false }, + { + name: 'a launch from the phone with a prompt starts', + caller: PHONE, + prompt: 'fix the build', + startsAtCreation: true + }, + { name: 'a bare launch from the CLI starts', caller: CLI, startsAtCreation: true } +])('$name', async ({ caller, prompt, startsAtCreation }) => { + const result = await launch(caller, prompt) + + expect(result.outcome).toMatchObject({ kind: 'structured', sessionId: SESSION }) + expect(rig.store.getRecord(SESSION)).toBeDefined() + // At rest, nothing is spawned until the chat's first message. + expect(rig.adapter.acquire.mock.calls.length > 0).toBe(startsAtCreation) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index e689978ee984..7993a6a6da84 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -30,6 +30,7 @@ import { createStructuredAgentSessionForWorktree } from './structured-agent-sess import { commitStructuredAgentSessionLaunchPrompt } from './agent-launch-structured-prompt' import { deliverTerminalAgentLaunchPrompt } from './agent-launch-terminal-prompt' import { AgentLaunchSessionAlreadyExistsError } from '../../../../shared/agent-launch-session-already-exists' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' import { createStructuredAgentSessionId } from '../../../../shared/structured-agent-session-create' import { toAgentLaunchPreferences } from '../../../../shared/agent-launch-preferences' import { paneIdentity } from '../../runtime-terminal-pane-identity' @@ -64,10 +65,16 @@ export function agentLaunchSurfaceFactory( agent, options, sessionId: requested, - tabId + tabId, + withoutPrompt }) => { const sessionId = requested ?? createStructuredAgentSessionId(agent, randomUUID) const seeded = narrowStructuredLaunchSeedOptions(options) + // A client that creates chats without starting them starts this one on its first message. + const atRest = + withoutPrompt === true && + context.clientCapabilities?.includes(AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY) === + true const created = await createStructuredAgentSessionForWorktree({ runtime: context.runtime, ensureHost: async () => { @@ -91,6 +98,7 @@ export function agentLaunchSurfaceFactory( agent, ...(seeded ? { options: seeded } : {}), ...(tabId ? { tabId } : {}), + ...(atRest ? { atRest: true as const } : {}), activate: !callerPresentsSurface && (presentation === undefined || (presentation === 'focused' && movesHostWindow)) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index 0f0ca0d752c0..6c236bcc632d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -242,10 +242,19 @@ export async function createStructuredAgentSessionForWorktree(args: { activate: boolean options?: Readonly> tabId?: string + /** Create without starting the agent; its first message starts it. */ + atRest?: true }): Promise> { + // An at-rest create's ledger row is keyed by its intent, as a client's create is. + const envelope = args.atRest + ? { + ...args.envelope, + payloadFingerprint: structuredAgentSessionCreateIntentFingerprint(args) + } + : args.envelope const prepared: PreparedStructuredAgentSessionCreate | StructuredCreateRefused = await resolveUncommittedStructuredCreate(() => - prepareStructuredAgentSessionCreateForWorktree(args) + prepareStructuredAgentSessionCreateForWorktree({ ...args, envelope }) ) if ('refusal' in prepared) { return { ok: false, refusal: prepared.refusal } From 750e11f6e7907d0c1585171989d58c969a465178 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:38:17 -0700 Subject: [PATCH 107/127] Take main's lockfile; the branch changes no dependencies --- pnpm-lock.yaml | 54 +++++++++++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f55ac3965e14..3f649d9eb423 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,7 +172,7 @@ patchedDependencies: '@xterm/addon-search@0.17.0-beta.301': eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0 '@xterm/addon-serialize@0.15.0-beta.301': b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f '@xterm/addon-webgl@0.20.0-beta.300': 5349718f2c19d9319a81552580bf9618ded9b00f3707a5c6cdbbae1910849cd9 - '@xterm/xterm@6.1.0-beta.304': c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff + '@xterm/xterm@6.1.0-beta.304': 87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156 i18next-cli@1.74.2: 2ee32eef742615cd133e1a6940f027deb3723a8430bb7953bba3a6769b0aac47 lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673 monaco-editor@0.55.1: 53b71f7d8a7c00b76ffdb73ce83d18e31692f193f5fafa6a390e48f5b3fdd636 @@ -204,7 +204,7 @@ importers: version: 2.5.6 '@xterm/addon-serialize': specifier: 0.15.0-beta.301 - version: 0.15.0-beta.301(patch_hash=b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff)) + version: 0.15.0-beta.301(patch_hash=b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f)(@xterm/xterm@6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156)) '@xterm/headless': specifier: 6.1.0-beta.303 version: 6.1.0-beta.303 @@ -415,28 +415,28 @@ importers: version: 1.18.0 '@xterm/addon-fit': specifier: 0.12.0-beta.301 - version: 0.12.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff)) + version: 0.12.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156)) '@xterm/addon-image': specifier: 0.10.0-beta.301 - version: 0.10.0-beta.301(patch_hash=f088c9207dac42844a542b9da8078be52351cee60585725ef189b9fd30f33f34)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff)) + version: 0.10.0-beta.301(patch_hash=f088c9207dac42844a542b9da8078be52351cee60585725ef189b9fd30f33f34)(@xterm/xterm@6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156)) '@xterm/addon-ligatures': specifier: 0.11.0-beta.301 - version: 0.11.0-beta.301(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff)) + version: 0.11.0-beta.301(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156)) '@xterm/addon-search': specifier: 0.17.0-beta.301 - version: 0.17.0-beta.301(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff)) + version: 0.17.0-beta.301(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156)) '@xterm/addon-unicode11': specifier: 0.10.0-beta.301 - version: 0.10.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff)) + version: 0.10.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156)) '@xterm/addon-web-links': specifier: 0.13.0-beta.301 - version: 0.13.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff)) + version: 0.13.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156)) '@xterm/addon-webgl': specifier: 0.20.0-beta.300 - version: 0.20.0-beta.300(patch_hash=5349718f2c19d9319a81552580bf9618ded9b00f3707a5c6cdbbae1910849cd9)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff)) + version: 0.20.0-beta.300(patch_hash=5349718f2c19d9319a81552580bf9618ded9b00f3707a5c6cdbbae1910849cd9)(@xterm/xterm@6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156)) '@xterm/xterm': specifier: 6.1.0-beta.304 - version: 6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff) + version: 6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -10923,43 +10923,43 @@ snapshots: '@xmldom/xmldom@0.8.15': {} - '@xterm/addon-fit@0.12.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))': + '@xterm/addon-fit@0.12.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156))': dependencies: - '@xterm/xterm': 6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff) + '@xterm/xterm': 6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156) - '@xterm/addon-image@0.10.0-beta.301(patch_hash=f088c9207dac42844a542b9da8078be52351cee60585725ef189b9fd30f33f34)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))': + '@xterm/addon-image@0.10.0-beta.301(patch_hash=f088c9207dac42844a542b9da8078be52351cee60585725ef189b9fd30f33f34)(@xterm/xterm@6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156))': dependencies: - '@xterm/xterm': 6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff) + '@xterm/xterm': 6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156) - '@xterm/addon-ligatures@0.11.0-beta.301(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))': + '@xterm/addon-ligatures@0.11.0-beta.301(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156))': dependencies: - '@xterm/xterm': 6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff) + '@xterm/xterm': 6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156) lru-cache: 11.5.1 opentype.js: 2.0.0 - '@xterm/addon-search@0.17.0-beta.301(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))': + '@xterm/addon-search@0.17.0-beta.301(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156))': dependencies: - '@xterm/xterm': 6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff) + '@xterm/xterm': 6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156) - '@xterm/addon-serialize@0.15.0-beta.301(patch_hash=b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))': + '@xterm/addon-serialize@0.15.0-beta.301(patch_hash=b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f)(@xterm/xterm@6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156))': dependencies: - '@xterm/xterm': 6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff) + '@xterm/xterm': 6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156) - '@xterm/addon-unicode11@0.10.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))': + '@xterm/addon-unicode11@0.10.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156))': dependencies: - '@xterm/xterm': 6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff) + '@xterm/xterm': 6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156) - '@xterm/addon-web-links@0.13.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))': + '@xterm/addon-web-links@0.13.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156))': dependencies: - '@xterm/xterm': 6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff) + '@xterm/xterm': 6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156) - '@xterm/addon-webgl@0.20.0-beta.300(patch_hash=5349718f2c19d9319a81552580bf9618ded9b00f3707a5c6cdbbae1910849cd9)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))': + '@xterm/addon-webgl@0.20.0-beta.300(patch_hash=5349718f2c19d9319a81552580bf9618ded9b00f3707a5c6cdbbae1910849cd9)(@xterm/xterm@6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156))': dependencies: - '@xterm/xterm': 6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff) + '@xterm/xterm': 6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156) '@xterm/headless@6.1.0-beta.303': {} - '@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff)': {} + '@xterm/xterm@6.1.0-beta.304(patch_hash=87de83f62154c0f2c31eb17d14b881e139f3368f8be1e9e2951bfeba16d86156)': {} abbrev@4.0.0: {} From d76a6af7dad93d09be6f6b2e713472cee25b6a26 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:46:28 -0700 Subject: [PATCH 108/127] Restore main's exact text in files the merge only reformatted The pre-commit formatter rewrote 37 of main's files staged by the merge, though this branch never changed them, pushing two past the line limit. Committed without the hook because the hook would reformat main's text again; CI lints the result. --- .../ai-vault/session-scanner-discovery.ts | 5 +- src/main/ai-vault/structured-chat-cli-fork.ts | 8 +- src/main/grok/grok-transcript-import.ts | 6 +- .../adopted-transcript-source.ts | 4 +- .../journal-legacy-import.ts | 18 +- ...tructured-agent-session-client-delivery.ts | 3 +- ...tructured-agent-session-open-child-work.ts | 6 +- .../structured-agent-session-status-feed.ts | 3 +- ...red-agent-session-status-ownership.test.ts | 1 + ...ructured-agent-session-status-ownership.ts | 5 +- .../structured-agent-session-turns.test.ts | 25 +- ...red-conversation-command-admission.test.ts | 28 +- .../native-chat/codex-adoption-preparation.ts | 20 +- src/main/native-chat/session-file-resolver.ts | 15 +- .../structured-agent-cli-conversations.ts | 5 +- .../structured-agent-cli-source.ts | 14 +- ...red-agent-session-source-admission.test.ts | 291 +++++------------- .../transcript-opencode-sqlite-query.test.ts | 4 +- .../transcript-opencode-sqlite-query.ts | 8 +- src/main/omp/omp-session-cwd-lookup.test.ts | 7 +- .../agent-launch-scope-runtime.test.ts | 10 +- ...ctured-agent-session-create-intent.test.ts | 26 +- .../methods/managed-runtime-install.test.ts | 4 +- .../rpc/methods/managed-runtime-install.ts | 5 +- src/main/runtime/runtime-pane-admission.ts | 16 +- .../structured-session-create-support.ts | 18 +- ...tructured-agent-session-create-adoption.ts | 43 +-- .../mobile-terminal-chat-fork-create.ts | 121 ++------ .../launch/mobile-terminal-chat-fork.test.ts | 276 ++++++----------- .../launch/mobile-terminal-chat-fork.ts | 9 +- .../launch/mobile-terminal-owned-create.ts | 6 +- .../launch/mobile-terminal-renderer-create.ts | 7 +- .../terminal-presentation-ipc-bridge.ts | 7 +- .../session-tabs-schemas-params.ts | 31 +- .../session-tabs-runtime-capabilities.ts | 3 +- .../structured-agent-session-create.test.ts | 31 +- .../structured-agent-session-mutation.ts | 5 +- 37 files changed, 311 insertions(+), 783 deletions(-) diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index 61f0fe311c43..353cfb34b3e3 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -296,10 +296,7 @@ export async function forEachSessionFile( options.signal?.throwIfAborted() // Why: a gate refusal means the scan could not run, not that the tree is // empty — swallowing it would misreport a stalled distro as "no transcript". - if ( - error instanceof WslTranscriptFsError || - (options.strictErrors && !isMissingSidecarError(error)) - ) { + if (error instanceof WslTranscriptFsError || (options.strictErrors && !isMissingSidecarError(error))) { throw error } return diff --git a/src/main/ai-vault/structured-chat-cli-fork.ts b/src/main/ai-vault/structured-chat-cli-fork.ts index de35351f6479..aeed4b694b4a 100644 --- a/src/main/ai-vault/structured-chat-cli-fork.ts +++ b/src/main/ai-vault/structured-chat-cli-fork.ts @@ -57,14 +57,10 @@ function assertNativeConversationContent(snapshot: AgentJournalSnapshot): void { } /** A slow preparation must not launch from a chat that left this workspace. */ -export function isStructuredChatCliForkSourceCurrent( - sessionId: string, - workspaceId: string -): boolean { +export function isStructuredChatCliForkSourceCurrent(sessionId: string, workspaceId: string): boolean { const record = getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId) return Boolean( - record && - record.location.workspaceId === workspaceId && + record && record.location.workspaceId === workspaceId && record.location.executionHostId === LOCAL_EXECUTION_HOST_ID && record.location.wslDistro === null ) diff --git a/src/main/grok/grok-transcript-import.ts b/src/main/grok/grok-transcript-import.ts index 5a40aa6744b7..dab984da73f9 100644 --- a/src/main/grok/grok-transcript-import.ts +++ b/src/main/grok/grok-transcript-import.ts @@ -11,10 +11,6 @@ export const GROK_TRANSCRIPT_IMPORT = directoryTranscriptImport({ resolveGrokHomeDir() ], findTranscript: async ({ providerSessionId, store, workspaceCwd, strictErrors }) => - (await findGrokChatHistoryByCwd({ - sessionId: providerSessionId, - cwd: workspaceCwd, - sessionsDir: store - })) ?? + (await findGrokChatHistoryByCwd({ sessionId: providerSessionId, cwd: workspaceCwd, sessionsDir: store })) ?? resolveSessionFilePath('grok', providerSessionId, { grokSessionsDir: store, strictErrors }) }) diff --git a/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts b/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts index af66dcce2df9..7cdedc6c12e1 100644 --- a/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts +++ b/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts @@ -36,9 +36,7 @@ export function adoptedTranscriptSourceObserver(input: { maxSourceBytes: number }) { let sessionId: string | null = - input.agent === 'claude' || input.agent === 'codex' - ? sessionIdFromFileName(input.filePath) - : null + input.agent === 'claude' || input.agent === 'codex' ? sessionIdFromFileName(input.filePath) : null let cwd: string | null = null let sawCodexMetadata = false return { diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index 232db1502832..80ad7ab6bfd1 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -10,11 +10,7 @@ // never be spliced into a sequence space that a structured session is also // writing; a later structured resume rolls the epoch again and rebuilds. -import { - adoptedTranscriptSourceObserver, - validateAdoptedOpenCodeSource, - type AdoptedTranscriptSource -} from './adopted-transcript-source' +import { adoptedTranscriptSourceObserver, validateAdoptedOpenCodeSource, type AdoptedTranscriptSource } from './adopted-transcript-source' import { createReadStream } from 'node:fs' import type { AgentType } from '../../../shared/agent-status-types' import type { @@ -219,14 +215,10 @@ async function decodeReplacementItems(input: { source?: AdoptedTranscriptSource limits: JournalPayloadLimits }): Promise<{ items: JournalReplacementItem[]; consumedBytes: number }> { - const source = input.source - ? adoptedTranscriptSourceObserver({ - agent: input.agent, - filePath: input.filePath, - source: input.source, - maxSourceBytes: MAX_LEGACY_IMPORT_RECORD_BYTES - }) - : null + const source = input.source ? adoptedTranscriptSourceObserver({ + agent: input.agent, filePath: input.filePath, source: input.source, + maxSourceBytes: MAX_LEGACY_IMPORT_RECORD_BYTES + }) : null const tracker = createLegacyIdentityTracker({ transcriptAgent: input.transcriptAgent, agent: input.agent, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index ee79bb9a170b..dea1e11227ef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -125,7 +125,8 @@ export class StructuredAgentSessionClientDelivery { captureChildWorkStops = ( sessionId: string, handleIds: readonly string[] - ): CapturedChildWorkStops => this.statusFeed.captureChildWorkStops(sessionId, handleIds) + ): CapturedChildWorkStops => + this.statusFeed.captureChildWorkStops(sessionId, handleIds) childWorkIncomplete = (sessionId: string): boolean => this.statusFeed.childWorkIncomplete(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-open-child-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-open-child-work.ts index e088b10dee73..78883ce6beac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-open-child-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-open-child-work.ts @@ -45,11 +45,7 @@ export class StructuredAgentSessionOpenChildWork { return session?.open.has(handleId) ? session.history.get(handleId)?.token : undefined } - matches( - sessionId: string, - handleId: string, - captured: ChildWorkLifetimeToken | undefined - ): boolean { + matches(sessionId: string, handleId: string, captured: ChildWorkLifetimeToken | undefined): boolean { return this.capture(sessionId, handleId) === captured } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 1dff7203f012..cc070c28289f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -316,7 +316,8 @@ export class StructuredAgentSessionStatusFeed { captureChildWorkStops = ( sessionId: string, handleIds: readonly string[] - ): CapturedChildWorkStops => this.ownership.captureChildWorkStops(sessionId, handleIds) + ): CapturedChildWorkStops => + this.ownership.captureChildWorkStops(sessionId, handleIds) /** Reads the store, so a failing read costs only the strip's note, as `readChildWork`'s does. */ childWorkIncomplete = (sessionId: string): boolean => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts index 03fba788aabf..8eee1b2c213e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.test.ts @@ -309,4 +309,5 @@ describe('child work the provider reported, recorded or not', () => { expect(owner.childWorkIncomplete(summary.sessionId)).toBe(true) expect(captured.current('helper')).toBe(false) }) + }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts index 381b91cf5a5a..38cd0a8925dc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts @@ -108,7 +108,10 @@ export class StructuredAgentSessionStatusOwnership { } /** A Stop's callback closes only lifetimes captured before the provider call can await. */ - captureChildWorkStops(sessionId: string, handleIds: readonly string[]): CapturedChildWorkStops { + captureChildWorkStops( + sessionId: string, + handleIds: readonly string[] + ): CapturedChildWorkStops { const captured = new Map() for (const handleId of handleIds) { captured.set(handleId, this.open.capture(sessionId, handleId)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 138fe64a07ba..8fca2c00c808 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -298,12 +298,10 @@ describe('performCancel', () => { const accepting = new Promise((resolve) => { acceptStop = resolve }) - const stopBackgroundTasks = vi.fn( - async (request: { acknowledge: (value: { taskId: string }) => void }) => { - request.acknowledge({ taskId: 'helper' }) - return { cancelled: true } - } - ) + const stopBackgroundTasks = vi.fn(async (request: { acknowledge: (value: { taskId: string }) => void }) => { + request.acknowledge({ taskId: 'helper' }) + return { cancelled: true } + }) const ctx = acceptedStop({ logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', @@ -322,14 +320,8 @@ describe('performCancel', () => { const stopping = runTargetedCancel(ctx, { clientOperationId: 'stop-earlier-lifetime', scope: 'background-tasks', - childWork: () => [ - liveTask('helper', { invocation: { invocationId: 'spawn-a', generation: 1 } }) - ], - captureStops: (targets) => - owner.captureChildWorkStops( - 'session-1', - targets.map((target) => target.providerId) - ), + childWork: () => [liveTask('helper', { invocation: { invocationId: 'spawn-a', generation: 1 } })], + captureStops: (targets) => owner.captureChildWorkStops('session-1', targets.map((target) => target.providerId)), acknowledgeStops: (evidence) => owner.publishChildWork('session-1', evidence, 'claude') }) expect(stopBackgroundTasks).not.toHaveBeenCalled() @@ -422,10 +414,7 @@ describe('performCancel', () => { scope: 'background-tasks', childWork: () => owner.readChildWork('session-1'), captureStops: (targets) => - owner.captureChildWorkStops( - 'session-1', - targets.map((target) => target.providerId) - ), + owner.captureChildWorkStops('session-1', targets.map((target) => target.providerId)), acknowledgeStops: (evidence) => owner.publishChildWork('session-1', evidence, 'claude'), unlisted: { present: () => owner.childWorkIncomplete('session-1'), stop: stopAgent } }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts index f7ccaaa0595b..6ecdb1d0ddad 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts @@ -92,23 +92,18 @@ describe('conversationCommandBlocked background work', () => { it('waits on running work the store has no record of', () => { const unrecorded = { views: [SETTLED], open: true } - expect(conversationCommandBlocked(contextWith(TARGETED), RECORD, unrecorded)?.message).toBe( - WAIT - ) + expect(conversationCommandBlocked(contextWith(TARGETED), RECORD, unrecorded)?.message).toBe(WAIT) }) it('asks for a stop when the strip offers one on a live row', () => { expect( - conversationCommandBlocked(contextWith(TARGETED), RECORD, reading([SETTLED, child()])) - ?.message + conversationCommandBlocked(contextWith(TARGETED), RECORD, reading([SETTLED, child()]))?.message ).toBe(STOP) }) it('asks for a stop when the strip offers its single untargeted stop', () => { const live = child({ stoppable: false }) - expect( - conversationCommandBlocked(contextWith(UNTARGETED), RECORD, reading([live]))?.message - ).toBe(STOP) + expect(conversationCommandBlocked(contextWith(UNTARGETED), RECORD, reading([live]))?.message).toBe(STOP) }) it('asks the user to wait when no live row on the strip has a stop', () => { @@ -116,23 +111,18 @@ describe('conversationCommandBlocked background work', () => { const unstoppable = child({ stoppable: false }) const unaddressable = child({ id: 'child-2', providerId: undefined }) expect( - conversationCommandBlocked( - contextWith(TARGETED), - RECORD, - reading([unstoppable, unaddressable]) - )?.message + conversationCommandBlocked(contextWith(TARGETED), RECORD, reading([unstoppable, unaddressable])) + ?.message ).toBe(WAIT) }) it('asks the user to wait when the provider exposes no stop at all', () => { // Codex: an instruction to stop would name a control that does not exist. - expect( - conversationCommandBlocked(contextWith(NO_STOP), RECORD, reading([child()]))?.message - ).toBe(WAIT) + expect(conversationCommandBlocked(contextWith(NO_STOP), RECORD, reading([child()]))?.message).toBe(WAIT) // No live provider session answers for it either. - expect( - conversationCommandBlocked(contextWith(undefined), RECORD, reading([child()]))?.message - ).toBe(WAIT) + expect(conversationCommandBlocked(contextWith(undefined), RECORD, reading([child()]))?.message).toBe( + WAIT + ) }) it('still refuses on the open turn, not on the work the strip now shows', () => { diff --git a/src/main/native-chat/codex-adoption-preparation.ts b/src/main/native-chat/codex-adoption-preparation.ts index 8ce69292a0f4..0e86904abdd9 100644 --- a/src/main/native-chat/codex-adoption-preparation.ts +++ b/src/main/native-chat/codex-adoption-preparation.ts @@ -21,16 +21,13 @@ export async function prepareCodexAdoption( return source } const nativeSystemHomePath = getSystemCodexHomePath() - const result = await prepare( - { - agent: 'codex', - sessionId: providerSessionId, - filePath: source.transcriptPath, - codexHome: source.accountHome.path, - executionHostId: 'local' - }, - nativeSystemHomePath - ) + const result = await prepare({ + agent: 'codex', + sessionId: providerSessionId, + filePath: source.transcriptPath, + codexHome: source.accountHome.path, + executionHostId: 'local' + }, nativeSystemHomePath) const home = result.useRealCodexHome ? nativeSystemHomePath : result.substituteCodexHome if ( !home || @@ -44,8 +41,7 @@ export async function prepareCodexAdoption( !recognizedHomes.some( (candidate) => isLegacyAgentSessionAccountHome(candidate) && - normalizeRuntimePathForComparison(candidate.path) === - normalizeRuntimePathForComparison(home) + normalizeRuntimePathForComparison(candidate.path) === normalizeRuntimePathForComparison(home) ) ) { throw new Error('The prepared conversation account is not recognized on this host.') diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 435e8bf0fe00..49d4ab5869e6 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -224,12 +224,7 @@ async function resolveTiered( agent: SessionIdLookupAgent, sessionId: string, hostDirs: readonly string[], - options: { - wslTier: boolean - accountDirs?: () => readonly string[] - signal?: AbortSignal - strictErrors?: boolean - } + options: { wslTier: boolean; accountDirs?: () => readonly string[]; signal?: AbortSignal; strictErrors?: boolean } ): Promise { const { signal } = options const hit = await findFirstInDirs(agent, sessionId, hostDirs, signal, options.strictErrors) @@ -237,13 +232,7 @@ async function resolveTiered( return hit } const accountDirs = (options.accountDirs?.() ?? []).filter((dir) => !hostDirs.includes(dir)) - const accountHit = await findFirstInDirs( - agent, - sessionId, - accountDirs, - signal, - options.strictErrors - ) + const accountHit = await findFirstInDirs(agent, sessionId, accountDirs, signal, options.strictErrors) if (accountHit || !options.wslTier) { return accountHit } diff --git a/src/main/native-chat/structured-agent-cli-conversations.ts b/src/main/native-chat/structured-agent-cli-conversations.ts index 864d52d87e73..c56e77add999 100644 --- a/src/main/native-chat/structured-agent-cli-conversations.ts +++ b/src/main/native-chat/structured-agent-cli-conversations.ts @@ -53,10 +53,7 @@ export function absoluteConversationStore(path: string | null | undefined): stri } export function directoryTranscriptImport(directories: { - conversationStore: (input: { - accountHomePath: string - launchEnv: NodeJS.ProcessEnv - }) => string | null + conversationStore: (input: { accountHomePath: string; launchEnv: NodeJS.ProcessEnv }) => string | null accountHomeCandidates: (input: { settings: StructuredAgentTranscriptImportSettings selectedAccountHomePath: string diff --git a/src/main/native-chat/structured-agent-cli-source.ts b/src/main/native-chat/structured-agent-cli-source.ts index bd7b8b055283..244a84dd3157 100644 --- a/src/main/native-chat/structured-agent-cli-source.ts +++ b/src/main/native-chat/structured-agent-cli-source.ts @@ -20,14 +20,12 @@ export function captureStructuredAgentCliSource( try { return Object.freeze({ rows: Object.freeze( - readRows().map((row) => - Object.freeze({ - ...row, - ...(row.providerSession - ? { providerSession: Object.freeze({ ...row.providerSession }) } - : {}) - }) - ) + readRows().map((row) => Object.freeze({ + ...row, + ...(row.providerSession + ? { providerSession: Object.freeze({ ...row.providerSession }) } + : {}) + })) ) }) } catch (error) { diff --git a/src/main/native-chat/structured-agent-session-source-admission.test.ts b/src/main/native-chat/structured-agent-session-source-admission.test.ts index bcdd5ff723d0..3474e02b1fc4 100644 --- a/src/main/native-chat/structured-agent-session-source-admission.test.ts +++ b/src/main/native-chat/structured-agent-session-source-admission.test.ts @@ -31,74 +31,30 @@ async function file(path: string, records: unknown[]) { } function params(agent: StructuredAgentId, transcriptPath: string): AgentSessionAttachParams { return { - envelope: { - sessionId: 'new-chat', - clientOperationId: 'create', - expectedRuntimeFence: null, - payloadFingerprint: '' - }, - location: { - executionHostId: 'local', - workspaceId: 'folder', - workspaceKind: 'folder', - wslDistro: null - }, - agent, - provider: agent, - runtimeKind: 'native', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, - adopt: { - providerHandle: { agent, transport: 'terminal-transcript', nativeId: id }, - transcriptPath, - source: { providerSessionId: id, workspaceCwd: root } - } + envelope: { sessionId: 'new-chat', clientOperationId: 'create', expectedRuntimeFence: null, payloadFingerprint: '' }, + location: { executionHostId: 'local', workspaceId: 'folder', workspaceKind: 'folder', wslDistro: null }, + agent, provider: agent, runtimeKind: 'native', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, + adopt: { providerHandle: { agent, transport: 'terminal-transcript', nativeId: id }, transcriptPath, + source: { providerSessionId: id, workspaceCwd: root } } } } it('creates from the host hook path when Claude filename differs, then checks actual recorded cwd', async () => { const transcriptPath = await file(join(root, 'projects', 'folder', 'different-uuid.jsonl'), [ - { - type: 'user', - uuid: 'first', - sessionId: id, - cwd: root, - message: { role: 'user', content: 'hello' } - }, - { - type: 'assistant', - uuid: 'reply', - sessionId: id, - cwd: '/later-drift', - message: { role: 'assistant', content: 'reply' } - } + { type: 'user', uuid: 'first', sessionId: id, cwd: root, message: { role: 'user', content: 'hello' } }, + { type: 'assistant', uuid: 'reply', sessionId: id, cwd: '/later-drift', message: { role: 'assistant', content: 'reply' } } ]) - const sourcePath = () => - structuredAgentCliSourcePath({ - agent: 'claude', - workspaceId: 'folder', - resumeFrom: { - providerSessionId: id, - cliSource: { workspaceId: 'folder', paneKey: 'tab:leaf' } - }, - rows: [ - { - paneKey: 'tab:leaf', - worktreeId: 'folder', - agentType: 'claude', - state: 'waiting', - prompt: '', - connectionId: null, - receivedAt: 1, - stateStartedAt: 1, - providerSession: { key: 'session_id', id, transcriptPath } - } - ] - }) + const sourcePath = () => structuredAgentCliSourcePath({ + agent: 'claude', workspaceId: 'folder', resumeFrom: { providerSessionId: id, + cliSource: { workspaceId: 'folder', paneKey: 'tab:leaf' } }, + rows: [{ paneKey: 'tab:leaf', worktreeId: 'folder', agentType: 'claude', state: 'waiting', prompt: '', + connectionId: null, receivedAt: 1, stateStartedAt: 1, + providerSession: { key: 'session_id', id, transcriptPath } }] + }) const importer = CLAUDE_CLI_CONVERSATIONS.transcriptImport! const selectedAccountHome = { variable: 'CLAUDE_CONFIG_DIR', path: root } vi.spyOn(importer, 'accountHomeCandidates').mockReturnValue([ - selectedAccountHome, - { ...selectedAccountHome, path: join(root, 'locked-account') } + selectedAccountHome, { ...selectedAccountHome, path: join(root, 'locked-account') } ]) const admit = importer.admitTranscriptPath.bind(importer) const admission = vi.spyOn(importer, 'admitTranscriptPath').mockImplementation((args) => { @@ -109,62 +65,36 @@ it('creates from the host hook path when Claude filename differs, then checks ac }) const find = vi.spyOn(importer, 'findTranscript') const adoption = await resolveStructuredAgentSessionAdoptionForCreate({ - settings: {}, - agent: 'claude', - transcriptImport: importer, - providerSessionId: id, + settings: {}, agent: 'claude', transcriptImport: importer, providerSessionId: id, selectedAccountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, - launchEnv: {}, - workspaceCwd: root, - resolveTranscriptPath: sourcePath + launchEnv: {}, workspaceCwd: root, resolveTranscriptPath: sourcePath }) expect(adoption.transcriptPath).toBe(transcriptPath) expect(admission).toHaveBeenCalledOnce() expect(find).not.toHaveBeenCalled() - expect(await prepareAdoptedTranscript(params('claude', adoption.transcriptPath))).toMatchObject({ - ok: true - }) + expect(await prepareAdoptedTranscript(params('claude', adoption.transcriptPath))).toMatchObject({ ok: true }) const wrongDirectory = params('claude', adoption.transcriptPath) if (wrongDirectory.adopt?.source) { wrongDirectory.adopt.source.workspaceCwd = '/another-folder' } - expect(await prepareAdoptedTranscript(wrongDirectory)).toMatchObject({ - ok: false, - refusal: { details: { reason: 'transcriptUnreadable' } } - }) + expect(await prepareAdoptedTranscript(wrongDirectory)).toMatchObject({ ok: false, + refusal: { details: { reason: 'transcriptUnreadable' } } }) const foreign = await file(join(root, 'foreign.jsonl'), []) const escaped = process.platform === 'win32' ? foreign : join(root, 'projects', 'escaped.jsonl') if (process.platform !== 'win32') { await symlink(foreign, escaped) } - await expect( - resolveStructuredAgentSessionAdoptionForCreate({ - settings: {}, - agent: 'claude', - transcriptImport: importer, - providerSessionId: id, - selectedAccountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, - launchEnv: {}, - workspaceCwd: root, - resolveTranscriptPath: () => escaped - }) - ).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptUnreadable' } } }) - admission.mockRejectedValueOnce( - Object.assign(new Error('Selected account inaccessible'), { code: 'EACCES' }) - ) - await expect( - resolveStructuredAgentSessionAdoptionForCreate({ - settings: {}, - agent: 'claude', - transcriptImport: importer, - providerSessionId: id, - selectedAccountHome, - launchEnv: {}, - workspaceCwd: root, - resolveTranscriptPath: sourcePath - }) - ).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptUnreadable' } } }) + await expect(resolveStructuredAgentSessionAdoptionForCreate({ + settings: {}, agent: 'claude', transcriptImport: importer, providerSessionId: id, + selectedAccountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, + launchEnv: {}, workspaceCwd: root, resolveTranscriptPath: () => escaped + })).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptUnreadable' } } }) + admission.mockRejectedValueOnce(Object.assign(new Error('Selected account inaccessible'), { code: 'EACCES' })) + await expect(resolveStructuredAgentSessionAdoptionForCreate({ + settings: {}, agent: 'claude', transcriptImport: importer, providerSessionId: id, + selectedAccountHome, launchEnv: {}, workspaceCwd: root, resolveTranscriptPath: sourcePath + })).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptUnreadable' } } }) expect(find).not.toHaveBeenCalled() }) @@ -177,13 +107,8 @@ it('judges the lookup and its re-admission against one store resolved once per a const find = vi.spyOn(importer, 'findTranscript') const admission = vi.spyOn(importer, 'admitTranscriptPath') const adoption = await resolveStructuredAgentSessionAdoptionForCreate({ - settings: {}, - agent: 'claude', - transcriptImport: importer, - providerSessionId: id, - selectedAccountHome, - launchEnv: {}, - workspaceCwd: root + settings: {}, agent: 'claude', transcriptImport: importer, providerSessionId: id, + selectedAccountHome, launchEnv: {}, workspaceCwd: root }) expect(adoption.transcriptPath).toBe(transcriptPath) expect(store).toHaveBeenCalledOnce() @@ -191,45 +116,29 @@ it('judges the lookup and its re-admission against one store resolved once per a expect(admission.mock.calls[0]?.[0].store).toBe(join(root, 'projects')) }) -it.skipIf(process.platform === 'win32')( - 'reports a not-yet-saved conversation under a linked account folder as unsaved', - async () => { - const linked = join(root, 'linked-account') - await mkdir(join(root, 'projects'), { recursive: true }) - await symlink(root, linked) - await expect( - resolveStructuredAgentSessionAdoptionForCreate({ - settings: {}, - agent: 'claude', - transcriptImport: CLAUDE_CLI_CONVERSATIONS.transcriptImport!, - providerSessionId: id, - selectedAccountHome: { variable: 'CLAUDE_CONFIG_DIR', path: linked }, - launchEnv: {}, - workspaceCwd: root, - resolveTranscriptPath: () => join(linked, 'projects', 'folder', `${id}.jsonl`) - }) - ).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptNotFound' } } }) - } -) +it.skipIf(process.platform === 'win32')('reports a not-yet-saved conversation under a linked account folder as unsaved', async () => { + const linked = join(root, 'linked-account') + await mkdir(join(root, 'projects'), { recursive: true }) + await symlink(root, linked) + await expect(resolveStructuredAgentSessionAdoptionForCreate({ + settings: {}, agent: 'claude', transcriptImport: CLAUDE_CLI_CONVERSATIONS.transcriptImport!, + providerSessionId: id, selectedAccountHome: { variable: 'CLAUDE_CONFIG_DIR', path: linked }, + launchEnv: {}, workspaceCwd: root, + resolveTranscriptPath: () => join(linked, 'projects', 'folder', `${id}.jsonl`) + })).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptNotFound' } } }) +}) -it.each(['claude', 'codex', 'grok', 'omp', 'pi'] as const)( - 'retains empty versus unreadable source facts for %s', - async (agent) => { - const transcript = join(root, `${id}.jsonl`) - await writeFile(transcript, '') - expect(await prepareAdoptedTranscript(params(agent, transcript))).toMatchObject({ - ok: false, - refusal: { details: { reason: 'transcriptNotFound' } } - }) - for (const content of ['{broken json}\n', '{"type":"future_record"}\n']) { - await writeFile(transcript, content) - expect(await prepareAdoptedTranscript(params(agent, transcript))).toMatchObject({ - ok: false, - refusal: { details: { reason: 'transcriptUnreadable' } } - }) - } +it.each(['claude', 'codex', 'grok', 'omp', 'pi'] as const)('retains empty versus unreadable source facts for %s', async (agent) => { + const transcript = join(root, `${id}.jsonl`) + await writeFile(transcript, '') + expect(await prepareAdoptedTranscript(params(agent, transcript))).toMatchObject({ ok: false, + refusal: { details: { reason: 'transcriptNotFound' } } }) + for (const content of ['{broken json}\n', '{"type":"future_record"}\n']) { + await writeFile(transcript, content) + expect(await prepareAdoptedTranscript(params(agent, transcript))).toMatchObject({ ok: false, + refusal: { details: { reason: 'transcriptUnreadable' } } }) } -) +}) it('checks canonical provider metadata during the existing import, including Codex copied parent metadata', async () => { const codex = await file(join(root, `${id}.jsonl`), [ @@ -241,38 +150,23 @@ it('checks canonical provider metadata during the existing import, including Cod for (const agent of ['omp', 'pi'] as const) { const transcript = await file(join(root, `${id}.jsonl`), [ { type: 'session', id, cwd: root }, - { - type: 'message', - id: 'turn', - message: { role: 'user', content: [{ type: 'text', text: 'hello' }] } - } + { type: 'message', id: 'turn', message: { role: 'user', content: [{ type: 'text', text: 'hello' }] } } ]) expect(await prepareAdoptedTranscript(params(agent, transcript))).toMatchObject({ ok: true }) await file(transcript, [{ type: 'session', id: 'foreign', cwd: root }]) expect(await prepareAdoptedTranscript(params(agent, transcript))).toMatchObject({ ok: false }) } - const grok = await file(join(root, id, 'chat_history.jsonl'), [ - { type: 'user', content: 'hello' } - ]) + const grok = await file(join(root, id, 'chat_history.jsonl'), [{ type: 'user', content: 'hello' }]) await file(join(root, id, 'summary.json'), [{ info: { id, cwd: root } }]) expect(await prepareAdoptedTranscript(params('grok', grok))).toMatchObject({ ok: true }) await rm(join(root, id, 'summary.json')) - expect(await prepareAdoptedTranscript(params('grok', grok))).toMatchObject({ - ok: false, - refusal: { details: { reason: 'transcriptUnreadable' } } - }) + expect(await prepareAdoptedTranscript(params('grok', grok))).toMatchObject({ ok: false, + refusal: { details: { reason: 'transcriptUnreadable' } } }) await file(codex, [{ type: 'session_meta', payload: { id } }]) - expect( - await prepareLegacyTranscriptImport({ - agent: 'codex', - sessionId: id, - options: { filePath: codex } - }) - ).toMatchObject({ ok: true, items: [], sourceEmpty: false }) - expect(await prepareAdoptedTranscript(params('codex', codex))).toMatchObject({ - ok: false, - refusal: { details: { reason: 'transcriptUnreadable' } } - }) + expect(await prepareLegacyTranscriptImport({ agent: 'codex', sessionId: id, + options: { filePath: codex } })).toMatchObject({ ok: true, items: [], sourceEmpty: false }) + expect(await prepareAdoptedTranscript(params('codex', codex))).toMatchObject({ ok: false, + refusal: { details: { reason: 'transcriptUnreadable' } } }) }) it('materializes legacy Codex into the system home and pins a verified selected account alias', async () => { @@ -281,9 +175,7 @@ it('materializes legacy Codex into the system home and pins a verified selected const historyOnly = join(root, 'history-only') const selected = join(root, 'codex-accounts', 'selected', 'home') const suffix = join('sessions', '2026', '10', '15', `rollout-stamp-${id}.jsonl`) - const transcriptPath = await file(join(legacy, suffix), [ - { type: 'session_meta', payload: { id } } - ]) + const transcriptPath = await file(join(legacy, suffix), [{ type: 'session_meta', payload: { id } }]) vi.spyOn(codexHomes, 'getSystemCodexHomePath').mockReturnValue(system) const source = { accountHome: { variable: 'CODEX_HOME' as const, path: legacy }, transcriptPath } const prepare = codexAiVaultSessionResumePreparation(() => ({ @@ -292,55 +184,18 @@ it('materializes legacy Codex into the system home and pins a verified selected resolveSelectedHostAccountCodexHomePathForResume: () => null, getHostCodexHomePathsForSessionDiscovery: () => [legacy] }, - systemCodexHomePath: historyOnly, - preparePinnedLaunchHome: async () => {} + systemCodexHomePath: historyOnly, preparePinnedLaunchHome: async () => {} })) const migrated = await prepareCodexAdoption(source, id, prepare, [source.accountHome]) - expect(migrated).toEqual({ - accountHome: { variable: 'CODEX_HOME', path: system }, - transcriptPath: join(system, suffix) - }) - await expect( - prepareLegacyTranscriptImport({ - agent: 'codex', - sessionId: id, - options: { filePath: join(historyOnly, suffix) } - }) - ).resolves.toMatchObject({ ok: false }) - await prepare({ - agent: 'codex', - sessionId: id, - filePath: transcriptPath, - codexHome: legacy, - executionHostId: 'local' - }) - expect( - await prepareLegacyTranscriptImport({ - agent: 'codex', - sessionId: id, - options: { filePath: join(historyOnly, suffix) } - }) - ).toMatchObject({ ok: true }) + expect(migrated).toEqual({ accountHome: { variable: 'CODEX_HOME', path: system }, transcriptPath: join(system, suffix) }) + await expect(prepareLegacyTranscriptImport({ agent: 'codex', sessionId: id, + options: { filePath: join(historyOnly, suffix) } })).resolves.toMatchObject({ ok: false }) + await prepare({ agent: 'codex', sessionId: id, filePath: transcriptPath, codexHome: legacy, executionHostId: 'local' }) + expect(await prepareLegacyTranscriptImport({ agent: 'codex', sessionId: id, + options: { filePath: join(historyOnly, suffix) } })).toMatchObject({ ok: true }) await file(join(selected, suffix), [{ type: 'session_meta', payload: { id } }]) - expect( - await prepareCodexAdoption( - migrated, - id, - async () => ({ useRealCodexHome: false, substituteCodexHome: selected }), - [{ variable: 'CODEX_HOME', path: selected }] - ) - ).toEqual({ - accountHome: { variable: 'CODEX_HOME', path: selected }, - transcriptPath: join(selected, suffix) - }) - await expect( - prepareCodexAdoption( - source, - id, - async () => { - throw new Error('EACCES selected account') - }, - [source.accountHome] - ) - ).rejects.toThrow('EACCES selected account') + expect(await prepareCodexAdoption(migrated, id, async () => ({ useRealCodexHome: false, substituteCodexHome: selected }), + [{ variable: 'CODEX_HOME', path: selected }])).toEqual({ accountHome: { variable: 'CODEX_HOME', path: selected }, transcriptPath: join(selected, suffix) }) + await expect(prepareCodexAdoption(source, id, async () => { throw new Error('EACCES selected account') }, + [source.accountHome])).rejects.toThrow('EACCES selected account') }) diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.test.ts b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts index 988ecc7818fa..79d05fa69140 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.test.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts @@ -503,9 +503,7 @@ describe('readOpenCodeTranscriptSignal', () => { insertPart(db, { id: 'prt-1', messageId: 'msg-1', time: 10, data: { type: 'text', text: 'a' } }) insertPart(db, { id: 'prt-2', messageId: 'msg-2', time: 20, data: { type: 'text', text: 'b' } }) - db.exec( - "ALTER TABLE session ADD COLUMN directory TEXT; UPDATE session SET directory = '/folder'" - ) + db.exec("ALTER TABLE session ADD COLUMN directory TEXT; UPDATE session SET directory = '/folder'") const signal = readOpenCodeTranscriptSignal(path, 'ses-1') expect(signal).toEqual({ cwd: '/folder', diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.ts b/src/main/native-chat/transcript-opencode-sqlite-query.ts index 823fe47119c6..3f2072166985 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.ts @@ -117,8 +117,7 @@ export function readOpenCodeTranscriptSignal( return { cwd: columnExists(db, 'session', 'directory') ? extractString( - asRecord(db.prepare('SELECT directory FROM session WHERE id = ?').get(sessionId)) - ?.directory + asRecord(db.prepare('SELECT directory FROM session WHERE id = ?').get(sessionId))?.directory ) : null, messageCount: messageRow?.message_count ?? 0, @@ -304,10 +303,7 @@ function mapMessageRows( ? [{ message_id: row.id, time_updated: row.time_updated, data: null }] : partList ) - const updated = partList.reduce( - (max, row) => (row.time_updated > max ? row.time_updated : max), - 0 - ) + const updated = partList.reduce((max, row) => (row.time_updated > max ? row.time_updated : max), 0) for (const message of messages.toReversed()) { items.push({ rowid: row.message_rowid, diff --git a/src/main/omp/omp-session-cwd-lookup.test.ts b/src/main/omp/omp-session-cwd-lookup.test.ts index b2fbbdbcb96c..691275f09fcb 100644 --- a/src/main/omp/omp-session-cwd-lookup.test.ts +++ b/src/main/omp/omp-session-cwd-lookup.test.ts @@ -52,12 +52,7 @@ async function lookup(cwd = '/repo/orca', homeDir = join(root, 'home'), temp = j launchEnv }) return store - ? OMP_TRANSCRIPT_IMPORT.findTranscript({ - providerSessionId: ID, - store, - workspaceCwd: cwd, - launchEnv - }) + ? OMP_TRANSCRIPT_IMPORT.findTranscript({ providerSessionId: ID, store, workspaceCwd: cwd, launchEnv }) : null } diff --git a/src/main/runtime/agent-launch-scope-runtime.test.ts b/src/main/runtime/agent-launch-scope-runtime.test.ts index d843e679838e..c238dcc2f1eb 100644 --- a/src/main/runtime/agent-launch-scope-runtime.test.ts +++ b/src/main/runtime/agent-launch-scope-runtime.test.ts @@ -128,13 +128,9 @@ it('builds a native Windows chat fork for the project shell even when the global prepared: { useRealCodexHome: false, cliForkSource: { - agent: 'codex', - sourceAgent: 'codex', - sessionId: 'chat-1', - filePath: 'C:\\sessions\\chat-1.jsonl', - cwd: WIN, - codexHome: 'C:\\codex-home', - executionHostPlatform: 'win32' + agent: 'codex', sourceAgent: 'codex', sessionId: 'chat-1', + filePath: 'C:\\sessions\\chat-1.jsonl', cwd: WIN, + codexHome: 'C:\\codex-home', executionHostPlatform: 'win32' } }, settings: { agentDefaultArgs: { codex: '' } }, diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index 67876fbc8373..746341ead836 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -122,10 +122,8 @@ describe('structured agent-session create intent', () => { envelope: { sessionId: 'new-chat', clientOperationId: 'operation-1' }, worktree: 'id:another-workspace', agent: 'claude', - resumeFrom: { - providerSessionId: 'provider-session-alpha-1', - cliSource: { workspaceId: 'another-workspace', paneKey: 'missing-pane' } - } + resumeFrom: { providerSessionId: 'provider-session-alpha-1', + cliSource: { workspaceId: 'another-workspace', paneKey: 'missing-pane' } } }) ).rejects.toMatchObject({ refusal: { @@ -498,23 +496,17 @@ describe('adopting a conversation recorded outside Orca', () => { expect(intent.adopt).toEqual({ providerHandle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: file }, transcriptPath: file, - source: { - providerSessionId: '0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a', - workspaceCwd: '/repos/workspace-1' - } + source: { providerSessionId: '0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a', workspaceCwd: '/repos/workspace-1' } }) workspaceDirectory = '/different-folder' const refused = await runtime.resolveStructuredAgentSessionCreateIntent({ - envelope: { sessionId: 'session-2', clientOperationId: 'operation-2' }, - worktree: 'id:workspace-1', - agent: 'pi', - resumeFrom: { providerSessionId: '0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a' } - }) - expect(await prepareAdoptedTranscript(refused)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_identity_required' } - }) + envelope: { sessionId: 'session-2', clientOperationId: 'operation-2' }, + worktree: 'id:workspace-1', + agent: 'pi', + resumeFrom: { providerSessionId: '0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a' } + }) + expect(await prepareAdoptedTranscript(refused)).toMatchObject({ ok: false, refusal: { code: 'agent_session_identity_required' } }) } finally { vi.unstubAllEnvs() await rm(accountHome, { recursive: true, force: true }) diff --git a/src/main/runtime/rpc/methods/managed-runtime-install.test.ts b/src/main/runtime/rpc/methods/managed-runtime-install.test.ts index 3e7329225252..6ea13e118b86 100644 --- a/src/main/runtime/rpc/methods/managed-runtime-install.test.ts +++ b/src/main/runtime/rpc/methods/managed-runtime-install.test.ts @@ -70,7 +70,9 @@ it('pushes a snapshot, then the install another client starts, until the transpo // Cancel answers with the outcome once the run has unwound. const outcome = await cancelMethod.handler({ agent: 'grok', operationId: id }, ctx) expect(outcome).toMatchObject({ id, phase: 'cancelled' }) - await vi.waitFor(() => expect(frames.at(-1)).toMatchObject({ operation: { phase: 'cancelled' } })) + await vi.waitFor(() => + expect(frames.at(-1)).toMatchObject({ operation: { phase: 'cancelled' } }) + ) transport.abort() const seen = frames.length diff --git a/src/main/runtime/rpc/methods/managed-runtime-install.ts b/src/main/runtime/rpc/methods/managed-runtime-install.ts index dde5d195757c..ec31cecee1b4 100644 --- a/src/main/runtime/rpc/methods/managed-runtime-install.ts +++ b/src/main/runtime/rpc/methods/managed-runtime-install.ts @@ -29,10 +29,7 @@ function installationFor(agent: string, runtime: OrcaRuntimeService): ManagedRun // What a start would run, outside Orca's releases: the launch's own lookup decides. const findExternal = async (): Promise => { const settings = runtime.requireStore().getSettings() - const env = { - ...(await resolveHostStructuredAgentLaunchEnvironment(agent, settings)), - ...spec.env - } + const env = { ...(await resolveHostStructuredAgentLaunchEnvironment(agent, settings)), ...spec.env } try { const command = resolveAcpLaunchCommand(spec, env, { commandSettings: settings, diff --git a/src/main/runtime/runtime-pane-admission.ts b/src/main/runtime/runtime-pane-admission.ts index d358e540fdd5..db318099f638 100644 --- a/src/main/runtime/runtime-pane-admission.ts +++ b/src/main/runtime/runtime-pane-admission.ts @@ -41,16 +41,12 @@ function admissionFor(args: AdmittedSpawnArgs): TerminalPaneAdmission { ...(row?.viewMode ? { viewMode: row.viewMode } : {}), ...(row?.customTitle ? { title: row.customTitle } : {}), ...(args.cwd || args.launchAgent || args.launchRuntime?.pty.shellOverride - ? { - creation: { - ...(args.cwd ? { startupCwd: args.cwd } : {}), - ...(args.launchAgent ? { launchAgent: args.launchAgent } : {}), - ...(args.launchRuntime?.pty.shellOverride - ? { shellOverride: args.launchRuntime.pty.shellOverride } - : {}) - } - } - : {}) + ? { creation: { + ...(args.cwd ? { startupCwd: args.cwd } : {}), + ...(args.launchAgent ? { launchAgent: args.launchAgent } : {}), + ...(args.launchRuntime?.pty.shellOverride + ? { shellOverride: args.launchRuntime.pty.shellOverride } : {}) + } } : {}) } } diff --git a/src/main/runtime/session-tabs/structured-session-create-support.ts b/src/main/runtime/session-tabs/structured-session-create-support.ts index b9dd78370a95..4d60e8fc2081 100644 --- a/src/main/runtime/session-tabs/structured-session-create-support.ts +++ b/src/main/runtime/session-tabs/structured-session-create-support.ts @@ -160,8 +160,7 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte selfSessionId: input.envelope.sessionId }) } - const structuredAgentCliSourceEvidence = - input.structuredAgentCliSourceEvidence ?? + const structuredAgentCliSourceEvidence = input.structuredAgentCliSourceEvidence ?? (input.resumeFrom?.cliSource ? captureStructuredAgentCliSource(() => this.getAgentProviderSessionRows()) : undefined) @@ -241,9 +240,7 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte return committedReplay } const resumeFrom = input.resumeFrom - const workspaceCwd = resumeFrom - ? (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path - : '' + const workspaceCwd = resumeFrom ? (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path : '' const selectedAccountHome = await resolveAccountHome({ launchEnv, location }) // Adopting pins the account home to wherever the conversation actually lives, which is not // necessarily the one a fresh create would pick: Codex resolves its rollout under @@ -256,13 +253,10 @@ export abstract class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport exte agent: input.agent, transcriptImport, providerSessionId: resumeFrom.providerSessionId, - resolveTranscriptPath: () => - structuredAgentCliSourcePathFromEvidence({ - agent: input.agent, - resumeFrom, - workspaceId: location.workspaceId, - evidence: input.structuredAgentCliSourceEvidence - }), + resolveTranscriptPath: () => structuredAgentCliSourcePathFromEvidence({ + agent: input.agent, resumeFrom, workspaceId: location.workspaceId, + evidence: input.structuredAgentCliSourceEvidence + }), prepareCodexResume: this.prepareAiVaultSessionResumeFn ?? undefined, selectedAccountHome, launchEnv: await resolveHostStructuredAgentLaunchEnvironment(input.agent, settings), diff --git a/src/main/runtime/structured-agent-session-create-adoption.ts b/src/main/runtime/structured-agent-session-create-adoption.ts index 42f06121cafe..0288fdeb8d52 100644 --- a/src/main/runtime/structured-agent-session-create-adoption.ts +++ b/src/main/runtime/structured-agent-session-create-adoption.ts @@ -1,8 +1,5 @@ import { stat } from 'node:fs/promises' -import { - agentSessionRefusalError, - isAgentSessionRefusalError -} from '../../shared/agent-session-wire-refusals' +import { agentSessionRefusalError, isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { prepareCodexAdoption } from '../native-chat/codex-adoption-preparation' import type { NativeCodexResumePreparation } from '../codex/codex-ai-vault-session-resume' import type { StructuredAgentSessionResumeSource } from '../../shared/structured-agent-session-create' @@ -115,13 +112,9 @@ export async function resolveStructuredAgentSessionAdoptionForCreate(input: { const stores = new Map() const storeOf = (accountHome: AgentSessionAccountHome): string | null => { if (!stores.has(accountHome)) { - stores.set( - accountHome, - input.transcriptImport.conversationStore({ - accountHome, - launchEnv: input.launchEnv - }) - ) + stores.set(accountHome, input.transcriptImport.conversationStore({ + accountHome, launchEnv: input.launchEnv + })) } return stores.get(accountHome) ?? null } @@ -152,13 +145,8 @@ export async function resolveStructuredAgentSessionAdoptionForCreate(input: { return exact } } catch (error) { - if ( - !( - error instanceof Error && - 'code' in error && - (error.code === 'ENOENT' || error.code === 'ENOTDIR') - ) - ) { + if (!(error instanceof Error && 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTDIR'))) { throw error } } @@ -177,10 +165,7 @@ export async function resolveStructuredAgentSessionAdoptionForCreate(input: { if (!found) { return null } - const path = await input.transcriptImport.admitTranscriptPath({ - store, - transcriptPath: found - }) + const path = await input.transcriptImport.admitTranscriptPath({ store, transcriptPath: found }) if (!path) { throw new Error('The conversation path is outside its account storage.') } @@ -190,21 +175,13 @@ export async function resolveStructuredAgentSessionAdoptionForCreate(input: { workspaceCwd: input.workspaceCwd }) return input.agent === 'codex' - ? await prepareCodexAdoption( - adoption, - input.providerSessionId, - input.prepareCodexResume, - candidates - ) + ? await prepareCodexAdoption(adoption, input.providerSessionId, input.prepareCodexResume, candidates) : adoption } catch (error) { if (isAgentSessionRefusalError(error)) { throw error } - throw agentSessionRefusalError( - 'agent_session_identity_required', - { reason: 'transcriptUnreadable' }, - error instanceof Error ? error.message : String(error) - ) + throw agentSessionRefusalError('agent_session_identity_required', + { reason: 'transcriptUnreadable' }, error instanceof Error ? error.message : String(error)) } } diff --git a/src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts index dbcbc20421f4..4c59bc49f010 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-chat-fork-create.ts @@ -3,10 +3,7 @@ import type { MobileTerminalCreateOptions } from './mobile-terminal-renderer-cre import type { OwnedMobileTerminalCreateOptions } from './mobile-terminal-owned-create' import type { TerminalWorkspaceLaunchScope } from '../../runtime-legacy-worker-terminal-recovery-types' import type { RuntimeMobileSessionCreateTerminalResult } from '../../../../shared/runtime-types' -import { - prepareStructuredChatCliFork, - isStructuredChatCliForkSourceCurrent -} from '../../../ai-vault/structured-chat-cli-fork' +import { prepareStructuredChatCliFork, isStructuredChatCliForkSourceCurrent } from '../../../ai-vault/structured-chat-cli-fork' import { buildMobileTerminalChatFork } from './mobile-terminal-chat-fork' import { isWslUncPath } from '../../../../shared/wsl-paths' import { isTuiAgent } from '../../../../shared/tui-agent-config' @@ -30,9 +27,7 @@ function resolveSavedChatForkAnchor( return {} } const workspace = loadWorkspaceLayoutWithoutReport( - LOCAL_EXECUTION_HOST_ID, - session, - nameBasedLoadContext() + LOCAL_EXECUTION_HOST_ID, session, nameBasedLoadContext() ).layout.workspaces[worktreeId] const tab = workspace && resolveSessionTabRow(workspace, rowId)?.tab const group = tab && workspace?.groups.find((entry) => entry.tabOrder.includes(tab.id)) @@ -60,13 +55,7 @@ export abstract class OrcaRuntimeWithMobileChatForkCreate extends OrcaRuntimeWit const { workspace, worktreeId, opts, afterTabId, graphEpoch } = args const sourceId = opts.sourceChatFork?.sessionId const mutationId = opts.clientMutationId - if ( - !sourceId || - !mutationId || - workspace.connectionId || - isWslUncPath(workspace.path) || - !this.store - ) { + if (!sourceId || !mutationId || workspace.connectionId || isWslUncPath(workspace.path) || !this.store) { throw new Error('chat_fork_runtime_incompatible') } const store = this.store @@ -78,12 +67,10 @@ export abstract class OrcaRuntimeWithMobileChatForkCreate extends OrcaRuntimeWit (prepareArgs) => this.aiVault.prepare(prepareArgs) ) this.assertStableReadyGraph(graphEpoch) - if ( - !isStructuredChatCliForkSourceCurrent(sourceId, worktreeId) || - !this.mobileSessionTabsByWorktree - .get(worktreeId) - ?.tabs.some((tab) => tab.type === 'agent-session' && tab.sessionId === sourceId) - ) { + if (!isStructuredChatCliForkSourceCurrent(sourceId, worktreeId) || + !this.mobileSessionTabsByWorktree.get(worktreeId)?.tabs.some((tab) => + tab.type === 'agent-session' && tab.sessionId === sourceId + )) { throw new Error('chat_fork_source_unavailable') } if (opts.signal?.aborted) { @@ -95,47 +82,24 @@ export abstract class OrcaRuntimeWithMobileChatForkCreate extends OrcaRuntimeWit } const settings = store.getSettings() const launchRuntime = this.resolveAgentLaunchRuntime(workspace, sourceAgent) - const startup = applyClaudeTerminalImageViewerEnv( - buildMobileTerminalChatFork({ - prepared, - settings, - runtime: launchRuntime - }), - opts.terminalImageViewerSupported - ) + const startup = applyClaudeTerminalImageViewerEnv(buildMobileTerminalChatFork({ + prepared, settings, runtime: launchRuntime + }), opts.terminalImageViewerSupported) const win = this.getAvailableAuthoritativeWindow() const placement = resolveSavedChatForkAnchor( - this.getWorkspaceSessionForWorktree(worktreeId), - worktreeId, - afterTabId + this.getWorkspaceSessionForWorktree(worktreeId), worktreeId, afterTabId ) - const phoneAnchor = this.mobileSessionTabsByWorktree - .get(worktreeId) - ?.tabs.some((tab) => tab.id === afterTabId) - ? afterTabId - : undefined - return { - startup, - launchRuntime, - win, - phoneAnchor, - ...placement, - targetGroupId: opts.targetGroupId ?? placement.targetGroupId - } + const phoneAnchor = this.mobileSessionTabsByWorktree.get(worktreeId)?.tabs.some( + (tab) => tab.id === afterTabId + ) ? afterTabId : undefined + return { startup, launchRuntime, win, phoneAnchor, ...placement, + targetGroupId: opts.targetGroupId ?? placement.targetGroupId } } catch (error) { - const code = - error instanceof OrchestrationError - ? error.code - : error instanceof Error - ? error.message - : '' - if ( - code === 'chat_fork_source_unavailable' || - code === 'chat_fork_runtime_incompatible' || + const code = error instanceof OrchestrationError + ? error.code : error instanceof Error ? error.message : '' + if (code === 'chat_fork_source_unavailable' || code === 'chat_fork_runtime_incompatible' || code === PROJECT_RUNTIME_REPAIR_REQUIRED_CODE || - code === 'runtime_unavailable' || - code === 'client_disconnected' - ) { + code === 'runtime_unavailable' || code === 'client_disconnected') { throw error } console.warn('[mobile-chat-fork] preparation failed:', error) @@ -145,16 +109,11 @@ export abstract class OrcaRuntimeWithMobileChatForkCreate extends OrcaRuntimeWit const { startup, launchRuntime, win, phoneAnchor, targetGroupId, afterDesktopTabId } = preflight const clientIdentity = opts.clientNavigationId ?? 'local' const seed = `${clientIdentity}\0${worktreeId}\0${mutationId}` - const releaseThrottle = - win && !win.webContents.isDestroyed?.() - ? rendererPublicationThrottle.acquire(win.webContents) - : () => {} + const releaseThrottle = win && !win.webContents.isDestroyed?.() + ? rendererPublicationThrottle.acquire(win.webContents) : () => {} try { const created = await this.createRuntimeOwnedMobileSessionTerminal( - worktreeId, - opts.activate !== false, - phoneAnchor, - { + worktreeId, opts.activate !== false, phoneAnchor, { ...startup, identity: { tabId: deterministicAgentSessionUuid(`mobile-chat-fork-tab\0${seed}`), @@ -173,16 +132,9 @@ export abstract class OrcaRuntimeWithMobileChatForkCreate extends OrcaRuntimeWit return created } const tab = created.tab - const pty = - typeof tab.terminal === 'string' ? this.getLivePtyForHandle(tab.terminal)?.pty : null - if ( - typeof tab.terminal !== 'string' || - !pty || - !tab.ptyId || - !tab.incarnationId || - pty.incarnationId !== tab.incarnationId || - win.webContents.isDestroyed?.() - ) { + const pty = typeof tab.terminal === 'string' ? this.getLivePtyForHandle(tab.terminal)?.pty : null + if (typeof tab.terminal !== 'string' || !pty || !tab.ptyId || !tab.incarnationId || + pty.incarnationId !== tab.incarnationId || win.webContents.isDestroyed?.()) { return { ...created, warning: 'desktop-tab-not-shown' } } try { @@ -202,24 +154,13 @@ export abstract class OrcaRuntimeWithMobileChatForkCreate extends OrcaRuntimeWit ...(targetGroupId ? { targetGroupId } : {}), ...(afterDesktopTabId ? { afterTabId: afterDesktopTabId } : {}), ...(launchRuntime.pty.shellOverride - ? { shellOverride: launchRuntime.pty.shellOverride } - : {}), - activate: false, - focus: false, - presentation: 'background', - surfaceOwner: false, - expectedProcessIdentity: { - terminalHandle: tab.terminal, - incarnationId: tab.incarnationId - } + ? { shellOverride: launchRuntime.pty.shellOverride } : {}), + activate: false, focus: false, presentation: 'background', surfaceOwner: false, + expectedProcessIdentity: { terminalHandle: tab.terminal, incarnationId: tab.incarnationId } }) - if ( - !revealed || - revealed.tabId !== tab.parentTabId || - !revealed.identity || + if (!revealed || revealed.tabId !== tab.parentTabId || !revealed.identity || revealed.identity.ptyId !== tab.ptyId || - revealed.identity.leafId !== tab.leafId - ) { + revealed.identity.leafId !== tab.leafId) { throw new Error('terminal_reveal_identity_mismatch') } if (this.getLivePtyForHandle(tab.terminal)?.pty.incarnationId !== tab.incarnationId) { diff --git a/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.test.ts b/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.test.ts index a409ff071b6e..231bf994cb4f 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.test.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.test.ts @@ -11,10 +11,7 @@ vi.mock('../../../ai-vault/structured-chat-cli-fork', () => ({ })) const cwd = process.platform === 'win32' ? 'C:\\repo' : '/tmp/repo' const filePath = process.platform === 'win32' ? 'C:\\sessions\\chat.jsonl' : '/tmp/chat.jsonl' -const runtime = { - platform: process.platform, - pty: { shellOverride: undefined, terminalWindowsWslDistro: null } -} +const runtime = { platform: process.platform, pty: { shellOverride: undefined, terminalWindowsWslDistro: null } } describe('native chat fork launch recipe', () => { it('keeps Manual empty args, custom command and environment in an independent fork', () => { @@ -22,13 +19,8 @@ describe('native chat fork launch recipe', () => { prepared: { useRealCodexHome: false, cliForkSource: { - agent: 'claude', - sourceAgent: 'claude', - sessionId: 'original-chat', - filePath, - cwd, - codexHome: null, - executionHostPlatform: process.platform + agent: 'claude', sourceAgent: 'claude', sessionId: 'original-chat', + filePath, cwd, codexHome: null, executionHostPlatform: process.platform } }, settings: { @@ -53,13 +45,8 @@ describe('native chat fork launch recipe', () => { useRealCodexHome: false, forkedSession: { agent: 'opencode', sessionId: 'copy', filePath }, cliForkSource: { - agent: 'opencode', - sourceAgent: 'opencode', - sessionId: 'copy', - filePath, - cwd, - codexHome: null, - executionHostPlatform: process.platform + agent: 'opencode', sourceAgent: 'opencode', sessionId: 'copy', + filePath, cwd, codexHome: null, executionHostPlatform: process.platform } }, settings: { agentDefaultArgs: { opencode: '' } }, @@ -71,186 +58,105 @@ describe('native chat fork launch recipe', () => { }) }) -it.each([true, false])( - 'forks a source-selected chat with saved placement available: %s', - async (saved) => { - const { OrcaRuntimeService, electronMocks } = await import('../../orca-runtime-test-mocks.spec') - const { - TEST_WORKTREE_ID: wt, - makeRuntimeStoreWithWorkspaceSession, - makeWorkspaceSessionWithHeadlessTerminal - } = await import('../../orca-runtime-test-fixtures.spec') - const { withChatTabChanges } = await import('../../agent-session-tab-layout') - const source = 'source-chat', - row = `agent-session:${source}`, - canonical = 'custom-chat-tab' - const initial = makeWorkspaceSessionWithHeadlessTerminal() - const session = - withChatTabChanges('local', initial, wt, [ - { open: { sessionId: source, agent: 'claude', tabId: canonical } } - ]) ?? initial - const group = session.tabGroups?.[wt]?.[0] - if (!group) { - throw new Error('Missing saved group') - } - group.tabOrder = [canonical, 'host-tab'] - const fixture = makeRuntimeStoreWithWorkspaceSession(saved ? session : initial) - const admit = vi.spyOn(fixture.runtimeStore, 'admitTerminalPane') - const service = new OrcaRuntimeService(fixture.runtimeStore) - const spawn = vi.fn(async (_options: unknown) => ({ - id: 'pty-fork', - incarnationId: '45454545-4545-4545-8545-454545454545' - })) - service.setPtyController({ - spawn, - write: () => true, - kill: () => true, - getForegroundProcess: async () => null, - listProcesses: async () => [] - }) - electronMocks.BrowserWindow.fromId.mockReturnValue({ - isDestroyed: () => false, - webContents: { - isDestroyed: () => false, - setBackgroundThrottling: vi.fn(), - capturePage: async () => undefined - } - }) - service.attachWindow(1) - service.syncWindowGraph(1, { tabs: [], leaves: [] }) - const { createMobileCreateTestNotifier } = - await import('../../orca-runtime-test-scenario-builders.spec') - const reveal = vi.fn>( - (_worktree, opts) => { - if (!opts.tabId || !opts.leafId) { - throw new Error('Missing revealed pane') - } - return { - tabId: opts.tabId, - identity: { - worktreeId: wt, - tabId: opts.tabId, - leafId: opts.leafId, - ptyId: opts.ptyId - } - } - } - ) - service.setNotifier({ - ...createMobileCreateTestNotifier(vi.fn()), - revealTerminalSession: reveal - }) - vi.spyOn(service, 'ensureStructuredAgentSessionHost').mockResolvedValue(undefined) - await service.publishStructuredAgentSessionTab({ - workspaceId: wt, - sessionId: source, - agent: 'claude', - activate: true - }) - vi.mocked(prepareStructuredChatCliFork).mockResolvedValue({ - useRealCodexHome: false, - cliForkSource: { - agent: 'claude', - sourceAgent: 'claude', - sessionId: source, - filePath, - cwd, - codexHome: null, - executionHostPlatform: process.platform - } - }) - const created = await service.createMobileSessionTerminal(`id:${wt}`, { - sourceChatFork: { sessionId: source }, - afterTabId: row, - clientMutationId: 'fork', - navigation: 'caller', - clientNavigationId: 'phone', - supportsSplitGroupPlacement: true - }) - expect(spawn).toHaveBeenCalledOnce() - expect(spawn.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ - command: expect.stringContaining(source) - }) - ) - expect(admit).toHaveBeenCalledOnce() - expect(admit.mock.calls[0]?.[0]).toMatchObject({ - type: 'createTerminalTab', - tabId: created.tab.parentTabId, - ...(saved ? { afterTabId: canonical, groupId: group.id } : {}) - }) - expect(admit.mock.calls[0]?.[0]).not.toMatchObject({ afterTabId: row }) - expect(reveal).toHaveBeenCalledWith( - wt, - expect.objectContaining({ - tabId: created.tab.parentTabId, - leafId: created.tab.leafId, - ptyId: created.tab.ptyId, - activate: false, - focus: false, - presentation: 'background', - surfaceOwner: false, - ...(saved ? { afterTabId: canonical, targetGroupId: group.id } : {}) - }) - ) - if (saved) { - expect( - fixture.getSession().tabGroups?.[wt]?.find((entry) => entry.id === group.id)?.tabOrder - ).toEqual([canonical, created.tab.parentTabId, 'host-tab']) - expect(fixture.getSession().unifiedTabs?.[wt]).toEqual( - expect.arrayContaining([expect.objectContaining({ id: canonical, entityId: source })]) - ) - } else { - expect(admit.mock.calls[0]?.[0]).not.toHaveProperty('afterTabId') - expect(reveal.mock.calls[0]?.[1]).not.toHaveProperty('afterTabId') - expect(reveal.mock.calls[0]?.[1]).not.toHaveProperty('targetGroupId') +it.each([true, false])('forks a source-selected chat with saved placement available: %s', async (saved) => { + const { OrcaRuntimeService, electronMocks } = await import('../../orca-runtime-test-mocks.spec') + const { TEST_WORKTREE_ID: wt, makeRuntimeStoreWithWorkspaceSession, + makeWorkspaceSessionWithHeadlessTerminal } = await import('../../orca-runtime-test-fixtures.spec') + const { withChatTabChanges } = await import('../../agent-session-tab-layout') + const source = 'source-chat', row = `agent-session:${source}`, canonical = 'custom-chat-tab' + const initial = makeWorkspaceSessionWithHeadlessTerminal() + const session = withChatTabChanges('local', initial, wt, [ + { open: { sessionId: source, agent: 'claude', tabId: canonical } } + ]) ?? initial + const group = session.tabGroups?.[wt]?.[0] + if (!group) { + throw new Error('Missing saved group') + } + group.tabOrder = [canonical, 'host-tab'] + const fixture = makeRuntimeStoreWithWorkspaceSession(saved ? session : initial) + const admit = vi.spyOn(fixture.runtimeStore, 'admitTerminalPane') + const service = new OrcaRuntimeService(fixture.runtimeStore) + const spawn = vi.fn(async (_options: unknown) => ({ + id: 'pty-fork', incarnationId: '45454545-4545-4545-8545-454545454545' + })) + service.setPtyController({ spawn, write: () => true, kill: () => true, + getForegroundProcess: async () => null, listProcesses: async () => [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false, + webContents: { isDestroyed: () => false, setBackgroundThrottling: vi.fn(), + capturePage: async () => undefined } }) + service.attachWindow(1) + service.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { createMobileCreateTestNotifier } = await import('../../orca-runtime-test-scenario-builders.spec') + const reveal = vi.fn>((_worktree, opts) => { + if (!opts.tabId || !opts.leafId) { + throw new Error('Missing revealed pane') } - expect((await service.listMobileSessionTabs(`id:${wt}`)).tabs).toEqual( - expect.arrayContaining([ - expect.objectContaining({ type: 'agent-session', id: row, sessionId: source }) - ]) - ) + return { tabId: opts.tabId, identity: { + worktreeId: wt, tabId: opts.tabId, leafId: opts.leafId, ptyId: opts.ptyId + } } + }) + service.setNotifier({ ...createMobileCreateTestNotifier(vi.fn()), revealTerminalSession: reveal }) + vi.spyOn(service, 'ensureStructuredAgentSessionHost').mockResolvedValue(undefined) + await service.publishStructuredAgentSessionTab({ + workspaceId: wt, sessionId: source, agent: 'claude', activate: true + }) + vi.mocked(prepareStructuredChatCliFork).mockResolvedValue({ useRealCodexHome: false, + cliForkSource: { agent: 'claude', sourceAgent: 'claude', sessionId: source, + filePath, cwd, codexHome: null, executionHostPlatform: process.platform } }) + const created = await service.createMobileSessionTerminal(`id:${wt}`, { + sourceChatFork: { sessionId: source }, afterTabId: row, clientMutationId: 'fork', + navigation: 'caller', clientNavigationId: 'phone', supportsSplitGroupPlacement: true + }) + expect(spawn).toHaveBeenCalledOnce() + expect(spawn.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ + command: expect.stringContaining(source) + })) + expect(admit).toHaveBeenCalledOnce() + expect(admit.mock.calls[0]?.[0]).toMatchObject({ type: 'createTerminalTab', + tabId: created.tab.parentTabId, ...(saved ? { afterTabId: canonical, groupId: group.id } : {}) }) + expect(admit.mock.calls[0]?.[0]).not.toMatchObject({ afterTabId: row }) + expect(reveal).toHaveBeenCalledWith(wt, expect.objectContaining({ + tabId: created.tab.parentTabId, leafId: created.tab.leafId, ptyId: created.tab.ptyId, + activate: false, focus: false, presentation: 'background', surfaceOwner: false, + ...(saved ? { afterTabId: canonical, targetGroupId: group.id } : {}) + })) + if (saved) { + expect(fixture.getSession().tabGroups?.[wt]?.find((entry) => entry.id === group.id)?.tabOrder) + .toEqual([canonical, created.tab.parentTabId, 'host-tab']) + expect(fixture.getSession().unifiedTabs?.[wt]).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: canonical, entityId: source }) + ])) + } else { + expect(admit.mock.calls[0]?.[0]).not.toHaveProperty('afterTabId') + expect(reveal.mock.calls[0]?.[1]).not.toHaveProperty('afterTabId') + expect(reveal.mock.calls[0]?.[1]).not.toHaveProperty('targetGroupId') } -) + expect((await service.listMobileSessionTabs(`id:${wt}`)).tabs).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'agent-session', id: row, sessionId: source }) + ])) +}) it('keeps preparation and runtime failures from starting a terminal', async () => { const { OrcaRuntimeService } = await import('../../orca-runtime-test-mocks.spec') const { store, TEST_WORKTREE_ID } = await import('../../orca-runtime-test-fixtures.spec') const runtime = new OrcaRuntimeService(store) const spawn = vi.fn() - runtime.setPtyController({ - spawn, - write: () => true, - kill: () => true, - getForegroundProcess: async () => null - }) + runtime.setPtyController({ spawn, write: () => true, kill: () => true, + getForegroundProcess: async () => null }) const ensure = vi.spyOn(runtime, 'ensureStructuredAgentSessionHost') for (const [failure, expected] of [ - [ - new Error('This chat has no saved conversation to copy.'), - { message: 'chat_fork_source_unavailable' } - ], - [ - new OrchestrationError(PROJECT_RUNTIME_REPAIR_REQUIRED_CODE, 'Repair project settings.'), - { code: PROJECT_RUNTIME_REPAIR_REQUIRED_CODE } - ] + [new Error('This chat has no saved conversation to copy.'), { message: 'chat_fork_source_unavailable' }], + [new OrchestrationError(PROJECT_RUNTIME_REPAIR_REQUIRED_CODE, 'Repair project settings.'), + { code: PROJECT_RUNTIME_REPAIR_REQUIRED_CODE }] ] as const) { ensure.mockResolvedValueOnce(undefined) vi.mocked(prepareStructuredChatCliFork).mockRejectedValueOnce(failure) - await expect( - runtime['createMobileChatForkTerminal']({ - workspace: { - id: TEST_WORKTREE_ID, - path: cwd, - connectionId: null, - repo: null, - folderWorkspace: null - }, - worktreeId: TEST_WORKTREE_ID, - opts: { sourceChatFork: { sessionId: 'unsaved-chat' }, clientMutationId: 'next-create' }, - graphEpoch: 0 - }) - ).rejects.toMatchObject(expected) + await expect(runtime['createMobileChatForkTerminal']({ + workspace: { id: TEST_WORKTREE_ID, path: cwd, connectionId: null, repo: null, folderWorkspace: null }, + worktreeId: TEST_WORKTREE_ID, + opts: { sourceChatFork: { sessionId: 'unsaved-chat' }, clientMutationId: 'next-create' }, + graphEpoch: 0 + })).rejects.toMatchObject(expected) } expect(spawn).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.ts b/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.ts index 9f0990639068..e892cdd5baae 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.ts @@ -20,11 +20,7 @@ export function buildMobileTerminalChatFork(args: { prepared: AiVaultPrepareSessionResumeResult settings: AiVaultResumeLaunchSettings runtime: AgentLaunchRuntime -}): MobileTerminalCommand & { - command: string - cwd?: string - resumeProviderSession?: AgentProviderSessionMetadata -} { +}): MobileTerminalCommand & { command: string; cwd?: string; resumeProviderSession?: AgentProviderSessionMetadata } { const { prepared, settings, runtime } = args const source = prepared.cliForkSource if (!source) { @@ -72,8 +68,7 @@ export function buildMobileTerminalChatFork(args: { ...(startup.envToDelete ? { envToDelete: startup.envToDelete } : {}), ...(startup.launchConfig ? { launchConfig: startup.launchConfig } : {}), ...(prepared.forkedSession && startup.providerSession - ? { resumeProviderSession: startup.providerSession } - : {}), + ? { resumeProviderSession: startup.providerSession } : {}), ...(isTuiAgent(source.agent) ? { launchAgent: source.agent } : {}), launchRuntime: runtime } diff --git a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts index 0dfd6721690b..e49370db1e29 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts @@ -258,11 +258,7 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext isPinned: candidate.isPinned === true })) group.tabOrder = insertTabIdIntoOrder( - group.tabOrder, - topLevelTabs, - parentTabId, - tab.isPinned === true, - opts.afterDesktopTabId + group.tabOrder, topLevelTabs, parentTabId, tab.isPinned === true, opts.afterDesktopTabId ) } } diff --git a/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts index 8d848097efe4..247bc7018647 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-renderer-create.ts @@ -43,6 +43,7 @@ export type MobileTerminalCreateOptions = { } export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends OrcaRuntimeWithMobileChatForkCreate { + protected abstract resolveMobileSessionTerminalCommand( workspace: TerminalWorkspaceLaunchScope, opts: MobileTerminalCommandOptions @@ -101,11 +102,7 @@ export abstract class OrcaRuntimeWithRunCreateMobileSessionTerminal extends Orca : requestedAfterTabId if (opts.sourceChatFork) { return this.createMobileChatForkTerminal({ - workspace, - worktreeId, - opts, - afterTabId, - graphEpoch + workspace, worktreeId, opts, afterTabId, graphEpoch }) } let afterDesktopTabId: string | undefined diff --git a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts index 31804615df90..f37def2cc511 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts @@ -116,8 +116,7 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v agentLaunchViewer === 'focus-window' || agentLaunchViewer === 'focus-in-workspace', ...(surfaceOwner === false && terminalPresentation === 'background' - ? { recordInteraction: false } - : {}), + ? { recordInteraction: false } : {}), ...(launchAgent ? { launchAgent, @@ -148,9 +147,7 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v if (afterTabId) { const unifiedTabs = useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? [] const createdUnified = unifiedTabs.find((item) => item.entityId === tab.id)?.id - const anchorUnified = unifiedTabs.find( - (item) => item.id === afterTabId || item.entityId === afterTabId - )?.id + const anchorUnified = unifiedTabs.find((item) => item.id === afterTabId || item.entityId === afterTabId)?.id if (createdUnified && anchorUnified) { insertUnifiedTabAfterAnchor(worktreeId, createdUnified, anchorUnified) } diff --git a/src/shared/rpc-contract/session-tabs-schemas-params.ts b/src/shared/rpc-contract/session-tabs-schemas-params.ts index 50e80991e282..c64b4ff562c1 100644 --- a/src/shared/rpc-contract/session-tabs-schemas-params.ts +++ b/src/shared/rpc-contract/session-tabs-schemas-params.ts @@ -141,10 +141,7 @@ export const SetTabProps = WorktreeTabSelector.extend({ }) export const CreateTerminalTab = WorktreeTabSelector.extend({ - sourceChatFork: z - .object({ sessionId: z.string().min(1) }) - .strict() - .optional(), + sourceChatFork: z.object({ sessionId: z.string().min(1) }).strict().optional(), afterTabId: z.string().optional(), targetGroupId: z.string().optional(), command: z.string().optional(), @@ -186,32 +183,14 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({ }).superRefine((value, context) => { if (value.sourceChatFork) { if (!value.clientMutationId) { - context.addIssue({ - code: 'custom', - path: ['clientMutationId'], - message: 'A fork needs a mutation id' - }) + context.addIssue({ code: 'custom', path: ['clientMutationId'], message: 'A fork needs a mutation id' }) } for (const field of [ - 'command', - 'shell', - 'agent', - 'agentPrompt', - 'env', - 'envToDelete', - 'cwd', - 'launchConfig', - 'launchToken', - 'launchAgent', - 'startupCommandDelivery', - 'viewMode' + 'command', 'shell', 'agent', 'agentPrompt', 'env', 'envToDelete', 'cwd', 'launchConfig', + 'launchToken', 'launchAgent', 'startupCommandDelivery', 'viewMode' ] as const) { if (value[field] !== undefined) { - context.addIssue({ - code: 'custom', - path: [field], - message: 'A fork uses host launch settings' - }) + context.addIssue({ code: 'custom', path: [field], message: 'A fork uses host launch settings' }) } } } diff --git a/src/shared/session-tabs-runtime-capabilities.ts b/src/shared/session-tabs-runtime-capabilities.ts index 17e16a661c6c..8cf484ddae00 100644 --- a/src/shared/session-tabs-runtime-capabilities.ts +++ b/src/shared/session-tabs-runtime-capabilities.ts @@ -2,8 +2,7 @@ // Legacy host paths disagree, so clients without this capability defer placement to the snapshot. export const SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY = 'session-tabs.split-group-placement.v1' as const -export const SESSION_TABS_CHAT_FORK_CLI_RUNTIME_CAPABILITY = - 'session.tabs.chat-fork-cli.v1' as const +export const SESSION_TABS_CHAT_FORK_CLI_RUNTIME_CAPABILITY = 'session.tabs.chat-fork-cli.v1' as const // Why: a host with this follows an "ask me later" session-tabs answer with a real one once its graph // publishes (#25736). Older hosts never do, so a client must not wait on that answer from them. export const SESSION_TABS_EMPTY_WORKTREE_ANSWER_RUNTIME_CAPABILITY = diff --git a/src/shared/structured-agent-session-create.test.ts b/src/shared/structured-agent-session-create.test.ts index f78c3b48f1ed..b4aa2ca38963 100644 --- a/src/shared/structured-agent-session-create.test.ts +++ b/src/shared/structured-agent-session-create.test.ts @@ -163,32 +163,15 @@ describe('the tab id a create reserves', () => { }) it('negotiates a strict pane identity and includes it in the create fingerprint without accepting paths', () => { - const source = { - providerSessionId: 'thread-abc', - cliSource: { workspaceId: 'folder', paneKey: 'tab:leaf' } - } + const source = { providerSessionId: 'thread-abc', cliSource: { workspaceId: 'folder', paneKey: 'tab:leaf' } } expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_CLI_SOURCE_RUNTIME_CAPABILITY) expect(ResumeSource.parse(source)).toEqual(source) expect(ResumeSource.safeParse({ ...source, transcriptPath: '/client/path' }).success).toBe(false) - expect( - ResumeSource.safeParse({ - ...source, - cliSource: { ...source.cliSource, accountHome: '/client/home' } - }).success - ).toBe(false) + expect(ResumeSource.safeParse({ ...source, cliSource: { ...source.cliSource, accountHome: '/client/home' } }).success).toBe(false) const first = createParams({ resumeFrom: source }) - expect(first.envelope.payloadFingerprint).not.toBe( - createParams({ resumeFrom: RESUME }).envelope.payloadFingerprint - ) - expect(first.envelope.payloadFingerprint).toBe( - createParams({ resumeFrom: source }).envelope.payloadFingerprint - ) - expect(first.envelope.payloadFingerprint).not.toBe( - createParams({ - resumeFrom: { - ...source, - cliSource: { ...source.cliSource, paneKey: 'other:leaf' } - } - }).envelope.payloadFingerprint - ) + expect(first.envelope.payloadFingerprint).not.toBe(createParams({ resumeFrom: RESUME }).envelope.payloadFingerprint) + expect(first.envelope.payloadFingerprint).toBe(createParams({ resumeFrom: source }).envelope.payloadFingerprint) + expect(first.envelope.payloadFingerprint).not.toBe(createParams({ resumeFrom: { + ...source, cliSource: { ...source.cliSource, paneKey: 'other:leaf' } + } }).envelope.payloadFingerprint) }) diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index 0546d7137ef0..6a0237ce272e 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -1,8 +1,5 @@ import { sha256 } from './sha256' -import type { - StructuredAgentSessionFirstMessage, - StructuredAgentSessionResumeSource -} from './structured-agent-session-create' +import type { StructuredAgentSessionFirstMessage, StructuredAgentSessionResumeSource } from './structured-agent-session-create' import type { StructuredAgentId } from './agent-session-provider-handle' function canonicalize(value: unknown): string { From 8e83c070dbc25e5d69263f8b89acb3a3ce3d58fe Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:00:19 -0700 Subject: [PATCH 109/127] Fit the startup tests to the owned-spawn, host-awaited-open contract Test doubles gain the mandatory open and an acquisition generation, the removed started event gives way to the rig's proveStart, and two literal types are tightened for CI's typecheck. A Stop during a held start writes no host note on this branch: the taken-back send draws its own stopped-before-start row, which the startup-attempt test now asserts. --- ...claude-structured-session-commands.test.ts | 2 +- .../codex-structured-session-close.test.ts | 1 + ...tured-agent-session-adapter-router.test.ts | 4 ++- ...-agent-session-codex-stop-note-end.test.ts | 6 +---- ...tured-agent-session-codex-stop-row.test.ts | 6 +---- ...ed-agent-session-conversation-stop.test.ts | 1 + .../structured-agent-session-create.test.ts | 6 ++--- .../structured-agent-session-host.test.ts | 6 +++-- ...agent-session-startup-attempt-host.test.ts | 12 +++++++-- .../structured-agent-session-turns.test.ts | 27 ++++++++++++++----- ...ured-agent-session-creation-picks.test.tsx | 11 +++++--- 11 files changed, 51 insertions(+), 31 deletions(-) diff --git a/src/main/claude/claude-structured-session-commands.test.ts b/src/main/claude/claude-structured-session-commands.test.ts index fbbad26ef4e4..aca42b67ded2 100644 --- a/src/main/claude/claude-structured-session-commands.test.ts +++ b/src/main/claude/claude-structured-session-commands.test.ts @@ -127,6 +127,6 @@ it("answers a resting chat's `/` menu only for chats this host runs", async () = await vi.waitFor(() => expect(catalog.read(local)).toEqual(listed)) // Same workspace and account on another host: only that host can read its folders. - const remote = { ...local, location: { ...local.location, executionHostId: 'ssh:box' } } + const remote = { ...local, location: { ...local.location, executionHostId: 'ssh:box' as const } } expect(catalog.read(remote)).toBeUndefined() }) diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index cb3121fad606..c0ba16255f40 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -71,6 +71,7 @@ function adapterFixture() { function claudeAdapterStub(): StructuredAgentSessionAdapter { return { acquire: vi.fn(async () => ({ process: { pid: 1 } }) as never), + open: vi.fn(async () => ({ reportedOptions: { model: '' } })), dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts index 7f8ad5c91067..c469ed1b4550 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -29,7 +29,8 @@ function acquisition(fence: number, spawnToken: string): AgentSessionAcquisition origin: 'created', mintedAtFence: fence, observedAt: 1 - } + }, + acquisitionGeneration: `generation-${fence}` } } @@ -38,6 +39,7 @@ function adapterOf( ): StructuredAgentSessionAdapter { return { acquire: vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)), + open: vi.fn(async () => ({ reportedOptions: { model: '' } })), releaseAcquisition, dispatch: vi.fn(), cancelTurn: vi.fn(), 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 af0a8848b13f..c75e460bf6f9 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,11 +70,7 @@ beforeEach(async () => { adapter = adapterFor(codex, launch, [], { onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), onEvent: (event) => { - if ( - event.type === 'started' || - event.type === 'options-reported' || - (event.type === 'ended' && 'cause' in event) - ) { + if (event.type === 'options-reported' || (event.type === 'ended' && 'cause' in event)) { void host.handleAdapterEvent(event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 5b4c829392e1..4a49b445b4da 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,11 +65,7 @@ beforeEach(async () => { const adapter = adapterFor(codex, launch, [], { onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), onEvent: (event) => { - if ( - event.type === 'started' || - event.type === 'options-reported' || - (event.type === 'ended' && 'cause' in event) - ) { + if (event.type === 'options-reported' || (event.type === 'ended' && 'cause' in event)) { void host.handleAdapterEvent(event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index f123079892b0..60968511fc6b 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 @@ -100,6 +100,7 @@ beforeEach(async () => { } } }, + open: async () => ({ reportedOptions: { model: '' } }), dispatch, closeSession, acknowledgeSessionRelease, 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 e6e05f91d6cd..62e856a3a39a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts @@ -210,12 +210,10 @@ it('holds the created first message until the child proves its start, then hands expect(held).toEqual([expect.objectContaining({ clientMessageId: first.clientMessageId })]) expect(held[0].handedOverAt).toBeUndefined() - await rig.host.handleAdapterEvent({ - type: 'started', + await rig.proveStart({ sessionId: SESSION, fence: rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, - acquisitionGeneration: generation, - reportedOptions: { model: 'gpt-live' } + acquisitionGeneration: generation }) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 9290892950a0..b2878d327528 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 @@ -142,7 +142,8 @@ describe('attach', () => { origin: 'created', mintedAtFence: fence + 1, observedAt: NOW - } + }, + acquisitionGeneration: 'generation-stale' })) .mockImplementation(async ({ fence, spawnToken }) => ({ process: { @@ -157,7 +158,8 @@ describe('attach', () => { origin: 'created', mintedAtFence: fence, observedAt: NOW - } + }, + acquisitionGeneration: `generation-${fence}` })) host = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, 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 797de829d3fb..a7a836552d32 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts @@ -12,6 +12,8 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journ import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { NATIVE_CHAT_STOPPED_BEFORE_START_TEXT } from '../../../shared/native-chat-stopped-before-start' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' @@ -296,8 +298,14 @@ describe('a start that ends before it proves itself', () => { }) expect((await submission(id))?.handedOverAt).toBeUndefined() expect(closeSession).toHaveBeenCalled() - const rows = (await host.journalSnapshot(SESSION)).items.map(({ body }) => body) - expect(rows).toContainEqual({ kind: 'status', text: 'Cancellation requested.' }) + // The taken-back send carries the Stop's feedback; a host row would say it twice. + const snapshot = await host.journalSnapshot(SESSION) + expect( + projectStructuredAgentSessionMessages(snapshot.items, [], snapshot.submissions) + ).toMatchObject([ + { role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { blocks: [{ type: 'text', text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT }] } + ]) expect(dispatch).not.toHaveBeenCalled() expect(startupAttemptOpen()).toBe(false) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 8fca2c00c808..277602ce0cda 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -298,10 +298,12 @@ describe('performCancel', () => { const accepting = new Promise((resolve) => { acceptStop = resolve }) - const stopBackgroundTasks = vi.fn(async (request: { acknowledge: (value: { taskId: string }) => void }) => { - request.acknowledge({ taskId: 'helper' }) - return { cancelled: true } - }) + const stopBackgroundTasks = vi.fn( + async (request: { acknowledge: (value: { taskId: string }) => void }) => { + request.acknowledge({ taskId: 'helper' }) + return { cancelled: true } + } + ) const ctx = acceptedStop({ logger: createStructuredAgentSessionLogger(), sessionId: 'session-1', @@ -320,8 +322,14 @@ describe('performCancel', () => { const stopping = runTargetedCancel(ctx, { clientOperationId: 'stop-earlier-lifetime', scope: 'background-tasks', - childWork: () => [liveTask('helper', { invocation: { invocationId: 'spawn-a', generation: 1 } })], - captureStops: (targets) => owner.captureChildWorkStops('session-1', targets.map((target) => target.providerId)), + childWork: () => [ + liveTask('helper', { invocation: { invocationId: 'spawn-a', generation: 1 } }) + ], + captureStops: (targets) => + owner.captureChildWorkStops( + 'session-1', + targets.map((target) => target.providerId) + ), acknowledgeStops: (evidence) => owner.publishChildWork('session-1', evidence, 'claude') }) expect(stopBackgroundTasks).not.toHaveBeenCalled() @@ -414,7 +422,10 @@ describe('performCancel', () => { scope: 'background-tasks', childWork: () => owner.readChildWork('session-1'), captureStops: (targets) => - owner.captureChildWorkStops('session-1', targets.map((target) => target.providerId)), + owner.captureChildWorkStops( + 'session-1', + targets.map((target) => target.providerId) + ), acknowledgeStops: (evidence) => owner.publishChildWork('session-1', evidence, 'claude'), unlisted: { present: () => owner.childWorkIncomplete('session-1'), stop: stopAgent } }) @@ -631,6 +642,7 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( agent: 'codex', adapter: { acquire: vi.fn(), + open: vi.fn(), dispatch: vi.fn(), closeSession: vi.fn(), cancelTurn: vi.fn(async () => outcome), @@ -716,6 +728,7 @@ describe('the note a Stop writes', () => { agent: 'codex', adapter: { acquire: vi.fn(), + open: vi.fn(), dispatch: vi.fn(), closeSession: vi.fn(), cancelTurn: vi.fn(async () => ({ cancelled: true })), diff --git a/src/renderer/src/components/native-chat/structured-agent-session-creation-picks.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-creation-picks.test.tsx index c9e418a57b9f..24cccaca5c26 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-creation-picks.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-creation-picks.test.tsx @@ -44,7 +44,10 @@ import { SendParams, SetOptionParams } from '../../../../shared/rpc-contract/structured-agent-session-params' -import type { AgentSessionHistoryPage } from '../../../../shared/agent-session-wire' +import type { + AgentSessionHistoryPage, + AgentSessionModelCatalogResult +} from '../../../../shared/agent-session-wire' import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' import { resetStructuredAgentLaunchRegistryForTests } from '@/lib/structured-agent-session-launch-registry' @@ -66,14 +69,14 @@ import { useStructuredAgentSessionSends } from './use-structured-agent-session-s const target = { kind: 'local' } as const const efforts = ['low', 'high'].map((value) => ({ value, label: value })) -const LISTING = { +const LISTING: AgentSessionModelCatalogResult = { origin: 'probe', fetchedAt: 1, models: [ { id: 'sonnet', label: 'Sonnet', isDefault: true, defaultEffort: 'low', efforts }, - { id: 'opus', label: 'Opus', defaultEffort: 'low', efforts } + { id: 'opus', label: 'Opus', isDefault: false, defaultEffort: 'low', efforts } ] -} as const +} function shown(snapshot: readonly SessionOptionDescriptor[], id: string): string | undefined { const kind = snapshot.find((entry) => entry.id === id)?.kind From 45f3b5ae896daa1b98be1f16d0ad454c9b782a82 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:31:35 -0700 Subject: [PATCH 110/127] Fit the remaining CI checks to the startup limits and current releases - Run agent-launch-at-rest.test.ts in the SQLite Node runtime project. - Pin the send-options cross-version case to v1.4.228, the first published release with agent-session.send-options.v1; v1.4.226 was never pushed. - Load the baseline's isStructuredTab from src/shared (moved in v1.4.228), falling back to the renderer path for older baselines. - End the never-proven start with a short startup ceiling: the silence limit it relied on is gone, and the ceiling counts from registration. --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + ...ss-version-agent-session-send.unit.test.ts | 4 +-- ...ss-version-agent-session-wire.unit.test.ts | 6 ++-- ...oss-version-registered-agents.unit.test.ts | 29 +++++++++++++++---- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 523b23ffb770..aa5f02434d4f 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -250,6 +250,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/orca-runtime-terminal-surface-close.test.ts', 'src/main/runtime/orchestration/orchestration-party-location.test.ts', 'src/main/runtime/orchestration/structured-worker-journal-page.test.ts', + 'src/main/runtime/rpc/methods/agent-launch-at-rest.test.ts', 'src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts', 'src/main/runtime/rpc/methods/orchestration/federation/federation-agent-death-liveness.test.ts', 'tests/e2e/agent-launch-cli.unit.test.ts', diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-send.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-send.unit.test.ts index 45fa4ca5a588..87a1e2ef72b8 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-send.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-send.unit.test.ts @@ -21,8 +21,8 @@ import { type SentMessage } from './versioned-agent-session-wire' -// The first release whose host advertises `agent-session.send-options.v1`. -const SEND_OPTIONS_RELEASE_REF = 'v1.4.226' +// The first published release whose host advertises `agent-session.send-options.v1` (#27826). +const SEND_OPTIONS_RELEASE_REF = 'v1.4.228' // Why: a cold CI run extracts the baseline checkout before the first pairing. const SUITE_TIMEOUT_MS = 180_000 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 46f931c7fa19..63394d27605e 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 @@ -742,7 +742,7 @@ describe('cross-version structured agent sessions', () => { /** Reopens the store from disk and installs a fresh host over the same journal * root — what a process restart actually leaves behind. */ - async function bootHost(generation: string, startupLimits?: { silenceMs: number }) { + async function bootHost(generation: string, startupLimits?: { ceilingMs: number }) { store = await openTestAgentSessionRecordStore(root) const host: StructuredAgentSessionHost = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, @@ -940,7 +940,9 @@ describe('cross-version structured agent sessions', () => { (capability) => capability !== AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY ) const created = await answer('agentSession.create', createIntentParams()) - await bootHost('b', { silenceMs: 200 }) + // The ceiling counts from the start's registration, so a short one ends a start whose + // handshake never answers without waiting out the real ten minutes. + await bootHost('b', { ceilingMs: 200 }) handshake = new Promise(() => undefined) const params = sendParams('never started', created.fence) diff --git a/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts index 2f45ad6f345c..68c4c8ab4330 100644 --- a/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-registered-agents.unit.test.ts @@ -37,7 +37,8 @@ import { parseWorkspaceSession } from '../../../src/shared/workspace-session-sch import { importReleaseCheckoutModule, materializeReleaseCheckout, - resolveBaselineReleaseRef + resolveBaselineReleaseRef, + type ReleaseCheckout } from './release-checkout' import { loadAgentSessionWireBuild, @@ -81,6 +82,27 @@ function member(module: Record, name: string): T { return value as T } +// v1.4.228 moved `isStructuredTab` to shared code; keeping both locations lets the rolling baseline +// cross that move and an older pinned ref still load. +const BASELINE_STRUCTURED_TAB_SOURCES = [ + 'src/shared/structured-agent-session-tab.ts', + 'src/renderer/src/components/native-chat/structured-agent-session-tabs.ts' +] as const + +async function importBaselineStructuredTab( + checkout: ReleaseCheckout +): Promise> { + for (const file of BASELINE_STRUCTURED_TAB_SOURCES) { + const loaded = await importReleaseCheckoutModule(checkout, file).catch(() => null) + if (loaded?.isStructuredTab !== undefined) { + return loaded + } + } + throw new Error( + `the baseline release exports no isStructuredTab (tried ${BASELINE_STRUCTURED_TAB_SOURCES.join(', ')})` + ) +} + beforeAll(async () => { const ref = resolveBaselineReleaseRef() const checkout = await materializeReleaseCheckout(ref) @@ -91,10 +113,7 @@ beforeAll(async () => { 'src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts' ), importReleaseCheckoutModule(checkout, 'src/shared/workspace-session-schema.ts'), - importReleaseCheckoutModule( - checkout, - 'src/renderer/src/components/native-chat/structured-agent-session-tabs.ts' - ), + importBaselineStructuredTab(checkout), importReleaseCheckoutModule(checkout, 'src/shared/agent-session-record.ts') ]) current = await loadAgentSessionWireBuild(WORKING_TREE) From 747ea33d1c59f2863bc2f7b84d02e662579a6bfd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:49:54 -0700 Subject: [PATCH 111/127] Read the option stamp from the chat's one host-execution selection --- .../NativeChatStructuredSession.tsx | 8 ++----- .../StructuredAgentSessionStatusBridge.tsx | 22 +++++-------------- 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index fef0d2477132..15572302954d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -37,10 +37,7 @@ import { structuredSessionNotices } from './native-chat-structured-session-notic import { NativeChatPromptSlotNotices } from './NativeChatComposerNotices' import { useNativeChatComposerError } from './use-native-chat-composer-notice' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' -import { - useStructuredAgentSessionHostExecution, - useStructuredAgentSessionOptionStamp -} from './StructuredAgentSessionStatusBridge' +import { useStructuredAgentSessionHostExecution } from './StructuredAgentSessionStatusBridge' import { useNativeChatRewindHost } from './use-native-chat-rewind-host' import { NativeChatRewindContext } from './native-chat-rewind-context' import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' @@ -74,7 +71,6 @@ export function NativeChatStructuredSession( const { sendThroughLaunch } = provisionalLaunch // The host's own word on whether the provider child has answered startup yet. const hostExecution = useStructuredAgentSessionHostExecution(props.sessionId, props.target) - const hostOptionStamp = useStructuredAgentSessionOptionStamp(props.sessionId, props.target) const paneKey = useMemo( () => structuredAgentSessionPaneKey(props.tabId, props.sessionId), [props.sessionId, props.tabId] @@ -92,7 +88,7 @@ export function NativeChatStructuredSession( queueFollowUps, hostStopping: hostExecution.stopping, optionSelection: hostExecution.optionSelection, - hostOptionStamp, + hostOptionStamp: hostExecution.optionStamp, providerStarting: hostExecution.phase === 'starting', // A start's own verdict (a signed-out agent) exists only once that start is proven. providerRunning: hostExecution.phase === 'ready', diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 49c6a1e817b8..f37e749f1bb5 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -64,23 +64,6 @@ export function useStructuredAgentSessionStatusSummary( return { summary, observation } } -/** The host status's option values as one stamp: another device's pick moves it, so options re-read. */ -export function useStructuredAgentSessionOptionStamp( - sessionId: string, - target: RuntimeClientTarget -): string { - const feed = useMemo(() => getStructuredAgentSessionStatusFeed(target), [target]) - useEffect(() => feed.activate(), [feed]) - return useSyncExternalStore( - feed.subscribe, - () => { - const summary = feed.getSnapshot().get(sessionId) - return `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}` - }, - () => '' - ) -} - /** Only the host's startup phase, so a chat re-renders when that changes, not on every status. */ export function useStructuredAgentSessionHostExecutionPhase( sessionId: string, @@ -111,12 +94,15 @@ export function useStructuredAgentSessionHostStopping( type HostExecutionSelection = { phase: AgentSessionStatusSummary['hostExecutionPhase'] | null stopping: boolean + /** The option values as one stamp: another device's pick moves it, so options re-read. */ + optionStamp: string optionSelection: AgentSessionStatusSummary['optionSelection'] } const EMPTY_HOST_EXECUTION: HostExecutionSelection = { phase: null, stopping: false, + optionStamp: '', optionSelection: undefined } @@ -134,6 +120,7 @@ export function useStructuredAgentSessionHostExecution( const next = { phase: summary?.hostExecutionPhase ?? null, stopping: summary?.stopping === true, + optionStamp: `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}`, optionSelection: structuralValuesEqual(previous.optionSelection, summary?.optionSelection) ? previous.optionSelection : summary?.optionSelection @@ -141,6 +128,7 @@ export function useStructuredAgentSessionHostExecution( if ( previous.phase === next.phase && previous.stopping === next.stopping && + previous.optionStamp === next.optionStamp && previous.optionSelection === next.optionSelection ) { return previous From 7322566ce0213a60f06fa90871a1a43c8c11bc83 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:58:03 -0700 Subject: [PATCH 112/127] fix: preserve startup authority and accepted agent choices --- .../mobile-structured-agent-options-input.ts | 18 ++++ .../mobile-structured-option-snapshot.ts | 44 +++++---- ...se-mobile-structured-agent-options.test.ts | 34 +++++++ .../use-mobile-structured-agent-options.ts | 73 +++++++-------- .../use-mobile-structured-agent-session.ts | 1 + ...mobile-structured-session-option-status.ts | 3 +- src/main/acp/acp-message-execution-options.ts | 9 +- .../acp-structured-host-option-hang.test.ts | 3 +- .../acp/acp-structured-session-adapter.ts | 3 +- .../claude-structured-message-options.test.ts | 55 +++++++++++ .../codex/codex-configured-launch-defaults.ts | 11 ++- .../codex-structured-message-options.test.ts | 28 ++++-- .../codex/codex-structured-message-options.ts | 43 +++++++-- .../codex/codex-structured-session-start.ts | 3 + .../codex/codex-structured-session-state.ts | 4 +- src/main/codex/codex-structured-turn-start.ts | 4 + ...structured-agent-session-acquire-aborts.ts | 6 +- .../structured-agent-session-agent-start.ts | 92 +++++++++++++------ ...ured-agent-session-attach-orchestration.ts | 39 +++++++- ...structured-agent-session-attach-publish.ts | 26 +++--- .../structured-agent-session-chat-stop.ts | 9 +- ...red-agent-session-conversation-lifetime.ts | 1 + .../structured-agent-session-delivery-loop.ts | 19 ++-- ...uctured-agent-session-execution-options.ts | 5 +- .../structured-agent-session-host-delivery.ts | 10 +- .../structured-agent-session-host.ts | 6 +- ...red-agent-session-mutation-context.test.ts | 2 +- ...ructured-agent-session-mutation-context.ts | 8 +- .../structured-agent-session-options-read.ts | 42 +++++---- ...tructured-agent-session-ready-operation.ts | 9 +- ...ured-agent-session-start-admission.test.ts | 83 ++++++++++++++++- .../structured-agent-session-start-flight.ts | 1 + ...t-session-wedged-profile-migration.test.ts | 5 +- src/main/pi/rpc-message-dispatch.ts | 3 +- src/main/pi/rpc-session-adapter.test.ts | 25 +++++ src/main/pi/rpc-session-startup.test.ts | 40 ++++++++ src/main/pi/rpc-session-startup.ts | 12 ++- src/main/pi/rpc-session.ts | 16 ++-- .../NativeChatStructuredSession.tsx | 1 + .../StructuredAgentSessionStatusBridge.tsx | 4 + ...e-structured-agent-session-option-state.ts | 44 ++++----- ...session-options.current-selection.test.tsx | 38 ++++++++ .../use-structured-agent-session-options.ts | 2 + .../use-structured-agent-session.ts | 2 + ...ructured-agent-session-option-view.test.ts | 34 +++++++ .../structured-agent-session-option-view.ts | 17 +++- .../structured-agent-session-options.ts | 9 +- 47 files changed, 733 insertions(+), 213 deletions(-) create mode 100644 mobile/src/session/mobile-structured-agent-options-input.ts diff --git a/mobile/src/session/mobile-structured-agent-options-input.ts b/mobile/src/session/mobile-structured-agent-options-input.ts new file mode 100644 index 000000000000..8d27250555f5 --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-options-input.ts @@ -0,0 +1,18 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' +import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc' + +export type MobileStructuredAgentOptionsInput = { + agent: string | null + client: RpcClient | null + sessionId: string | null + enabled: boolean + fence: number | null + mutate: StructuredAgentSessionMutate + /** Moves when the host's status says a session option changed; each move re-reads them. */ + optionStamp?: string + /** What the host advertised; null until its status probe answers. */ + hostSupport: Pick | null + hostSelection?: Readonly> + hostPermissionMode?: string +} diff --git a/mobile/src/session/mobile-structured-option-snapshot.ts b/mobile/src/session/mobile-structured-option-snapshot.ts index 693e18843e1b..8b304ca06051 100644 --- a/mobile/src/session/mobile-structured-option-snapshot.ts +++ b/mobile/src/session/mobile-structured-option-snapshot.ts @@ -1,29 +1,39 @@ -import { createNativeChatSessionOptionRecord } from '../../../src/shared/native-chat-session-option-state' import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options' import { commitStructuredAgentSessionOptionValues, + structuredAgentSessionOptionSnapshot, type StructuredAgentSessionOptionState } from '../../../src/shared/structured-agent-session-options' -import { structuredAgentSessionShownSnapshot } from '../../../src/shared/structured-agent-session-option-view' +import { structuredAgentSessionOptionView } from '../../../src/shared/structured-agent-session-option-view' + +export function commitMobileStructuredOptions( + state: StructuredAgentSessionOptionState, + hostSelection: Readonly> | undefined, + values: Readonly> +): StructuredAgentSessionOptionState { + return commitStructuredAgentSessionOptionValues(state, { ...hostSelection, ...values }) +} + +/** Display, validation and send share the host selection and this phone's pending pick. */ +export function mobileStructuredOptionView( + state: StructuredAgentSessionOptionState, + hostSelection: Readonly> | undefined, + takesSendOptions: boolean, + permissionMode?: string +): StructuredAgentSessionOptionState { + return structuredAgentSessionOptionView(state, undefined, {}, takesSendOptions, { + selection: hostSelection, + permissionMode + }) +} -/** The host's saved choice with this phone's still-pending pick on top, as the picker shows it. */ export function mobileStructuredOptionSnapshot( - agent: string, state: StructuredAgentSessionOptionState, hostSelection: Readonly> | undefined, - takesSendOptions: boolean + takesSendOptions: boolean, + permissionMode?: string ): SessionOptionDescriptor[] { - const selected = hostSelection - ? commitStructuredAgentSessionOptionValues( - { ...state, record: createNativeChatSessionOptionRecord(agent) }, - hostSelection - ) - : state - const pending = state.pendingPick - return structuredAgentSessionShownSnapshot( - pending - ? commitStructuredAgentSessionOptionValues(selected, { [pending.id]: pending.value }) - : selected, - takesSendOptions + return structuredAgentSessionOptionSnapshot( + mobileStructuredOptionView(state, hostSelection, takesSendOptions, permissionMode) ) } diff --git a/mobile/src/session/use-mobile-structured-agent-options.test.ts b/mobile/src/session/use-mobile-structured-agent-options.test.ts index 918c614e962f..2e85c161083f 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.test.ts @@ -12,6 +12,7 @@ import type { StructuredAgentSessionMutate, StructuredAgentSessionMutationResult } from './mobile-structured-agent-session-rpc' +import { agentSessionExecutionOptionsFromSnapshot } from '../../../src/shared/agent-session-execution-option-snapshot' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' import { rememberMobileCreatedStructuredSession } from './mobile-created-structured-sessions' @@ -146,6 +147,7 @@ type ProbeProps = { sessionId: string | null fence: number | null mutate: StructuredAgentSessionMutate + hostSelection?: Readonly> onRender: (controller: Controller) => void } @@ -158,6 +160,7 @@ function Probe(props: ProbeProps): null { enabled: true, fence: props.fence, mutate: props.mutate, + hostSelection: props.hostSelection, hostSupport: { sendOptions: true, permissionModes: true } }) ) @@ -671,3 +674,34 @@ describe('useMobileStructuredAgentOptions host catalog', () => { await harness.unmount() }) }) + +it('validates and sends the effort offered for the status model while its read waits', async () => { + const stale = deferred() + const first = optionsClient( + queuedReads({ ...OPTIONS, current: { model: 'gpt-fast', effort: 'low' } }) + ) + const delayed = optionsClient(() => stale.promise) + const { calls, mutate } = recordingMutate(async () => + accepted( + { key: 'effort', value: 'high', options: { model: 'gpt-live', effort: 'high' } }, + false + ) + ) + const harness = await mountOptions({ ...BASE, client: first.client, mutate }) + await harness.rerender({ client: delayed.client, hostSelection: { model: 'gpt-live' } }) + expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('gpt-live') + expect(descriptorFor(harness.current().optionSnapshot, 'effort')?.kind).toMatchObject({ + choices: expect.arrayContaining([{ value: 'high', label: 'High' }]) + }) + await act(async () => + expect(await harness.current().setStructuredOption('effort', 'high')).toBe(true) + ) + expect(calls).toEqual([ + { method: 'agentSession.setOption', fields: { key: 'effort', value: 'high' } } + ]) + expect(agentSessionExecutionOptionsFromSnapshot(harness.current().optionSnapshot)).toMatchObject({ + model: 'gpt-live', + effort: 'high' + }) + await harness.unmount() +}) diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 0c885268be00..060ab9a0e495 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -11,23 +11,17 @@ import { applyStructuredAgentSessionOptions, canSetStructuredAgentSessionOption, commitStructuredAgentSessionOption, - commitStructuredAgentSessionOptionValues, createStructuredAgentSessionOptionState, structuredAgentSessionOptionPicks, type StructuredAgentSessionOptionState } from '../../../src/shared/structured-agent-session-options' -import { structuredAgentSessionOptionView } from '../../../src/shared/structured-agent-session-option-view' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID, agentChatPermissionModes } from '../../../src/shared/agent-chat-permission-mode' import { legacyHostPermissionDescriptor } from '../../../src/shared/structured-agent-session-permission-option' -import type { RpcClient } from '../transport/rpc-client' -import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' -import { - callAgentSession, - type StructuredAgentSessionMutate -} from './mobile-structured-agent-session-rpc' +import type { MobileStructuredAgentOptionsInput } from './mobile-structured-agent-options-input' +import { callAgentSession } from './mobile-structured-agent-session-rpc' import { persistMobileStructuredOptionPicks } from './mobile-native-chat-session-option-persistence' import { useMobileHostModelCatalogUpgrade } from './use-mobile-host-model-catalog-upgrade' import { @@ -35,22 +29,14 @@ import { mobileCreatedStructuredSession } from './mobile-created-structured-sessions' import { encodeStructuredAgentSessionOptionValue } from '../../../src/shared/structured-agent-session-option-codec' -import { mobileStructuredOptionSnapshot } from './mobile-structured-option-snapshot' +import { + commitMobileStructuredOptions, + mobileStructuredOptionSnapshot, + mobileStructuredOptionView +} from './mobile-structured-option-snapshot' import { useMobileStructuredOptionSurface } from './use-mobile-structured-option-surface' -export function useMobileStructuredAgentOptions(args: { - agent: string | null - client: RpcClient | null - sessionId: string | null - enabled: boolean - fence: number | null - mutate: StructuredAgentSessionMutate - /** Moves when the host's status says a session option changed; each move re-reads them. */ - optionStamp?: string - /** What the host advertised; null until its status probe answers. */ - hostSupport: Pick | null - hostSelection?: Readonly> -}) { +export function useMobileStructuredAgentOptions(args: MobileStructuredAgentOptionsInput) { const { agent, client, @@ -59,10 +45,12 @@ export function useMobileStructuredAgentOptions(args: { mutate, optionStamp, hostSelection, + hostPermissionMode, sessionId, hostSupport } = args const takesSendOptions = hostSupport?.sendOptions === true + const observed = hostSelection !== undefined // Every agent's seed, as on the desktop: a built-in list or the provider-default pill. const optionCatalog = useMemo( () => (agent ? structuredAgentSessionSeedCatalog(agent) : null), @@ -157,7 +145,7 @@ export function useMobileStructuredAgentOptions(args: { setConversationSupport({ sessionId, commands: result.conversationCommands ?? [] }) updateOptionState((current) => current.record === activeOptionRecordRef.current - ? applyStructuredAgentSessionOptions(current, optionCatalog, result) + ? applyStructuredAgentSessionOptions(current, optionCatalog, result, observed) : current ) } @@ -171,6 +159,7 @@ export function useMobileStructuredAgentOptions(args: { enabled, optionCatalog, optionStamp, + observed, listingsLanded, sessionId, fence, @@ -187,8 +176,8 @@ export function useMobileStructuredAgentOptions(args: { optionState.permissionMode === null const shownSnapshot = useCallback( (state: StructuredAgentSessionOptionState) => - mobileStructuredOptionSnapshot(agent ?? 'codex', state, hostSelection, takesSendOptions), - [agent, hostSelection, takesSendOptions] + mobileStructuredOptionSnapshot(state, hostSelection, takesSendOptions, hostPermissionMode), + [hostSelection, takesSendOptions, hostPermissionMode] ) const optionSnapshot = useMemo(() => { const shown = shownSnapshot(optionState) @@ -197,22 +186,23 @@ export function useMobileStructuredAgentOptions(args: { const setStructuredOption = useCallback( async (id: string, value: SessionOptionValue): Promise => { - const currentState = optionStateRef.current + const currentState = mobileStructuredOptionView( + optionStateRef.current, + hostSelection, + takesSendOptions, + hostPermissionMode + ) + const targetRecord = optionStateRef.current.record const encoded = encodeStructuredAgentSessionOptionValue(id, value) if ( !client || !sessionId || !optionCatalog || encoded === null || - !canSetStructuredAgentSessionOption( - structuredAgentSessionOptionView(currentState, undefined, {}, takesSendOptions), - id, - value - ) + !canSetStructuredAgentSessionOption(currentState, id, value) ) { return false } - const targetRecord = currentState.record const mutationGeneration = ++optionMutationGeneration.current const isCurrent = (): boolean => activeOptionRecordRef.current === targetRecord && @@ -242,7 +232,7 @@ export function useMobileStructuredAgentOptions(args: { const committed = result.value.options ?? { [id]: encoded } updateOptionState((current) => current.record === targetRecord && result.sameFence - ? commitStructuredAgentSessionOptionValues(current, committed) + ? commitMobileStructuredOptions(current, hostSelection, committed) : current ) // Only an accepted pick: an `unknown` outcome commits optimistically to the @@ -262,7 +252,7 @@ export function useMobileStructuredAgentOptions(args: { readsBack = false updateOptionState((current) => current.record === targetRecord - ? commitStructuredAgentSessionOption(current, id, encoded) + ? commitMobileStructuredOptions(current, hostSelection, { [id]: encoded }) : current ) return true @@ -277,7 +267,7 @@ export function useMobileStructuredAgentOptions(args: { if (isCurrent()) { updateOptionState((latest) => latest.record === targetRecord - ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) + ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed, observed) : latest ) } @@ -293,7 +283,18 @@ export function useMobileStructuredAgentOptions(args: { } } }, - [agent, client, mutate, optionCatalog, sessionId, takesSendOptions, updateOptionState] + [ + agent, + client, + mutate, + optionCatalog, + sessionId, + takesSendOptions, + hostSelection, + hostPermissionMode, + observed, + updateOptionState + ] ) const { optionPickerRequest, invokeStructuredOption, optionSurface } = diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index fea0141e1e39..7362803f8341 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -116,6 +116,7 @@ export function useMobileStructuredAgentSession(args: { mutate, optionStamp: optionStatus.stamp, hostSelection: optionStatus.selection, + hostPermissionMode: optionStatus.permissionMode, hostSupport }) const { conversationCommands, invokeStructuredOption, optionSnapshot, setStructuredOption } = diff --git a/mobile/src/session/use-mobile-structured-session-option-status.ts b/mobile/src/session/use-mobile-structured-session-option-status.ts index a5211ce922b4..4e0056631289 100644 --- a/mobile/src/session/use-mobile-structured-session-option-status.ts +++ b/mobile/src/session/use-mobile-structured-session-option-status.ts @@ -9,6 +9,7 @@ const noSubscription = (): (() => void) => () => {} type OptionStatus = { stamp: string selection: AgentSessionStatusSummary['optionSelection'] + permissionMode?: string } const EMPTY_OPTION_STATUS: OptionStatus = { stamp: '', selection: undefined } @@ -34,7 +35,7 @@ export function useMobileStructuredSessionOptionStatus(args: { ? previous.selection : summary?.optionSelection if (previous.stamp !== stamp || previous.selection !== selection) { - previous = { stamp, selection } + previous = { stamp, selection, permissionMode: summary?.permissionMode } } return previous } diff --git a/src/main/acp/acp-message-execution-options.ts b/src/main/acp/acp-message-execution-options.ts index 9fb2d6540a20..4c142d544c9c 100644 --- a/src/main/acp/acp-message-execution-options.ts +++ b/src/main/acp/acp-message-execution-options.ts @@ -9,7 +9,8 @@ export async function applyAcpMessageOptions( session: AcpStructuredSession, options: AgentSessionExecutionOptions | undefined, setOption: (key: 'model' | 'effort', value: string) => Promise, - isCurrent: () => boolean + isCurrent: () => boolean, + signal?: AbortSignal ): Promise { if (!options) { return true @@ -36,13 +37,17 @@ export async function applyAcpMessageOptions( try { await setOption(key, value) } catch (error) { + signal?.throwIfAborted() + if (!isCurrent()) { + return false + } if (error instanceof AcpAgentError) { throw withProviderDiagnostic( new AgentSessionOptionRejectedError(error), providerDiagnostic(acpPromptErrorDetail(session.spec.dialect, error), 'person') ) } - throw error + throw new AgentSessionOptionRejectedError(error) } if (!isCurrent()) { return false 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 96f268f8582f..7544fedeb1cc 100644 --- a/src/main/acp/acp-structured-host-option-hang.test.ts +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -100,8 +100,9 @@ describe('a Grok model pick Grok never answers during send', () => { await waitFor(() => expect( host.collaboratorsForTests().sessions.get(SESSION)?.journal.submissions()[0]?.dispatchState - ).toBe('unknown') + ).toBe('rejected') ) + expect(framesOf(rig.child(), 'session/prompt')).toEqual([]) // The chat's queue moved on: a later operation runs. expect(await within(host.readOptions(SESSION), 1_000)).not.toBe('pending') const [frame] = framesOf(rig.child(), 'session/set_config_option') diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 75be58b238f6..ce0186f2d7a0 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -192,7 +192,8 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte input.executionOptions, (key, value) => this.setOption({ sessionId, fence, key, value, signal }), () => - this.children.sessions.get(input.sessionId) === session && session.journalClosed === null + this.children.sessions.get(input.sessionId) === session && session.journalClosed === null, + signal ) if (!optionsApplied) { return this.rejected(session, 'providerExited') diff --git a/src/main/claude/claude-structured-message-options.test.ts b/src/main/claude/claude-structured-message-options.test.ts index 2cc53747affb..b2ad35ad21b4 100644 --- a/src/main/claude/claude-structured-message-options.test.ts +++ b/src/main/claude/claude-structured-message-options.test.ts @@ -5,6 +5,8 @@ import { fakeClaude, USER_MESSAGE } from './claude-structured-session-test-support' +import { captureStructuredExecutionOptions } from '../native-chat/agent-session-wire/structured-agent-session-execution-options' +import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' const MODELS = [ @@ -96,6 +98,59 @@ describe('Claude accepted message choices', () => { expect(order).toEqual(['model:opus', 'effort:{"effortLevel":"high"}', 'prompt']) }) + it('keeps saved Fast for an older partial send, freezes it, and honors explicit false', async () => { + let fastMode = true + const supported = MODELS.map((model) => ({ ...model, supportsFastMode: true })) + const claude = fakeClaude({ + initModels: supported, + routes: { + list_models: () => supported, + apply_flag_settings: ({ settings } = {}) => { + if ( + typeof settings === 'object' && + settings !== null && + 'fastMode' in settings && + typeof settings.fastMode === 'boolean' + ) { + fastMode = settings.fastMode + } + } + } + }) + const adapter = await acquired(claude) + const saved = { model: 'sonnet', effort: 'high', fastMode: 'true' } + const ctx = { agents: claudeAndCodexAgents(), agent: 'claude', persistedOptions: saved } + const captured = captureStructuredExecutionOptions(ctx, { model: 'sonnet', effort: 'high' }) + if ('refusal' in captured) { + throw new Error(captured.refusal.message) + } + saved.fastMode = 'false' + const connection = claude.connections[0]! + connection.getSettings = async () => ({ effective: { effortLevel: 'high', fastMode } }) + for (const [id, options] of [ + ['old', captured.options], + ['off', { ...captured.options, fastMode: 'false' }] + ] as const) { + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: id, + body: USER_MESSAGE, + executionOptions: options, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + } + expect(captured.options.fastMode).toBe('true') + expect(connection.calls.filter((call) => call.subtype === 'apply_flag_settings')).toEqual( + expect.arrayContaining([ + { subtype: 'apply_flag_settings', params: { settings: { fastMode: true } } }, + { subtype: 'apply_flag_settings', params: { settings: { fastMode: false } } } + ]) + ) + expect(connection.sent).toHaveLength(2) + }) + it('restores the configured model and effort for an empty snapshot', async () => { let currentEffort = 'high' const claude = fakeClaude({ diff --git a/src/main/codex/codex-configured-launch-defaults.ts b/src/main/codex/codex-configured-launch-defaults.ts index 3ccc9f5dcb06..b8add319b8a5 100644 --- a/src/main/codex/codex-configured-launch-defaults.ts +++ b/src/main/codex/codex-configured-launch-defaults.ts @@ -4,7 +4,12 @@ import type { CodexAppServerConnection } from './codex-app-server-connection' // Why short: config/read is a local file read; a slow answer must not hold the listing. const CONFIG_READ_TIMEOUT_MS = 3_000 -type CodexConfiguredLaunchDefaults = { model: string | null; effort: string | null } +type CodexConfiguredLaunchDefaults = { + model: string | null + effort: string | null + personality?: string + serviceTier?: string +} function configuredText(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null @@ -28,7 +33,11 @@ export async function readCodexConfiguredLaunchDefaults( if (typeof config !== 'object' || config === null) { return null } + const personality = 'personality' in config ? configuredText(config.personality) : null + const serviceTier = 'service_tier' in config ? configuredText(config.service_tier) : null return { + ...(personality ? { personality } : {}), + ...(serviceTier ? { serviceTier } : {}), model: 'model' in config ? configuredText(config.model) : null, effort: 'model_reasoning_effort' in config ? configuredText(config.model_reasoning_effort) : null diff --git a/src/main/codex/codex-structured-message-options.test.ts b/src/main/codex/codex-structured-message-options.test.ts index 6bef482395ea..17038a8648a7 100644 --- a/src/main/codex/codex-structured-message-options.test.ts +++ b/src/main/codex/codex-structured-message-options.test.ts @@ -80,15 +80,15 @@ describe('Codex captured message choices', () => { ]) expect(Object.fromEntries(session.options)).toEqual({ model: 'model-b', - effort: 'low', - personality: 'none', - serviceTier: 'default' + effort: 'low' }) }) it('resets an explicit empty choice after a thread launch and earlier turn override', async () => { const request = vi.fn(async (method: string, _params?: Record) => - method === 'turn/start' ? { turn: { id: 'opened' } } : {} + method === 'turn/start' + ? { turn: { id: 'opened' } } + : { config: { personality: 'pragmatic', service_tier: 'flex' } } ) const session = optionSession(request) session.reportedOptions = { model: 'model-a', effort: 'high' } @@ -121,22 +121,32 @@ describe('Codex captured message choices', () => { request.mock.calls.filter(([method]) => method === 'turn/start').map(([, params]) => params) ).toMatchObject([ { model: 'model-a', effort: 'high', personality: 'friendly', serviceTier: 'priority' }, - { model: 'model-b', effort: 'medium', personality: 'none', serviceTier: 'default' }, - { model: 'model-a', effort: 'low', personality: 'none', serviceTier: 'default' } + { model: 'model-b', effort: 'medium', personality: 'pragmatic', serviceTier: 'flex' }, + { model: 'model-a', effort: 'low' } ]) expect(request.mock.calls.map(([method]) => method)).toEqual([ + 'config/read', 'turn/start', 'turn/start', 'turn/start' ]) expect(Object.fromEntries(session.options)).toEqual({ model: 'model-a', - effort: 'low', - personality: 'none', - serviceTier: 'default' + effort: 'low' }) }) + it('inherits untouched native tier and personality without a configuration probe', async () => { + const request = vi.fn(async (_method: string) => ({ turn: { id: 'native' } })) + const session = optionSession(request) + session.reportedOptions = { model: 'model-a', serviceTier: 'flex', serviceTierKnown: true } + const turnOptions = await resolvedCodexMessageOptions(session, {}) + await startCodexTurn(session, { clientMessageId: 'native', body: USER_MESSAGE, turnOptions }) + expect(request).toHaveBeenCalledOnce() + expect(request.mock.calls[0]?.[0]).toBe('turn/start') + expect(turnOptions).toEqual({}) + }) + it('opens from the first captured model even when launch resolution saw a newer saved pick', async () => { const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'opened' } }) }) const adapter = adapterFor(codex, { model: 'model-b' }) diff --git a/src/main/codex/codex-structured-message-options.ts b/src/main/codex/codex-structured-message-options.ts index 57b870e14040..2aee7279dc97 100644 --- a/src/main/codex/codex-structured-message-options.ts +++ b/src/main/codex/codex-structured-message-options.ts @@ -1,11 +1,12 @@ import type { AgentSessionExecutionOptions } from '../../shared/agent-session-execution-options' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' +import { readCodexConfiguredLaunchDefaults } from './codex-configured-launch-defaults' import { codexAcquireCatalogListing } from './codex-structured-catalog-entry' import { CODEX_DEFAULT_SERVICE_TIER } from './codex-structured-service-tier' import type { CodexSession } from './codex-structured-session-state' import { applyCodexStructuredSessionOption } from './codex-structured-session-options' -/** Validate one message against known facts without reading the provider or changing the next pick. */ +/** Resolve one message's choices and baseline without changing the next pick. */ export async function resolvedCodexMessageOptions( session: CodexSession, choices: AgentSessionExecutionOptions | undefined @@ -32,13 +33,43 @@ export async function resolvedCodexMessageOptions( if (choices.effort === undefined && priorEffort && !defaultEffort) { throw new AgentSessionOptionRejectedError(new Error('codex default effort is unavailable')) } - // Codex retains turn overrides, so defaults must be named after an earlier override. + // Capture configuration before our first override, never from mutable turn reports. + if ( + (choices.personality && !session.nativeDefaults?.personality) || + ((choices.serviceTier || choices.fastMode) && !session.nativeDefaults?.serviceTier) + ) { + const configured = await readCodexConfiguredLaunchDefaults(session.connection) + session.nativeDefaults = { + ...session.nativeDefaults, + ...(configured?.personality && !session.nativeDefaults?.personality + ? { personality: configured.personality } + : {}), + ...(configured?.serviceTier && !session.nativeDefaults?.serviceTier + ? { serviceTier: configured.serviceTier } + : {}) + } + } + const restored: Record = {} + for (const key of ['personality', 'serviceTier'] as const) { + const applied = session.appliedTurnOptions?.[key] + if ( + choices[key] !== undefined || + (key === 'serviceTier' && choices.fastMode !== undefined) || + !applied + ) { + continue + } + const baseline = session.nativeDefaults?.[key] + if (!baseline) { + throw new AgentSessionOptionRejectedError(new Error(`codex default ${key} is unavailable`)) + } + if (applied !== baseline) { + restored[key] = baseline + } + } const selected = { ...session, - options: new Map([ - ['personality', 'none'], - ['serviceTier', CODEX_DEFAULT_SERVICE_TIER] - ]), + options: new Map(Object.entries(restored)), reportedOptions: { ...session.reportedOptions, effort: undefined } } if ( diff --git a/src/main/codex/codex-structured-session-start.ts b/src/main/codex/codex-structured-session-start.ts index 6ee37cbf8597..efa3067c92cc 100644 --- a/src/main/codex/codex-structured-session-start.ts +++ b/src/main/codex/codex-structured-session-start.ts @@ -129,6 +129,9 @@ export async function openCodexStructuredSession( start.forceCloseUnexpected(sessionId, input.fence, generation, reason), ...(start.unbindReadingControl ? { unbindReadingControl: start.unbindReadingControl } : {}) } + if (opened.serviceTier) { + session.nativeDefaults = { ...session.nativeDefaults, serviceTier: opened.serviceTier } + } current() sessions.set(sessionId, session) for (const event of attempt.window.drain()) { diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 1744bc712daf..b84768bf221e 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -158,7 +158,9 @@ export type CodexSession = { serviceTierKnown?: true } /** A fresh thread opened without a model override proved these native defaults. */ - nativeDefaults?: { model: string; effort?: string } + nativeDefaults?: { model?: string; effort?: string; personality?: string; serviceTier?: string } + /** Overrides actually attempted on this child, independently of future saved picks. */ + appliedTurnOptions?: Readonly> /** Absent when the adapter runs without a host catalog store (tests). */ catalogAccess?: CodexSessionCatalogAccess /** Sends whose identity is still to be settled by the provider echo. */ diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 1037d0e52803..bb75243e4827 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -48,6 +48,7 @@ export type CodexTurnHost = { connection: Pick threadId: string options: Map + appliedTurnOptions?: Readonly> reportedOptions?: { model?: string } dispatchEchoes: CodexDispatchEchoes activeTurnIds?: ReadonlySet @@ -134,6 +135,7 @@ export async function startCodexTurn( if (steered) { return steered } + const previousApplied = host.appliedTurnOptions const previousOptions = input.turnOptions ? new Map(host.options) : null if (input.turnOptions) { host.options.clear() @@ -141,6 +143,7 @@ export async function startCodexTurn( host.options.set(key, value) } } + host.appliedTurnOptions = input.turnOptions ?? Object.fromEntries(host.options) let answer: unknown try { answer = await host.connection.request( @@ -159,6 +162,7 @@ export async function startCodexTurn( previousOptions && (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) ) { + host.appliedTurnOptions = previousApplied host.options.clear() for (const [key, value] of previousOptions) { host.options.set(key, value) 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 9aef5be41d65..1d28be9b0679 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 @@ -9,7 +9,10 @@ export class StructuredAgentSessionAcquireAborts { private quitReason: Error | null = null /** For the provider wait about to run; `end` once it settles. */ - begin(sessionId: string, handover = false): { signal: AbortSignal; end: () => void } { + begin( + sessionId: string, + handover = false + ): { signal: AbortSignal; abort: () => void; end: () => void } { const controller = new AbortController() if (this.quitReason) { controller.abort(this.quitReason) @@ -21,6 +24,7 @@ export class StructuredAgentSessionAcquireAborts { this.inFlight.set(sessionId, waits) return { signal: controller.signal, + abort: () => controller.abort(new Error('stopped while starting')), end: () => { waits.delete(active) if (waits.size === 0 && this.inFlight.get(sessionId) === waits) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts index 50162213bfb2..fa4ef169c00e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-agent-start.ts @@ -23,6 +23,7 @@ import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { randomUUID } from 'node:crypto' import { attachStructuredAgentSessionForStart } from './structured-agent-session-attach-orchestration' import type { StructuredAgentSessionAttachLock } from './structured-agent-session-attach-lock' @@ -59,31 +60,49 @@ export type StructuredAgentSessionResumeOutcome = /** The attach's caller key: the ledger row a start settles is Orca's own. */ const AGENT_START_CALLER_KEY = 'trusted-local:agent-start' -/** - * Gives the session a provider child if it has none, for a caller OUTSIDE its serialize. The short - * step that registers the start is what makes "if it has none" exact: a second asker finds the - * first one's child, or joins its start. A failed start leaves the next asker to make its own. - */ +export type StructuredAgentSessionStartClaim = { + outcome: Promise +} + +/** Claims the effect in the ordered decision that needs it; waiting happens outside that lane. */ +export function claimStructuredAgentSessionAgent( + context: StructuredAgentSessionAttachContext, + sessionId: string, + startedFor?: string +): StructuredAgentSessionStartClaim { + const session = context.sessions.get(sessionId) + const child = session?.child + if ( + startedFor && + !session?.journal + .submissions() + .some( + (submission) => + submission.clientMessageId === startedFor && isQueuedAgentJournalSubmission(submission) + ) + ) { + return { outcome: Promise.resolve({ ok: false, aborted: true, refusal: startFailedRefusal() }) } + } + if (child && !child.close) { + return { outcome: Promise.resolve({ ok: true }) } + } + return { + outcome: + context.runtimeState.startFlights.get(sessionId)?.outcome ?? + registerStart(context, sessionId, startedFor) + } +} + +/** Convenience for callers outside the lane; the claim itself never awaits its execution. */ export async function startStructuredAgentSessionAgent( context: StructuredAgentSessionAttachContext, sessionId: string, startedFor?: string ): Promise { - // Wrapped, so the step settles at registration and never waits on the start it registered. - const registered = await context.serialize<{ - outcome: Promise - }>(sessionId, async () => { - const child = context.sessions.get(sessionId)?.child - if (child && !child.close) { - return { outcome: Promise.resolve({ ok: true }) } - } - return { - outcome: - context.runtimeState.startFlights.get(sessionId)?.outcome ?? - registerStart(context, sessionId, startedFor) - } - }) - return registered.outcome + const claim = await context.serialize(sessionId, async () => + claimStructuredAgentSessionAgent(context, sessionId, startedFor) + ) + return claim.outcome } /** The same, for an operation's admission: a start that throws is that operation's refusal. */ @@ -91,15 +110,26 @@ export function startStructuredAgentSessionAgentForOperation( context: StructuredAgentSessionAttachContext, sessionId: string ): Promise { - return startStructuredAgentSessionAgent(context, sessionId).catch((error: unknown) => { - // The error is Orca's own and goes to the log; the refusal says only that the start failed. - context.deps.logger.warn('starting the agent for an operation failed', { - scope: 'operation-agent-start', - sessionId, - error + return context + .serialize(sessionId, async () => claimOperationStart(context, sessionId)) + .then((claim) => claim.outcome) +} + +export function claimOperationStart( + context: StructuredAgentSessionAttachContext, + sessionId: string +): StructuredAgentSessionStartClaim { + const claim = claimStructuredAgentSessionAgent(context, sessionId) + return { + outcome: claim.outcome.catch((error: unknown) => { + context.deps.logger.warn('starting the agent for an operation failed', { + scope: 'operation-agent-start', + sessionId, + error + }) + return { ok: false, refusal: startFailedRefusal() } }) - return { ok: false, refusal: startFailedRefusal() } - }) + } } /** Inside the register step, before any wait: the controller a Stop, close or quit aborts, the @@ -140,7 +170,11 @@ function registerStart( runtimeState.startupAttempts.abandon(sessionId, attemptId) }) ) - runtimeState.startFlights.register(sessionId, { attemptId, outcome }) + runtimeState.startFlights.register(sessionId, { + attemptId, + outcome, + abort: acquire.abort + }) return outcome } 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 8c3fc32e0b69..3b2b9e1ec4fb 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,6 +14,8 @@ import { // queue — so leaving it inline made the host grow every time any of them did. The host keeps the // state; this owns the ordering between them. +import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' +import { refuse } from '../../../shared/agent-session-wire-refusals' import { randomUUID } from 'node:crypto' import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree' import type { @@ -89,9 +91,38 @@ export function attachStructuredAgentSession( // evicts, so no child is spawned behind the eviction and orphaned. const run = (recordPhase?: AgentSessionCreatePhaseRecorder) => context.tasks.trackAttach( - context.serialize(sessionId, () => - runAttach(context, callerKey, params, { ...options, recordPhase }) - ) + (async () => { + for (;;) { + const selected = await context.serialize(sessionId, async () => { + const child = context.sessions.get(sessionId)?.child + const flight = context.runtimeState.startFlights.get(sessionId) + // The owning flight alone publishes its acquisition; replay waits outside its lane. + if (!child && flight) { + return { flight } + } + const record = context.deps.store.getRecord(sessionId) + if (!child && record && agentSessionLeaseAdmitsWriter(record.lease)) { + return { + result: { + ok: false as const, + refusal: refuse( + 'agent_session_ownership_unknown', + { reason: 'noProviderChild' }, + 'The session has no provider child to write to.' + ) + } + } + } + return { + result: await runAttach(context, callerKey, params, { ...options, recordPhase }) + } + }) + if ('result' in selected) { + return selected.result + } + await selected.flight.outcome + } + })() ) if (params.envelope.expectedRuntimeFence !== null) { return run() @@ -272,7 +303,7 @@ async function runAttachUnderAbort( if (barrier && !barrier.ok) { throw barrier.error } - if (owner === 'kept' && (attempt.bound?.current?.phase ?? 'ready') === 'ready') { + if (owner === 'kept' && attempt.bound?.current?.phase === 'ready') { attempt.rewind = await readStructuredRewindRecovery( context.deps, sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-publish.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-publish.ts index acf745ed9e10..f2fba191dc1e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-publish.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-publish.ts @@ -58,18 +58,20 @@ export async function publishAttachedChild( owner === 'acquired' ? attempt.startup?.permissionMode : current?.permissionMode const startedFor = owner === 'acquired' ? input.startedFor : current?.startedFor // A replayed create over a chat at rest has no child to index. - if (owner !== 'none') { - attempt.candidate = { - sink: eventSink, - child: { - generation: acquisitionGeneration ?? current?.generation ?? null, - fence, - // A spawned child starts unready; a re-attach keeps what its child already proved. - phase: owner === 'acquired' ? 'starting' : (current?.phase ?? 'ready'), - ...(startedFor === undefined ? {} : { startedFor }), - ...(permissionMode === undefined ? {} : { permissionMode }) - } - } + const child = + owner === 'acquired' + ? { + generation: acquisitionGeneration, + fence, + phase: 'starting' as const, + ...(startedFor === undefined ? {} : { startedFor }), + ...(permissionMode === undefined ? {} : { permissionMode }) + } + : owner === 'kept' && current + ? { ...current, fence } + : null + if (child) { + attempt.candidate = { sink: eventSink, child } } if (attempt.candidate?.child.phase === 'ready') { await applyStructuredRewindRecovery( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index f8de1f80cc0f..102c63bd6fb5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -144,11 +144,12 @@ export function mutateWithChatStop( ) } const stop = async (ctx: AgentSessionTurnContext): Promise => { + const flight = context.startFlights.get(sessionId) const target = captureChatStopTarget(ctx, { child: context.sessions.get(ctx.sessionId)?.child ?? null, ...named, endsSession: ctx.adapter.stopEndsSession?.(ctx.sessionId) === true, - stoppedPrePrompt: params.stoppedPrePrompt + stoppedPrePrompt: params.stoppedPrePrompt || flight !== undefined }) if (!target) { // Late, or nothing to stop: answered as a no-op, its receipt committed before the answer. @@ -158,6 +159,10 @@ export function mutateWithChatStop( if (!accepted.ok) { return accepted } + // Invalidation belongs to the accepted Stop, including a claim made after its early abort. + if (flight && (target.queued || target.reach === 'withdraw' || target.reach === 'starting')) { + flight.abort() + } if (params.stoppedPrePrompt && target.reach === 'withdraw') { await writeStructuredAgentSessionStopNote( ctx, @@ -167,7 +172,7 @@ export function mutateWithChatStop( ) } try { - return await act(ctx, target, accepted.value.settled) + return await act(ctx, target, accepted.value.settled || flight !== undefined) } catch (error) { // Saved, then failed: noted on the chat, then thrown so the answer is the Stop's receipt. // On the turn its event named, else the one it named, else the one live now. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 9bf020633a55..39a923cc7790 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 @@ -241,6 +241,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') return serialize(sessionId, async () => { readRefusals.forget(sessionId) + host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') const session = sessions.get(sessionId) if (session) { // Settled and marked before the stop, so no start delivers it, and nothing it closed with 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 fe0d3f0b394b..c4db2ce7f3f1 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 @@ -26,7 +26,7 @@ import { structuredAgentSessionStartFailure, type StructuredAgentSessionStartFailureCause } from './structured-agent-session-failure-text' -import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' +import type { StructuredAgentSessionStartClaim } from './structured-agent-session-agent-start' import type { StructuredAgentSessionHostSession, StructuredAgentSessionProviderChildIdentity @@ -61,12 +61,8 @@ export type StructuredAgentSessionDeliveryLoopDeps = { serialize: (sessionId: string, task: () => Promise) => Promise /** A start step, tracked from enqueue so quit waits for the child it may produce. */ trackStart: (start: Promise) => Promise - /** Starts a child for `startedFor`, the queued message at the head, if the session has none; - * outside `serialize`. */ - startProviderChild: ( - sessionId: string, - startedFor: string - ) => Promise + /** Claims the start for the queued head inside the ordered preparation. */ + startProviderChild: (sessionId: string, startedFor: string) => StructuredAgentSessionStartClaim /** Stops a child the chat outgrew (a new login or permission mode); inside `serialize`. */ retireOutgrownChild: (sessionId: string) => Promise readiness: ( @@ -95,7 +91,7 @@ export type StructuredAgentSessionDeliveryLoopDeps = { type Prepared = | 'stop' - | { start: string; acceptedBy: AgentJournalCursor } + | { start: StructuredAgentSessionStartClaim; acceptedBy: AgentJournalCursor } | { ok: true awaited: StructuredAgentSessionProviderChildIdentity @@ -158,7 +154,7 @@ export class StructuredAgentSessionDeliveryLoop { } if ('start' in prepared) { // Outside the serialize: sends are accepted while the agent starts. - const started = await this.deps.startProviderChild(sessionId, prepared.start) + const started = await prepared.start.outcome if (started.ok || started.aborted) { // Aborted: a close, Stop or quit stopped this start on purpose, and settled what it // was for; a message accepted since gets its own start, and quit's next step stops the @@ -251,7 +247,10 @@ export class StructuredAgentSessionDeliveryLoop { await this.deps.retireOutgrownChild(sessionId) const child = this.deps.sessions.get(sessionId)?.child if (!child || child.close) { - return { start: oldest.clientMessageId, acceptedBy: session.journal.cursor() } + return { + start: this.deps.startProviderChild(sessionId, oldest.clientMessageId), + acceptedBy: session.journal.cursor() + } } // The child this run waits on; handover checks it is still the one there. return { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts index be45b0a480c0..b26b3788bf51 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-execution-options.ts @@ -13,12 +13,13 @@ export function captureStructuredExecutionOptions( supplied?: Readonly> ): { options: AgentSessionExecutionOptions } | { refusal: AgentSessionWireRefusal } { const saved = ctx.persistedOptions ?? {} - const candidate = supplied ?? { + const candidate = { ...(saved.model ? { model: saved.model } : {}), ...(saved.effort ? { effort: saved.effort } : {}), ...(saved.personality ? { personality: saved.personality } : {}), ...(saved.fastMode ? { fastMode: saved.fastMode } : {}), - ...(saved.serviceTier ? { serviceTier: saved.serviceTier } : {}) + ...(saved.serviceTier ? { serviceTier: saved.serviceTier } : {}), + ...supplied } const options = readAgentSessionExecutionOptions(candidate) if (!options) { 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 2d0936032e08..a088bacad4fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -16,7 +16,7 @@ import type { StructuredAgentSessionClientDelivery } from './structured-agent-se import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' import { StructuredAgentSessionDeliveryLoop } from './structured-agent-session-delivery-loop' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' -import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' +import type { StructuredAgentSessionStartClaim } from './structured-agent-session-agent-start' import type { StructuredAgentSessionStartReadiness } from './structured-agent-session-startup-attempt' import type { StructuredAgentSessionHostDeps, @@ -60,12 +60,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessions: Map serialize: (sessionId: string, task: () => Promise) => Promise trackStart: (start: Promise) => Promise - /** Starts a child for `startedFor`, the queued message at the head, if the session has none; - * for a caller outside the session's serialize. */ - startProviderChild: ( - sessionId: string, - startedFor: string - ) => Promise + /** Claims the queued head's start during its ordered preparation. */ + startProviderChild: (sessionId: string, startedFor: string) => StructuredAgentSessionStartClaim deliveryOrder: StructuredAgentSessionDeliveryOrder readiness: ( sessionId: string, 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 fdd74c7c4d13..8f502d737423 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 @@ -120,7 +120,7 @@ export class StructuredAgentSessionHost { // Quit drains a delivery start before it evicts, so the child it produces is stopped. trackStart: (start) => this.tasks.trackAttach(start), startProviderChild: (sessionId, startedFor) => - agentStart.startStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), + agentStart.claimStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), deliveryOrder: this.runtimeState.deliveryOrder, readiness: (id, child) => this.runtimeState.startupAttempts.readiness(id, child), // Put to rest like an idle agent: the stop writes nothing, and the next start is new. @@ -283,8 +283,8 @@ export class StructuredAgentSessionHost { childWorkIncomplete: this.clientDelivery.childWorkIncomplete, serialize: (sessionId, task) => this.serialize(sessionId, task), openConversation: this.conversationDelivery.open, - startAgent: (sessionId) => - agentStart.startStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), + claimAgent: (sessionId) => agentStart.claimOperationStart(this.attachContext(), sessionId), + startFlights: this.runtimeState.startFlights, startupAttempts: this.runtimeState.startupAttempts, deliveryOrder: this.runtimeState.deliveryOrder, joinChildClose: (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 be1b6cc0ca49..ff51888df851 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts @@ -214,7 +214,7 @@ it.each([false, true])( } })) } - const startAgent = vi.spyOn(ctx, 'startAgent') + const startAgent = vi.spyOn(ctx, 'claimAgent') const error = new Error('receipt read failed') const warn = vi.spyOn(ctx.deps.logger, 'warn') vi.spyOn(rig.store, 'readCommandReceipt').mockImplementationOnce(() => { 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 1ea485244865..a0541a571fca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -17,6 +17,8 @@ import type { StructuredAgentSessionStopEnding } from './structured-agent-sessio import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' import type { CapturedChildWorkStops } from './structured-agent-session-status-ownership' +import type { StructuredAgentSessionStartClaim } from './structured-agent-session-agent-start' +import type { StructuredAgentSessionStartFlights } from './structured-agent-session-start-flight' import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' import { hostStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work' import type { @@ -41,9 +43,9 @@ export type StructuredAgentSessionMutationContext = { serialize: (sessionId: string, task: () => Promise) => Promise /** The session's conversation, opened when closed; inside the caller's serialize. */ openConversation: (sessionId: string) => Promise - /** Gives the session a provider child; OUTSIDE the caller's serialize, which the start's own - * commits take. */ - startAgent: (sessionId: string) => Promise + /** Claims a provider start on the caller's lane; its outcome is awaited outside. */ + claimAgent: (sessionId: string) => StructuredAgentSessionStartClaim + startFlights: Pick startupAttempts: Pick deliveryOrder: StructuredAgentSessionDeliveryOrder /** Joins a close a stop began on the session's child, for an operation that starts no child; 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 ab4056a2b60e..8f87491b94b0 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 @@ -254,25 +254,35 @@ export async function readStructuredAgentSessionOptions( const selected = live ? await readStructuredAgentSessionOptionsAtRest(context.deps, sessionId, reader) : options - const confirmed = options.current.confirmed?.filter((key) => { - if (key === 'model') { - return selected.current.model === options.current.model - } - if (key === 'effort') { - return selected.current.effort === options.current.effort - } - if (key === 'fastMode') { - return selected.current.fastMode === options.current.fastMode - } - if (key === 'serviceTier') { - return selected.current.serviceTier === options.current.serviceTier - } - return false - }) + const saved = record?.options ?? {} + const model = saved.model ?? (options.current.model || selected.current.model) + const current = { + ...selected.current, + ...options.current, + model, + effort: + saved.effort ?? + (model === options.current.model + ? options.current.effort + : model === selected.current.model + ? selected.current.effort + : undefined), + fastMode: + saved.fastMode !== undefined + ? selected.current.fastMode + : (options.current.fastMode ?? selected.current.fastMode), + serviceTier: saved.serviceTier ?? options.current.serviceTier ?? selected.current.serviceTier + } + const confirmed = options.current.confirmed?.filter((key) => + key === 'model' || key === 'effort' || key === 'fastMode' || key === 'serviceTier' + ? current[key] === options.current[key] + : false + ) return { ...options, current: { - ...selected.current, + ...current, + confirmed, ...(options.current.fastModeState ? { fastModeState: options.current.fastModeState } : {}), ...(confirmed?.length ? { confirmed } : {}), ...(permissionMode ? { permissionMode } : {}) 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 6d4a15a7e303..5ffe3b54b84b 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 @@ -22,6 +22,7 @@ import { commandReceiptScope } from '../agent-session-journal/command-receipt-sc import { parseAgentSessionOperationTimestamp } from '../../../shared/agent-session-host-authority' import { waitForStructuredAgentSessionStart } from './structured-agent-session-start-readiness' import type { StructuredAgentSessionDeliveryPlace } from './structured-agent-session-delivery-order' +import type { StructuredAgentSessionStartClaim } from './structured-agent-session-agent-start' import type { StructuredAgentSessionStartReadiness } from './structured-agent-session-startup-attempt' import { @@ -49,6 +50,7 @@ type QueuedReadyOperation = /** Null: the session has no child to run on yet, so the operation starts one outside. */ child: StructuredAgentSessionProviderChildIdentity | null readiness: StructuredAgentSessionStartReadiness | null + start: StructuredAgentSessionStartClaim | null place: StructuredAgentSessionDeliveryPlace } @@ -213,6 +215,7 @@ export async function runWithReadyStructuredAgentSessionOperation( ) const place = context.deliveryOrder.reserve(envelope.sessionId) return { + start: child.child ? null : context.claimAgent(envelope.sessionId), child: child.child, readiness: child.readiness, place @@ -225,9 +228,9 @@ export async function runWithReadyStructuredAgentSessionOperation( try { let { child, readiness } = selected if (!child) { - const started = await context.startAgent(envelope.sessionId) - if (!started.ok) { - return started + const started = await selected.start?.outcome + if (!started?.ok) { + return started ?? operationDidNotRun() } const selection = selectStructuredAgentSessionOperationChild(context, envelope.sessionId) if (!selection.ok || !selection.child) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts index b58d541ca727..e662b3633e24 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts @@ -28,6 +28,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { runWithReadyStructuredAgentSessionOperation } from './structured-agent-session-ready-operation' import { persistRewindRecord } from './structured-rewind-recovery' const CALLER = { callerKey: 'client-1' } @@ -67,6 +68,7 @@ let closeSession: Mock> let probeOwner: Mock<(record: unknown) => Promise> let delivered: string[] +let initialAttach: ReturnType let idleSweep: { intervalMs: number; idleMs: number } function startHost(): void { @@ -237,7 +239,8 @@ beforeEach(async () => { probeOwner = vi.fn(async () => PID_ABSENT) store = await openTestAgentSessionRecordStore(root) startHost() - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + initialAttach = hostTestAttachParams(null) + expect(await host.attach(CALLER, initialAttach)).toMatchObject({ ok: true }) }) afterEach(async () => { @@ -370,6 +373,84 @@ describe('a Stop while the agent start is held', () => { }) describe('a start racing the host', () => { + it('honors Stop admitted while preparation yields, then starts later work once', async () => { + await restart() + const { loop } = host.collaboratorsForTests().conversationDelivery + const preparation = Promise.withResolvers() + const entered = Promise.withResolvers() + vi.spyOn(loop['deps'], 'retireOutgrownChild').mockImplementationOnce(async () => { + entered.resolve() + await preparation.promise + }) + const spawned = acquire.mock.calls.length + const first = send('withdraw before claim') + await entered.promise + expect(host.collaboratorsForTests().runtimeState.startFlights.has(SESSION)).toBe(false) + const stopped = stop() + preparation.resolve() + expect(await stopped).toMatchObject({ ok: true }) + await eventually(async () => + expect((await submission(first.id))?.dispatchState).toBe('rejected') + ) + await eventually(() => + expect(host.collaboratorsForTests().runtimeState.startFlights.has(SESSION)).toBe(false) + ) + expect(acquire).toHaveBeenCalledTimes(spawned) + expect(child()).toBeNull() + const later = send('new demand') + await eventually(async () => + expect((await submission(later.id))?.dispatchState).toBe('accepted') + ) + expect(delivered).toEqual([later.id]) + expect(acquire).toHaveBeenCalledTimes(spawned + 1) + }) + + it('replays eager attach through the unpublished flight without inventing readiness', async () => { + const { first, release, spawned } = await heldAt('drain') + await first.sent + expect(child()).toBeNull() + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + const opened = Promise.withResolvers() + holds.set('open', opened) + let operationRan = false + const operation = runWithReadyStructuredAgentSessionOperation( + host['mutationContext'](), + CALLER.callerKey, + envelope('agentSession.setOption', {}), + { method: 'agentSession.setOption', fields: {} }, + async () => { + operationRan = true + return { + ok: true, + replayed: false, + fence: child()!.fence, + cursor: host.collaboratorsForTests().sessions.get(SESSION)!.journal.cursor(), + value: {} + } + } + ) + let replayed = false + const replay = host.attach(CALLER, initialAttach).then((result) => { + replayed = true + return result + }) + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + expect(replayed).toBe(false) + expect(child()).toBeNull() + release() + expect(await replay).toMatchObject({ ok: true, replayed: true }) + expect(child()).toMatchObject({ phase: 'starting', generation: `generation-${spawned + 1}` }) + expect(operationRan).toBe(false) + opened.resolve() + expect(await operation).toMatchObject({ ok: true }) + expect(operationRan).toBe(true) + await eventually(async () => + expect((await submission(first.id))?.dispatchState).toBe('accepted') + ) + expect(child()).toMatchObject({ phase: 'ready', generation: `generation-${spawned + 1}` }) + expect(acquire).toHaveBeenCalledTimes(spawned + 1) + }) + it('spawns nothing when quit lands before the spawn', async () => { const { first, release, spawned } = await heldAt('resolver') await first.sent diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-flight.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-flight.ts index 89c4372fbfb8..3747bf6e774f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-flight.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-flight.ts @@ -14,6 +14,7 @@ import type { StructuredAgentSessionResumeOutcome } from './structured-agent-ses export type StructuredAgentSessionStartFlight = { /** The start's generation: the startup attempt it mints carries the same id. */ + readonly abort: () => void readonly attemptId: string /** Settles once the start is over: its child published, refused, or aborted. */ readonly outcome: Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 2f45c8cc4a00..dc53536a6b1a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -155,7 +155,10 @@ function openHost(overrides: Partial = {}): void /** The host starting the agent with no message to deliver, as an operation that needs it does. */ function startAgent(): Promise { - return host['mutationContext']().startAgent(SESSION) + const context = host['mutationContext']() + return context + .serialize(SESSION, async () => context.claimAgent(SESSION)) + .then((claim) => claim.outcome) } beforeEach(async () => { diff --git a/src/main/pi/rpc-message-dispatch.ts b/src/main/pi/rpc-message-dispatch.ts index c172ba451233..3aa0fe8d4200 100644 --- a/src/main/pi/rpc-message-dispatch.ts +++ b/src/main/pi/rpc-message-dispatch.ts @@ -87,6 +87,7 @@ export async function dispatchPiRpcMessage( input.signal ) } catch (error) { + input.signal?.throwIfAborted() if (session.connection.closed) { return exited() } @@ -96,7 +97,7 @@ export async function dispatchPiRpcMessage( providerDiagnostic(error.message, 'person') ) } - throw error + throw new AgentSessionOptionRejectedError(error) } } input.signal?.throwIfAborted() diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 683c59460c5f..062582a06bd9 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -168,6 +168,31 @@ describe('Pi RPC session ownership and delivery', () => { expect(h.connection.sent).toMatchObject([{ type: 'prompt', message: 'chosen' }]) }) + it('rejects a known-unsent prompt when an option times out and preserves Stop reason', async () => { + const h = await setup() + const timeout = new Error('Pi did not answer set_model') + h.connection.requestOverride = (command) => + command === 'set_model' ? Promise.reject(timeout) : undefined + const input = { + sessionId, + fence: 7, + clientMessageId: 'unsent', + body: { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'go' }] + }, + executionOptions: { model: 'anthropic/model-2' } + } + await expect(h.adapter.dispatch(input)).rejects.toMatchObject({ + name: 'AgentSessionOptionRejectedError' + }) + expect(h.connection.sent).toEqual([]) + const controller = new AbortController() + controller.abort(timeout) + await expect(h.adapter.dispatch({ ...input, signal: controller.signal })).rejects.toBe(timeout) + }) + it('rejects stale fences and a Stop for a different turn before sending abort', async () => { const h = await setup() await expect( diff --git a/src/main/pi/rpc-session-startup.test.ts b/src/main/pi/rpc-session-startup.test.ts index 584e8020e926..6be7a232f95d 100644 --- a/src/main/pi/rpc-session-startup.test.ts +++ b/src/main/pi/rpc-session-startup.test.ts @@ -95,6 +95,46 @@ describe('Pi host awaited open', () => { }) }) + it('learns the immutable opening model and effort while listing spans a message switch', async () => { + const h = await setup() + const listing = Promise.withResolvers() + let current = { ...state, thinkingLevel: 'low' } + const { acquired, child } = await restart(h, (connection) => { + connection.requestOverride = (command) => { + if (command === 'get_available_models') { + return listing.promise + } + if (command === 'get_state') { + return Promise.resolve(current) + } + if (command === 'set_model') { + current = { ...current, model: { provider: 'anthropic', id: 'model-2' } } + } + if (command === 'set_thinking_level') { + current = { ...current, thinkingLevel: 'high' } + return Promise.resolve({}) + } + return undefined + } + }) + await openAt8(h, acquired.acquisitionGeneration) + await h.adapter.dispatch({ + sessionId, + fence: 8, + clientMessageId: 'switch', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }, + executionOptions: { model: 'anthropic/model-2', effort: 'high' } + }) + listing.resolve({ models: [state.model, current.model] }) + await vi.waitFor(() => + expect(reportedEvent(h.lifecycle, 'options-reported')).toMatchObject({ + reportedOptions: { model: 'anthropic/model-2', effort: 'high' }, + catalogListing: { configuredDefault: { modelId: 'anthropic/model-1', effort: 'low' } } + }) + ) + expect(child.sent).toMatchObject([{ type: 'prompt', message: 'go' }]) + }) + it('teaches the account default only from a new session started with no pick', async () => { const h = await setup() await vi.waitFor(() => diff --git a/src/main/pi/rpc-session-startup.ts b/src/main/pi/rpc-session-startup.ts index ae00361e90ba..518ebe4650be 100644 --- a/src/main/pi/rpc-session-startup.ts +++ b/src/main/pi/rpc-session-startup.ts @@ -62,13 +62,21 @@ export class PiRpcSessionStartup { /** After the open: the listing and the `/` commands reach the host as later reports, and a read * that fails only leaves the chat without them. */ - async readAfterOpen(generation: string): Promise { + async readAfterOpen( + generation: string, + opening: AgentSessionOptionsResult['current'] + ): Promise { const options = await this.readListing() if (options && !this.failedCause() && !this.connection.closed) { // What it started on names its config's default once; a later switch teaches nothing. const { catalogListing } = withLiveCatalogListing( options, - unpickedSessionConfiguredChoice({ resolvesConfig: this.resolvesConfig, ...options }) + unpickedSessionConfiguredChoice({ + resolvesConfig: this.resolvesConfig, + picked: new Set(Object.keys(this.input.options ?? {})), + models: options.models, + current: opening + }) ) this.deps.onLifecycle({ type: 'options-reported', diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts index 9764821c0319..964ace72debb 100644 --- a/src/main/pi/rpc-session.ts +++ b/src/main/pi/rpc-session.ts @@ -164,13 +164,15 @@ export class PiRpcSession { ...(result.reportedOptions.effort ? { effort: result.reportedOptions.effort } : {}) } this.openedLink = result.link ?? null - void this.startup.readAfterOpen(this.generation).catch((error: unknown) => - this.deps.logger.warn('Pi startup reports failed', { - scope: 'pi-startup-reports', - sessionId: this.input.identity.sessionId, - error - }) - ) + void this.startup + .readAfterOpen(this.generation, result.reportedOptions) + .catch((error: unknown) => + this.deps.logger.warn('Pi startup reports failed', { + scope: 'pi-startup-reports', + sessionId: this.input.identity.sessionId, + error + }) + ) return result } diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 15572302954d..ca1fba31a525 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -88,6 +88,7 @@ export function NativeChatStructuredSession( queueFollowUps, hostStopping: hostExecution.stopping, optionSelection: hostExecution.optionSelection, + hostPermissionMode: hostExecution.permissionMode, hostOptionStamp: hostExecution.optionStamp, providerStarting: hostExecution.phase === 'starting', // A start's own verdict (a signed-out agent) exists only once that start is proven. diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index f37e749f1bb5..02280803591f 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -96,6 +96,7 @@ type HostExecutionSelection = { stopping: boolean /** The option values as one stamp: another device's pick moves it, so options re-read. */ optionStamp: string + permissionMode: AgentSessionStatusSummary['permissionMode'] optionSelection: AgentSessionStatusSummary['optionSelection'] } @@ -103,6 +104,7 @@ const EMPTY_HOST_EXECUTION: HostExecutionSelection = { phase: null, stopping: false, optionStamp: '', + permissionMode: undefined, optionSelection: undefined } @@ -121,6 +123,7 @@ export function useStructuredAgentSessionHostExecution( phase: summary?.hostExecutionPhase ?? null, stopping: summary?.stopping === true, optionStamp: `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}`, + permissionMode: summary?.permissionMode, optionSelection: structuralValuesEqual(previous.optionSelection, summary?.optionSelection) ? previous.optionSelection : summary?.optionSelection @@ -129,6 +132,7 @@ export function useStructuredAgentSessionHostExecution( previous.phase === next.phase && previous.stopping === next.stopping && previous.optionStamp === next.optionStamp && + previous.permissionMode === next.permissionMode && previous.optionSelection === next.optionSelection ) { return previous 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 1884b28abc3c..b0b2a27d5dac 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 @@ -7,9 +7,7 @@ import type { AgentSessionOptionCatalog } from '../../../../shared/agent-session import { applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, - commitStructuredAgentSessionOptionValues, createStructuredAgentSessionOptionState, - structuredAgentSessionOptionCatalog, type StructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' @@ -19,10 +17,7 @@ import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../../share import { useStructuredAgentSessionHostCapability } from '@/runtime/structured-agent-session-host-capability' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { readHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots' -import { - cloneNativeChatSessionOptionRecord, - createNativeChatSessionOptionRecord -} from '../../../../shared/native-chat-session-option-state' +import { cloneNativeChatSessionOptionRecord } from '../../../../shared/native-chat-session-option-state' import { createCoalescedPollRunner, type CoalescedPollRunner @@ -46,6 +41,7 @@ export function useStructuredAgentSessionOptionState(args: { /** The option values the host's status carries: a pick made on another device moves it. */ hostOptionStamp: string readsBeforeStart: boolean + hostPermissionMode?: string hostSelection: Readonly> | undefined launchSeedOptions: Readonly> | undefined held: Readonly> @@ -64,6 +60,7 @@ export function useStructuredAgentSessionOptionState(args: { providerVisible, readsBeforeStart, hostSelection, + hostPermissionMode, launchSeedOptions, held, sessionId, @@ -175,14 +172,12 @@ export function useStructuredAgentSessionOptionState(args: { if (current.record !== activeOptionRecordRef.current) { return current } - if (!selectionFromStatus) { - return applyStructuredAgentSessionOptions(current, optionCatalog, result) - } - if (result.modelsKnown === false) { - return current - } - const catalog = structuredAgentSessionOptionCatalog(optionCatalog, result) - return { ...current, catalog, catalogSource: 'live' } + return applyStructuredAgentSessionOptions( + current, + optionCatalog, + result, + selectionFromStatus + ) }) } }) @@ -250,7 +245,7 @@ export function useStructuredAgentSessionOptionState(args: { : undefined const defaultEffort = defaultModel?.options.find((option) => option.id === 'effort')?.kind const catalog = - optionState.catalog && selectionFromStatus + optionState.catalog && selectionFromStatus && defaultModel ? { ...optionState.catalog, defaultModelIsCliDefault: defaultModel ? (true as const) : undefined, @@ -277,24 +272,16 @@ export function useStructuredAgentSessionOptionState(args: { })) } : optionState.catalog - const selected = selectionFromStatus - ? commitStructuredAgentSessionOptionValues( - { ...optionState, catalog, record: createNativeChatSessionOptionRecord(agent) }, - hostSelection - ) - : optionState - // A pick on its way to the host shows, and so rides the next send, as the host will hold it. - const pending = optionIdentityRef.current === identity ? optionState.pendingPick : null return structuredAgentSessionOptionView( { - ...(pending - ? commitStructuredAgentSessionOptionValues(selected, { [pending.id]: pending.value }) - : selected), - pendingPick: pending + ...optionState, + catalog, + pendingPick: optionIdentityRef.current === identity ? optionState.pendingPick : null }, selectionFromStatus ? undefined : launchSeedOptions, held, - takesSendOptions + takesSendOptions, + { selection: hostSelection, permissionMode: hostPermissionMode } ) }, [ agent, @@ -304,6 +291,7 @@ export function useStructuredAgentSessionOptionState(args: { optionCatalog, held, hostSelection, + hostPermissionMode, launchSeedOptions, optionState, selectionFromStatus, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx index 2d1957bca36b..08453c4a86f2 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx @@ -233,3 +233,41 @@ it('fences a late pick when the same session moves to another host', async () => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') view.unmount() }) + +it('restores an unpicked chat’s observed model/effort and independent status permission', async () => { + const mutate = vi.fn() + const selection = {} + const view = renderHook( + ({ mode }: { mode: string }) => + useStructuredAgentSessionOptions({ + agent: 'codex', + sessionId: 'restored', + target: LOCAL, + transportEnabled: true, + isVisible: true, + providerVisible: true, + optionSelection: selection, + hostPermissionMode: mode, + fence: 1, + turnId: null, + unloadedTurnRevisions: undefined, + mutate + }), + { initialProps: { mode: 'ask' } } + ) + await waitFor(() => + expect(pill(view.result.current.optionSnapshot)).toEqual({ model: 'gpt-one', effort: 'medium' }) + ) + const permission = () => + view.result.current.optionSnapshot.find((option) => option.id === 'permissionMode')?.kind + .currentValue + expect(permission()).toBe('ask') + expect( + view.result.current.optionSnapshot.find((option) => option.id === 'model')?.valueSource + ).toBe('reported') + view.rerender({ mode: 'full-access' }) + expect(permission()).toBe('full-access') + expect(mutate).not.toHaveBeenCalled() + expect(mocks.settings).not.toHaveBeenCalled() + view.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 33b9fa5582ad..a20171c94206 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 @@ -45,6 +45,7 @@ export function useStructuredAgentSessionOptions(args: { providerRunning?: boolean hostOptionStamp?: string optionSelection?: Readonly> + hostPermissionMode?: string fence: number | null turnId: string | null unloadedTurnRevisions: number | undefined @@ -96,6 +97,7 @@ export function useStructuredAgentSessionOptions(args: { // holds the model it ran, so only the former waits rather than show the host's guess. readsBeforeStart: launch?.kind !== 'new', hostSelection: args.optionSelection, + hostPermissionMode: args.hostPermissionMode, launchSeedOptions, held, turnId, 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 f9042c535ac9..71cbf80bdf8a 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 @@ -57,6 +57,7 @@ export function useStructuredAgentSession(args: { providerRunning?: boolean /** The current selection in a capable host's status, including an empty selection. */ optionSelection?: Readonly> + hostPermissionMode?: string /** This view started the session; only then does the stored selection name what it runs. */ launch?: StructuredAgentSessionLaunchView /** The composer Edit copies a card's text into. */ @@ -123,6 +124,7 @@ export function useStructuredAgentSession(args: { providerVisible, providerStarting, optionSelection: args.optionSelection, + hostPermissionMode: args.hostPermissionMode, ...(args.providerRunning ? { providerRunning: true } : {}), ...(args.hostOptionStamp ? { hostOptionStamp: args.hostOptionStamp } : {}), fence: state.fence, diff --git a/src/shared/structured-agent-session-option-view.test.ts b/src/shared/structured-agent-session-option-view.test.ts index 2f86af051246..bc96919a1c88 100644 --- a/src/shared/structured-agent-session-option-view.test.ts +++ b/src/shared/structured-agent-session-option-view.test.ts @@ -4,6 +4,7 @@ import type { AgentSessionModelCatalogResult } from './agent-session-wire' import { applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, + canSetStructuredAgentSessionOption, createStructuredAgentSessionOptionState, structuredAgentSessionOptionSnapshot, type StructuredAgentSessionOptionState @@ -217,3 +218,36 @@ describe('nearestAgentEffort', () => { expect(nearestAgentEffort('turbo', ['low', 'high'])).toBeUndefined() }) }) + +it('offers, validates and snapshots the status model without carrying another model’s observation', () => { + const catalog = CLAUDE_SESSION_OPTION_CATALOG + const state = applyStructuredAgentSessionOptions( + fresh(), + catalog, + { + models: [SONNET, OPUS], + current: { model: 'sonnet', effort: 'high', permissionMode: 'ask' } + }, + true + ) + const view = structuredAgentSessionOptionView(state, undefined, {}, true, { + selection: { model: 'opus[1m]' } + }) + expect(shown(state, undefined, {}, true, { selection: { model: 'opus[1m]' } })).toEqual({ + model: 'opus[1m]', + effort: undefined + }) + expect(canSetStructuredAgentSessionOption(view, 'effort', 'xhigh')).toBe(true) + const picked = structuredAgentSessionOptionView( + { ...state, pendingPick: { id: 'effort', value: 'xhigh' } }, + undefined, + {}, + true, + { selection: { model: 'opus[1m]' } } + ) + expect( + structuredAgentSessionSendSelection('claude', structuredAgentSessionOptionSnapshot(picked)) + ).toEqual({ model: 'opus[1m]', effort: 'xhigh' }) + expect(state.record.model).toEqual({ value: 'sonnet', source: 'reported' }) + expect(state.record.valuesByModel['opus[1m]']).toBeUndefined() +}) diff --git a/src/shared/structured-agent-session-option-view.ts b/src/shared/structured-agent-session-option-view.ts index a05a41c00397..7f97789bae20 100644 --- a/src/shared/structured-agent-session-option-view.ts +++ b/src/shared/structured-agent-session-option-view.ts @@ -29,8 +29,23 @@ export function structuredAgentSessionOptionView( state: StructuredAgentSessionOptionState, seed: Readonly> | undefined, held: Readonly>, - takesSendOptions: boolean + takesSendOptions: boolean, + host?: { selection?: Readonly>; permissionMode?: string } ): StructuredAgentSessionOptionState { + const pending = state.pendingPick + if (host?.selection !== undefined || pending) { + state = { ...state, record: cloneNativeChatSessionOptionRecord(state.record) } + if (host?.selection !== undefined) { + state = commitStructuredAgentSessionOptionValues(state, host.selection) + } + if (pending) { + state = commitStructuredAgentSessionOptionValues(state, { [pending.id]: pending.value }) + } + state = { ...state, pendingPick: pending } + } + if (host?.permissionMode !== undefined && pending?.id !== AGENT_CHAT_PERMISSION_MODE_OPTION_ID) { + state = { ...state, permissionMode: host.permissionMode } + } const { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: heldPermission, ...heldSelection } = held const replaced = takesSendOptions ? structuredAgentSessionListedPicks(state, seed, heldSelection) diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index fb5bbbbeae86..aefbf5801695 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -142,7 +142,8 @@ export function applyStructuredAgentSessionModelCatalog( export function applyStructuredAgentSessionOptions( state: StructuredAgentSessionOptionState, seed: AgentSessionOptionCatalog, - result: AgentSessionOptionsResult + result: AgentSessionOptionsResult, + observed = false ): StructuredAgentSessionOptionState { if (result.current.fastMode === undefined) { clearTrackedSessionOption(state.record, result.current.model ?? null, 'fastMode') @@ -158,7 +159,7 @@ export function applyStructuredAgentSessionOptions( ...(result.current.fastMode !== undefined ? { fastMode: result.current.fastMode } : {}), ...(result.current.serviceTier ? { serviceTier: result.current.serviceTier } : {}) }, - result.current.confirmed ?? [] + observed ? undefined : (result.current.confirmed ?? []) ) return { ...state, @@ -167,7 +168,7 @@ export function applyStructuredAgentSessionOptions( ? state.catalog : structuredAgentSessionOptionCatalog(seed, result), catalogSource: result.modelsKnown === false ? state.catalogSource : 'live', - permissionMode: result.current.permissionMode ?? null + permissionMode: result.current.permissionMode ?? state.permissionMode } } @@ -269,7 +270,7 @@ export function commitStructuredAgentSessionOption( 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) { + if (effort?.source === 'dispatched') { setTrackedSessionOption(state.record, 'effort', effort.value, 'dispatched') } return { ...state, pendingPick: null } From 45c1d9e28cfb718a9b9b66756461cedaf5481fd0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:59:51 -0700 Subject: [PATCH 113/127] Retain dispatch authority and reconcile accepted options --- .../mobile-structured-option-snapshot.ts | 10 +- .../mobile-structured-session-status-feed.ts | 8 ++ .../use-mobile-structured-agent-options.ts | 94 ++++++++-------- ...mobile-structured-session-option-status.ts | 2 +- src/main/acp/acp-message-execution-options.ts | 4 +- .../acp-structured-host-option-hang.test.ts | 32 ++++-- src/main/acp/acp-structured-options.ts | 31 ++++-- .../acp/acp-structured-session-adapter.ts | 1 + ...codex-structured-dispatch-boundary.test.ts | 100 ++++++++++++++++++ .../codex/codex-structured-turn-open-wait.ts | 9 +- src/main/codex/codex-structured-turn-start.ts | 26 ++++- .../structured-agent-session-delivery-loop.ts | 8 +- ...structured-agent-session-delivery-order.ts | 31 ++++-- .../structured-agent-session-handover.ts | 6 ++ ...gent-session-ready-operation-order.test.ts | 30 ++++++ ...tructured-agent-session-ready-operation.ts | 6 +- src/main/pi/rpc-message-dispatch.ts | 19 ++-- src/main/pi/rpc-options.ts | 30 +++++- src/main/pi/rpc-session-adapter.test.ts | 35 ++++++ src/main/pi/rpc-session-adapter.ts | 9 +- src/main/pi/rpc-session.ts | 2 + .../StructuredAgentSessionStatusBridge.tsx | 2 +- ...hat-agent-message-sender-identity.test.tsx | 1 + ...e-structured-agent-session-option-state.ts | 13 ++- ...session-options.current-selection.test.tsx | 43 +++++++- .../use-structured-agent-session-options.ts | 61 ++++------- ...ructured-agent-session-status-feed.test.ts | 5 + .../structured-agent-session-status-feed.ts | 4 + .../structured-agent-session-option-view.ts | 13 ++- .../structured-agent-session-options.ts | 7 ++ 30 files changed, 494 insertions(+), 148 deletions(-) diff --git a/mobile/src/session/mobile-structured-option-snapshot.ts b/mobile/src/session/mobile-structured-option-snapshot.ts index 8b304ca06051..6ad0e636f622 100644 --- a/mobile/src/session/mobile-structured-option-snapshot.ts +++ b/mobile/src/session/mobile-structured-option-snapshot.ts @@ -11,7 +11,15 @@ export function commitMobileStructuredOptions( hostSelection: Readonly> | undefined, values: Readonly> ): StructuredAgentSessionOptionState { - return commitStructuredAgentSessionOptionValues(state, { ...hostSelection, ...values }) + return { + ...commitStructuredAgentSessionOptionValues(state, { + ...(state.reconciliation ? {} : hostSelection), + ...values + }), + reconciliation: { + permission: state.reconciliation?.permission || values.permissionMode !== undefined + } + } } /** Display, validation and send share the host selection and this phone's pending pick. */ diff --git a/mobile/src/session/mobile-structured-session-status-feed.ts b/mobile/src/session/mobile-structured-session-status-feed.ts index 12db68c71772..762a6c74dcad 100644 --- a/mobile/src/session/mobile-structured-session-status-feed.ts +++ b/mobile/src/session/mobile-structured-session-status-feed.ts @@ -15,6 +15,7 @@ import type { RpcClient } from '../transport/rpc-client' export type MobileStructuredSessionStatusFeed = { subscribe: (listener: () => void) => () => void + getSnapshotSample: (sessionId: string) => number getSnapshot: () => AgentSessionStatusSnapshot } @@ -49,6 +50,7 @@ function streamRefusal(value: unknown): { code?: string; message?: string } | nu } function createFeed(client: RpcClient): MobileStructuredSessionStatusFeed { + const snapshotSamples = new Map() let snapshot: AgentSessionStatusSnapshot = new Map() // `unavailable`: the host refused the method to phones; asked again only on a new connection. let stream: 'closed' | 'open' | 'unavailable' = 'closed' @@ -78,6 +80,11 @@ function createFeed(client: RpcClient): MobileStructuredSessionStatusFeed { revokeLive() return } + if (raw.type === 'snapshot') { + for (const session of raw.sessions) { + snapshotSamples.set(session.sessionId, (snapshotSamples.get(session.sessionId) ?? 0) + 1) + } + } setSnapshot(foldAgentSessionStatusEvent(snapshot, raw)) return } @@ -123,6 +130,7 @@ function createFeed(client: RpcClient): MobileStructuredSessionStatusFeed { listeners.delete(listener) } }, + getSnapshotSample: (sessionId) => snapshotSamples.get(sessionId) ?? 0, getSnapshot: () => snapshot } } diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index 060ab9a0e495..f9112b7a7cc5 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -1,4 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + createCoalescedPollRunner, + type CoalescedPollRunner +} from '../../../src/renderer/src/components/right-sidebar/coalesced-poll-runner' import type { AgentSessionConversationCommand } from '../../../src/shared/agent-session-conversation-command' import { isAgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle' import { structuredAgentSessionSeedCatalog } from '../../../src/shared/structured-agent-session-seed-catalog' @@ -123,48 +127,55 @@ export function useMobileStructuredAgentOptions(args: MobileStructuredAgentOptio onListingLanded }) + const optionsReadRef = useRef(null) useEffect(() => { if (!client || !sessionId || !enabled || !optionCatalog) { return } let stale = false - const readGeneration = optionMutationGeneration.current - void callAgentSession(client, 'agentSession.options', { sessionId }) - .then((result) => { - // This view keeps its latch; a later one may run a model picked here. - forgetMobileCreatedStructuredSession(sessionId) - // Not an option value: a pick made during the read leaves it standing. - if (!stale) { - setImageSupport({ - sessionId, - acceptsImages: result.acceptsImages, - statesPermissionMode: result.current?.permissionMode !== undefined - }) - } - if (!stale && optionMutationGeneration.current === readGeneration) { - setConversationSupport({ sessionId, commands: result.conversationCommands ?? [] }) - updateOptionState((current) => - current.record === activeOptionRecordRef.current - ? applyStructuredAgentSessionOptions(current, optionCatalog, result, observed) - : current - ) - } + const runner = createCoalescedPollRunner(() => { + const readGeneration = optionMutationGeneration.current + return callAgentSession(client, 'agentSession.options', { + sessionId }) - .catch(() => undefined) + .then((result) => { + // This view keeps its latch; a later one may run a model picked here. + forgetMobileCreatedStructuredSession(sessionId) + // Not an option value: a pick made during the read leaves it standing. + if (!stale) { + setImageSupport({ + sessionId, + acceptsImages: result.acceptsImages, + statesPermissionMode: result.current?.permissionMode !== undefined + }) + } + if (!stale && optionMutationGeneration.current === readGeneration) { + setConversationSupport({ sessionId, commands: result.conversationCommands ?? [] }) + updateOptionState((current) => + current.record === activeOptionRecordRef.current + ? applyStructuredAgentSessionOptions(current, optionCatalog, result, observed) + : current + ) + } + }) + .catch(() => undefined) + }) + optionsReadRef.current = runner + runner.run() return () => { stale = true + runner.dispose() } - }, [ - client, - enabled, - optionCatalog, - optionStamp, - observed, - listingsLanded, - sessionId, - fence, - updateOptionState - ]) + }, [client, enabled, optionCatalog, observed, sessionId, fence, updateOptionState]) + + const refresh = `${optionStamp}|${listingsLanded}` + const seenRefresh = useRef(refresh) + useEffect(() => { + if (seenRefresh.current !== refresh) { + seenRefresh.current = refresh + optionsReadRef.current?.run() + } + }, [refresh]) // A host predating per-chat modes (1.4.224 and older) states none: say what its chats run instead. const legacyHostPermissions = @@ -203,7 +214,7 @@ export function useMobileStructuredAgentOptions(args: MobileStructuredAgentOptio ) { return false } - const mutationGeneration = ++optionMutationGeneration.current + let mutationGeneration = ++optionMutationGeneration.current const isCurrent = (): boolean => activeOptionRecordRef.current === targetRecord && optionMutationGeneration.current === mutationGeneration @@ -229,6 +240,7 @@ export function useMobileStructuredAgentOptions(args: MobileStructuredAgentOptio return result.status !== 'rejected' } if (result.status === 'accepted') { + mutationGeneration = ++optionMutationGeneration.current const committed = result.value.options ?? { [id]: encoded } updateOptionState((current) => current.record === targetRecord && result.sameFence @@ -260,19 +272,7 @@ export function useMobileStructuredAgentOptions(args: MobileStructuredAgentOptio return false } finally { if (readsBack && isCurrent()) { - void callAgentSession(client, 'agentSession.options', { - sessionId - }) - .then((refreshed) => { - if (isCurrent()) { - updateOptionState((latest) => - latest.record === targetRecord - ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed, observed) - : latest - ) - } - }) - .catch(() => undefined) + optionsReadRef.current?.run() } if (isCurrent()) { updateOptionState((current) => diff --git a/mobile/src/session/use-mobile-structured-session-option-status.ts b/mobile/src/session/use-mobile-structured-session-option-status.ts index 4e0056631289..904877aa365b 100644 --- a/mobile/src/session/use-mobile-structured-session-option-status.ts +++ b/mobile/src/session/use-mobile-structured-session-option-status.ts @@ -30,7 +30,7 @@ export function useMobileStructuredSessionOptionStatus(args: { let previous = EMPTY_OPTION_STATUS return (): OptionStatus => { const summary = feed && sessionId ? feed.getSnapshot().get(sessionId) : undefined - const stamp = `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}` + const stamp = `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}|${JSON.stringify(summary?.optionSelection)}|${feed?.getSnapshotSample(sessionId ?? '')}` const selection = structuralValuesEqual(previous.selection, summary?.optionSelection) ? previous.selection : summary?.optionSelection diff --git a/src/main/acp/acp-message-execution-options.ts b/src/main/acp/acp-message-execution-options.ts index 4c142d544c9c..c684ef155536 100644 --- a/src/main/acp/acp-message-execution-options.ts +++ b/src/main/acp/acp-message-execution-options.ts @@ -24,14 +24,14 @@ export async function applyAcpMessageOptions( for (const key of ['model', 'effort'] as const) { const value = options?.[key] ?? (key === 'model' ? initial.model : defaultEffort) if (value === undefined) { - if (session.options.reported()[key] !== undefined) { + if (session.options.picked.has(key) || session.options.reported()[key] !== undefined) { throw new AgentSessionOptionRejectedError( new Error(`The default ${key} for this model is unknown`) ) } continue } - if (session.options.reported()[key] === value) { + if (session.options.currentProof.has(key) && session.options.reported()[key] === value) { continue } try { 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 7544fedeb1cc..7792ae071c31 100644 --- a/src/main/acp/acp-structured-host-option-hang.test.ts +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -87,7 +87,7 @@ describe('a Grok model pick Grok never answers during send', () => { expect(framesOf(rig.child(), 'session/prompt')).toEqual([]) }) - it('bounds its write, frees the chat, and keeps what Grok answers late', async () => { + it('re-establishes opening A after uncertain B without adopting B’s late reply', async () => { const { rig, host, store, fence } = await openAttachedHostRig({ optionWriteTimeoutMs: 100, script: (agent) => agent.on('session/set_config_option', () => {}) @@ -105,17 +105,31 @@ describe('a Grok model pick Grok never answers during send', () => { expect(framesOf(rig.child(), 'session/prompt')).toEqual([]) // The chat's queue moved on: a later operation runs. expect(await within(host.readOptions(SESSION), 1_000)).not.toBe('pending') - const [frame] = framesOf(rig.child(), 'session/set_config_option') - rig.child().agent.reply(frame!, { + const restore = rig.adapter.dispatch({ + sessionId: SESSION, + fence: fence(), + clientMessageId: 'restore-A', + executionOptions: { model: 'grok-4.7' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'A again' }] } + }) + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(2)) + const [old, current] = framesOf(rig.child(), 'session/set_config_option') + rig.child().agent.reply(current!, { configOptions: GROK_CONFIG_OPTIONS }) + await waitFor(() => expect(framesOf(rig.child(), 'session/set_config_option')).toHaveLength(3)) + rig.child().agent.reply(framesOf(rig.child(), 'session/set_config_option')[2]!, { + configOptions: GROK_CONFIG_OPTIONS + }) + await restore + expect(framesOf(rig.child(), 'session/prompt')).toHaveLength(1) + rig.child().agent.reply(old!, { configOptions: GROK_CONFIG_OPTIONS.map((option) => - option.id === 'model' ? { ...option, currentValue: 'grok-4.7' } : option + option.id === 'model' ? { ...option, currentValue: 'grok-4.6' } : option ) }) - await waitFor(async () => - expect( - (await rig.adapter.readOptions({ sessionId: SESSION, fence: fence() })).current.model - ).toBe('grok-4.7') - ) + await rig.settle() + expect( + (await rig.adapter.readOptions({ sessionId: SESSION, fence: fence() })).current.model + ).toBe('grok-4.7') expect((await host.readOptions(SESSION)).current.model).toBe(savedModel) expect(store.getRecord(SESSION)?.options?.model).toBe(savedModel) await host.close(SESSION, 'user-close') diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index d4441044aaaa..a052e7351f9b 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -63,8 +63,10 @@ export class AcpStructuredOptions { private commands: AgentSessionSlashCommand[] | undefined // A loaded session keeps the model it ran; only a new one resolves the agent's own config. private resolvesConfig = false - private readonly picked = new Set() + readonly picked = new Set() private initialChoice: Readonly> = {} + currentProof = new Set() + writeRevision = 0 /** The session state a new, load or resume response reported. */ adoptSession( @@ -78,6 +80,7 @@ export class AcpStructuredOptions { this.models = response.models ?? null this.resolvesConfig = origin === 'new' this.initialChoice = this.reported() + this.currentProof = new Set(Object.keys(this.initialChoice)) } /** The native selection before any message changed this child. */ @@ -235,26 +238,42 @@ export async function readAcpLiveOptions( return { ...withLiveCatalogListing(session.options.read()), acceptsImages } } -/** Sends a pick and adopts the agent's answer, even one that lands after the wait gave up: that is - * still what the agent runs. The wait fails at `timeoutMs`, or once `signal` aborts. */ +/** Only the current, still-awaited write may establish reusable option proof. */ export function writeAcpSessionOption( connection: Pick, options: AcpStructuredOptions, write: AcpOptionWrite, - bound: { agent: string; timeoutMs: number; signal?: AbortSignal } + bound: { agent: string; timeoutMs: number; signal?: AbortSignal; key: string } ): Promise { const { agent, timeoutMs, signal } = bound if (signal?.aborted) { return Promise.reject(abortSignalReason(signal)) } + const revision = ++options.writeRevision + options.currentProof.delete(bound.key) + if (bound.key === 'model') { + options.currentProof.delete('effort') + } + const adopt = (apply: () => void): void => { + if (options.writeRevision !== revision) { + throw new Error(`${agent} option write was superseded`) + } + apply() + options.currentProof.add(bound.key) + } const applied = write.method === 'config' ? connection .setConfigOption(write.configId, write.value) - .then((result) => options.adoptConfigOptions(result.configOptions)) - : connection.setModel(write.modelId).then(() => options.adoptModel(write.modelId)) + .then((result) => adopt(() => options.adoptConfigOptions(result.configOptions))) + : connection + .setModel(write.modelId) + .then(() => adopt(() => options.adoptModel(write.modelId))) return new Promise((resolve, reject) => { const fail = (error: Error): void => { + if (options.writeRevision === revision) { + options.writeRevision += 1 + } settle() reject(error) } diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index ce0186f2d7a0..a502c05fa498 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -258,6 +258,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte session.options.notePick(input.key) // Bounded, and abandoned by a close or Stop: the session's queue waits on it. await writeAcpSessionOption(session.connection, session.options, write, { + key: input.key, agent: session.spec.agent, timeoutMs: this.deps.optionWriteTimeoutMs ?? ACP_OPTION_WRITE_TIMEOUT_MS, ...(input.signal ? { signal: input.signal } : {}) diff --git a/src/main/codex/codex-structured-dispatch-boundary.test.ts b/src/main/codex/codex-structured-dispatch-boundary.test.ts index 5ad36626c46b..7e92abc06318 100644 --- a/src/main/codex/codex-structured-dispatch-boundary.test.ts +++ b/src/main/codex/codex-structured-dispatch-boundary.test.ts @@ -1,8 +1,12 @@ import { expect, it, vi } from 'vitest' import { AgentSessionPreDispatchError } from '../native-chat/agent-session-wire/structured-agent-session-operation-settlement' import { + type LateSettlement, acquiredCodexAdapter, CODEX_TEST_USER_MESSAGE, + startTurn, + echoUserMessage, + refuseUnroutedSteer, fakeCodexAppServer } from './codex-structured-dispatch-test-support' @@ -28,3 +32,99 @@ it('checks continuation authority before writing turn/start', async () => { await adapter.closeAll() } }) + +it('stops a refused steer before its fallback write', async () => { + const refused = Promise.withResolvers() + const codex = fakeCodexAppServer({ 'turn/steer': () => refused.promise }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'running') + const controller = new AbortController() + const reason = new Error('Stopped before fallback') + const send = (id: string) => + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: id, + fence: 7, + body: CODEX_TEST_USER_MESSAGE, + signal: controller.signal + }) + const sending = send('stopped') + await vi.waitFor(() => + expect(connection.calls.some((call) => call.method === 'turn/steer')).toBe(true) + ) + controller.abort(reason) + try { + refuseUnroutedSteer(undefined) + } catch (error) { + refused.reject(error) + } + await expect(sending).rejects.toBe(reason) + expect(connection.calls.some((call) => call.method === 'turn/start')).toBe(false) + echoUserMessage(connection, { turnId: 'running', itemId: 'stopped', clientId: 'stopped' }) + expect(settlements).toEqual([]) + await adapter.closeAll() +}) + +it('abandons only the stopped message while waiting for a previous turn to open', async () => { + const codex = fakeCodexAppServer({ 'turn/start': () => ({ turn: { id: 'opening' } }) }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + const dispatch = (id: string, signal?: AbortSignal) => + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: id, + fence: 7, + body: CODEX_TEST_USER_MESSAGE, + signal + }) + await expect(dispatch('first')).resolves.toEqual({ state: 'admitted' }) + const controller = new AbortController() + const stopped = dispatch('waiting', controller.signal) + await new Promise((resolve) => setTimeout(resolve, 10)) + const reason = new Error('Stopped local wait') + controller.abort(reason) + await expect(stopped).rejects.toBe(reason) + expect(connection.calls.filter((call) => call.method === 'turn/start')).toHaveLength(1) + startTurn(connection, 'opening') + echoUserMessage(connection, { turnId: 'opening', itemId: 'ignored', clientId: 'waiting' }) + expect(settlements).toEqual([]) + await adapter.closeAll() +}) + +it.each(['accepted', 'uncertain'] as const)( + 'retains the %s outcome of a steer written before Stop', + async (outcome) => { + const answer = Promise.withResolvers() + const codex = fakeCodexAppServer({ 'turn/steer': () => answer.promise }) + const settlements: LateSettlement[] = [] + const adapter = await acquiredCodexAdapter({ codex, settlements }) + const connection = codex.connections[0]! + startTurn(connection, 'running') + const controller = new AbortController() + const sent = adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'written', + fence: 7, + body: CODEX_TEST_USER_MESSAGE, + signal: controller.signal + }) + await vi.waitFor(() => + expect(connection.calls.some((call) => call.method === 'turn/steer')).toBe(true) + ) + controller.abort(new Error('Stop after write')) + if (outcome === 'accepted') { + answer.resolve({ turnId: 'running' }) + await expect(sent).resolves.toEqual({ state: 'admitted' }) + } else { + const lost = new Error('Reply lost after write') + answer.reject(lost) + await expect(sent).rejects.toBe(lost) + } + echoUserMessage(connection, { turnId: 'running', itemId: 'written', clientId: 'written' }) + expect(settlements.map((entry) => entry.clientMessageId)).toEqual(['written']) + await adapter.closeAll() + } +) diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts index 9dd071656f35..6e7a575f2fa0 100644 --- a/src/main/codex/codex-structured-turn-open-wait.ts +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -2,6 +2,7 @@ // to: it ended, the thread stopped running, or the child is gone. Held in memory only. import type { AgentSessionCancelOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' import { codexThreadStoppedRunning, @@ -142,7 +143,8 @@ export async function interruptOpeningCodexTurn( * by then. */ export async function codexRunningOrOpeningTurn( - session: CodexOpeningTurnSession + session: CodexOpeningTurnSession, + signal?: AbortSignal ): Promise { const running = [...(session.activeTurnIds ?? [])].at(-1) if (running) { @@ -155,7 +157,10 @@ export async function codexRunningOrOpeningTurn( if (!answered) { return null } - await session.turnOpenWaits.wait(answered, CODEX_TURN_OPEN_WAIT_MS) + await waitForPromiseWithSignal( + session.turnOpenWaits.wait(answered, CODEX_TURN_OPEN_WAIT_MS), + signal + ) if (session.activeTurnIds?.has(answered)) { return answered } diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index bb75243e4827..9d9780a65aa2 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -42,6 +42,13 @@ export function isCodexTurnOptionKey(key: string): boolean { return CODEX_TURN_OPTION_KEYS.has(key) } +function checkDispatchAuthority(host: CodexTurnHost, id: string, signal?: AbortSignal): void { + if (signal?.aborted) { + host.dispatchEchoes.disarm(id) + signal.throwIfAborted() + } +} + /** The session state one turn needs. */ export type CodexTurnHost = { account?: AgentSessionAccountKind @@ -79,9 +86,15 @@ function turnInputFor(body: AgentJournalMessageItem): Record[] async function steerCodexTurn( host: CodexTurnHost, expectedTurnId: string, - input: { clientMessageId: string; body: AgentJournalMessageItem; timeoutMs?: number } + input: { + clientMessageId: string + body: AgentJournalMessageItem + timeoutMs?: number + signal?: AbortSignal + } ): Promise<{ turnId: string; via: 'steer' } | null> { try { + checkDispatchAuthority(host, input.clientMessageId, input.signal) const answer = await host.connection.request( 'turn/steer', { @@ -114,6 +127,7 @@ export async function startCodexTurn( requestedAt?: number timeoutMs?: number turnOptions?: Readonly> + signal?: AbortSignal } ): Promise<{ turnId: string | null; via: AgentJournalTurnJoin } | false> { // Armed before the write: the echo and `turn/started` can both land while the @@ -125,7 +139,13 @@ export async function startCodexTurn( // the send opens its own turn. const steerable = (turnId: string | null | undefined): turnId is string => typeof turnId === 'string' && turnId !== '' && !host.abortedTurnIds?.has(turnId) - const runningTurnId = await codexRunningOrOpeningTurn(host) + let runningTurnId: string | null + try { + runningTurnId = await codexRunningOrOpeningTurn(host, input.signal) + } catch (error) { + host.dispatchEchoes.disarm(input.clientMessageId) + throw error + } let steered = steerable(runningTurnId) ? await steerCodexTurn(host, runningTurnId, input) : null // Refused because a turn Orca heard of meanwhile is running: steer that one, once. const runningSince = steered ? undefined : [...(host.activeTurnIds ?? [])].at(-1) @@ -135,6 +155,7 @@ export async function startCodexTurn( if (steered) { return steered } + checkDispatchAuthority(host, input.clientMessageId, input.signal) const previousApplied = host.appliedTurnOptions const previousOptions = input.turnOptions ? new Map(host.options) : null if (input.turnOptions) { @@ -187,6 +208,7 @@ export async function dispatchCodexTurn( body: AgentJournalMessageItem requestedAt?: number turnOptions?: Readonly> + signal?: AbortSignal }, timeoutMs: number | undefined ): Promise { 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 c4db2ce7f3f1..101164476b42 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 @@ -188,7 +188,10 @@ export class StructuredAgentSessionDeliveryLoop { // The provider call runs outside; its resolution is the next commit. Tracked, so quit's // drain waits for both after its abort ends the call. await this.deps.trackStart( - handed.dispatch().then((resolve) => this.deps.serialize(sessionId, resolve)) + handed + .dispatch() + .then((resolve) => this.deps.serialize(sessionId, resolve)) + .finally(() => handed.release?.()) ) } } @@ -326,7 +329,8 @@ export class StructuredAgentSessionDeliveryLoop { childWork: () => this.deps.readChildWork(sessionId), childWorkOpen: () => this.deps.childWorkOpen(sessionId), now: this.deps.now, - acquireAborts: this.deps.acquireAborts + acquireAborts: this.deps.acquireAborts, + deliveryOrder: this.deps.deliveryOrder }, next, // Re-read right before the provider call, which runs outside the serialize. 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 8347f64c5127..382b41a197ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-order.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-order.ts @@ -10,9 +10,10 @@ type Lane = { tail: Promise places: Set sends: Map + active: Set } -/** One delivery order; journal commits retire sends, bounded callers retire operations. */ +/** One delivery order; active handovers retain their place through settlement. */ export class StructuredAgentSessionDeliveryOrder { private readonly lanes = new Map() @@ -21,7 +22,7 @@ export class StructuredAgentSessionDeliveryOrder { const lane = this.lanes.get(sessionId) const ids = new Set(queued.map((submission) => submission.clientMessageId)) for (const [id, place] of lane?.sends ?? []) { - if (!ids.has(id)) { + if (!ids.has(id) && !lane?.active.has(id)) { lane?.sends.delete(id) place.release() } @@ -41,7 +42,8 @@ export class StructuredAgentSessionDeliveryOrder { const lane = this.lanes.get(sessionId) ?? { tail: Promise.resolve(), places: new Set(), - sends: new Map() + sends: new Map(), + active: new Set() } this.lanes.set(sessionId, lane) const done = Promise.withResolvers() @@ -64,13 +66,30 @@ export class StructuredAgentSessionDeliveryOrder { return this.lanes.get(sessionId)?.sends.get(messageId)?.before ?? Promise.resolve() } + activateSend(sessionId: string, messageId: string): () => void { + const lane = this.lanes.get(sessionId) + const place = lane?.sends.get(messageId) + lane?.active.add(messageId) + return () => { + lane?.active.delete(messageId) + lane?.sends.delete(messageId) + place?.release() + } + } + + hasActiveSend(sessionId: string): boolean { + return (this.lanes.get(sessionId)?.active.size ?? 0) > 0 + } + /** Queued messages keep their journal rows, but stop ordering operations when delivery parks. */ parkSends(sessionId: string): void { const lane = this.lanes.get(sessionId) - for (const place of lane?.sends.values() ?? []) { - place.release() + for (const [id, place] of lane?.sends ?? []) { + if (!lane?.active.has(id)) { + lane?.sends.delete(id) + place.release() + } } - lane?.sends.clear() } dispose(): void { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts index 156f8910947b..ceef1bb1dbc8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts @@ -23,17 +23,20 @@ import { structuredAgentSessionHandoverOrigin, type StructuredAgentSessionCommandHandoverContext } from './structured-agent-session-command-turn' +import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' import { carriedStoppedHistory } from './structured-agent-session-stopped-history-selection' import { withStoppedHistory } from '../../../shared/agent-session-stopped-history' export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverContext & { acquireAborts?: Pick + deliveryOrder?: StructuredAgentSessionDeliveryOrder } /** A handover whose pending row and abort handle are committed: the provider call runs outside the * session's serialize and answers with the commit that resolves it. */ export type StructuredAgentSessionPendingHandover = { dispatch: () => Promise<() => Promise> + release?: () => void } /** @@ -68,6 +71,7 @@ export async function beginSubmissionHandover( // on this same row before anything is written. const turnScope = contextStructuredAgentSessionCurrentWork(ctx).turnScope() const carried = carriedStoppedHistory(ctx, turnScope, submission, body) + const release = ctx.deliveryOrder?.activateSend(ctx.sessionId, clientMessageId) const attempt = ctx.acquireAborts?.begin(ctx.sessionId, true) try { await ctx.journal.resolveDispatch({ @@ -78,6 +82,7 @@ export async function beginSubmissionHandover( ...(carried ? { carries: carried.covers } : {}) }) } catch (error) { + release?.() attempt?.end() throw error } @@ -85,6 +90,7 @@ export async function beginSubmissionHandover( // live counter never re-anchors at turn-open. const requestedAt = structuredAgentSessionHandoverOrigin(ctx.journal, submission) return { + release, dispatch: async () => { let outcome: AgentSessionDispatchOutcome | null try { 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 1539248f2df0..4400679b6108 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts @@ -96,3 +96,33 @@ it('rechecks a goal retry settled during startup, and replays it later without a expect(acquire).toHaveBeenCalledTimes(2) expect(changeThreadGoal).toHaveBeenCalledTimes(3) }) + +it.each([false, true])('keeps a goal behind active dispatch (ready child: %s)', async (ready) => { + const { host, dispatch } = hostTestState() + const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) + host.deps.adapter.changeThreadGoal = changeThreadGoal + await attach() + if (ready) { + const change = { kind: 'set' as const, objective: 'Warm child' } + await host.changeThreadGoal(CALLER, { + envelope: envelope('agentSession.threadGoal', { change }), + change + }) + changeThreadGoal.mockClear() + } + const held = Promise.withResolvers<{ state: 'admitted' }>() + dispatch.mockImplementationOnce(() => held.promise) + const body = hostTestMessage('Earlier message') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + const change = { kind: 'set' as const, objective: 'Later goal' } + const goal = host.changeThreadGoal(CALLER, { + envelope: envelope('agentSession.threadGoal', { change }), + change + }) + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + expect(changeThreadGoal).not.toHaveBeenCalled() + held.resolve({ state: 'admitted' }) + expect(await goal).toMatchObject({ ok: true }) + expect(changeThreadGoal).toHaveBeenCalledOnce() +}) 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 5ffe3b54b84b..7ba3b715f8c6 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 @@ -206,7 +206,11 @@ export async function runWithReadyStructuredAgentSessionOperation( if (!child.ok) { return { result: child } } - if (child.child && !child.readiness) { + if ( + child.child && + !child.readiness && + !context.deliveryOrder.hasActiveSend(envelope.sessionId) + ) { return { result: await run({ ok: true, child: child.child, deadline }) } } context.deliveryOrder.reconcile( diff --git a/src/main/pi/rpc-message-dispatch.ts b/src/main/pi/rpc-message-dispatch.ts index 3aa0fe8d4200..a0dbf15fa67c 100644 --- a/src/main/pi/rpc-message-dispatch.ts +++ b/src/main/pi/rpc-message-dispatch.ts @@ -6,7 +6,6 @@ import { import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import { ClaudeDispatchContentError } from '../claude/claude-structured-dispatch-content' import { JsonlRpcResponseError } from '../jsonl-rpc/peer' -import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter @@ -69,22 +68,22 @@ export async function dispatchPiRpcMessage( (key === 'model' || model === session.initialOptions.model ? session.initialOptions[key] : undefined) - if (value === undefined && session.selected.has(key)) { + if (value === undefined && session.optionProof.writes.has(key)) { throw new AgentSessionOptionRejectedError( new Error(`The default ${key} for this model is unknown`) ) } - if ( - value === undefined || - session.selected.get(key) === value || - (!session.selected.has(key) && session.initialOptions[key] === value) - ) { + if (value === undefined || session.optionProof.current.get(key) === value) { continue } try { - await waitForPromiseWithSignal( - applyPiRpcSessionOption(session.connection, session.selected, key, value), - input.signal + await applyPiRpcSessionOption( + session.connection, + session.selected, + key, + value, + { signal: input.signal }, + session.optionProof ) } catch (error) { input.signal?.throwIfAborted() diff --git a/src/main/pi/rpc-options.ts b/src/main/pi/rpc-options.ts index b881d31f1729..8f91dc3b0c0d 100644 --- a/src/main/pi/rpc-options.ts +++ b/src/main/pi/rpc-options.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import type { AgentSessionCommandsResult, AgentSessionOptionsResult @@ -88,26 +89,49 @@ export async function readPiRpcSessionOptions( } } +export type PiRpcOptionProof = { current: Map; writes: Map } + export async function applyPiRpcSessionOption( rpc: PiRpcRequester, selected: Map, key: string, value: string, - options: { timeoutMs?: number | null } = {} + options: { timeoutMs?: number | null; signal?: AbortSignal } = {}, + proof?: PiRpcOptionProof ): Promise>> { + let command: string + let params: Record if (key === 'model') { const model = parsePiModelOptionId(value) modelSchema.parse({ provider: model.provider, id: model.modelId }) - await rpc.request('set_model', model, options) + command = 'set_model' + params = model } else if (key === 'effort') { if (!THINKING_LEVELS.some((level) => level === value)) { throw new Error(`Unsupported Pi thinking level: ${value}`) } - await rpc.request('set_thinking_level', { level: value }, options) + command = 'set_thinking_level' + params = { level: value } } else { throw new Error(`Unsupported Pi option: ${key}`) } + options.signal?.throwIfAborted() + const token = Symbol(key) + proof?.writes.set(key, token) + proof?.current.delete(key) + if (key === 'model') { + proof?.writes.set('effort', token) + proof?.current.delete('effort') + } + await waitForPromiseWithSignal( + rpc.request(command, params, { timeoutMs: options.timeoutMs }), + options.signal + ) + if (proof && proof.writes.get(key) !== token) { + throw new Error('Pi option write was superseded') + } selected.set(key, value) + proof?.current.set(key, value) return Object.fromEntries(selected) } diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 062582a06bd9..557b6734330f 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -193,6 +193,41 @@ describe('Pi RPC session ownership and delivery', () => { await expect(h.adapter.dispatch({ ...input, signal: controller.signal })).rejects.toBe(timeout) }) + it('re-establishes opening A after an unanswered unary B and ignores B’s late answer', async () => { + const h = await setup() + const unanswered = Promise.withResolvers() + const controller = new AbortController() + h.connection.requestOverride = (command) => + command === 'set_model' ? unanswered.promise : undefined + const pick = h.adapter.setOption({ + sessionId, + fence: 7, + key: 'model', + value: 'anthropic/model-2', + signal: controller.signal + }) + await vi.waitFor(() => expect(h.connection.requests).toContain('set_model')) + controller.abort(new Error('Stop')) + await expect(pick).rejects.toThrow('Stop') + h.connection.requestOverride = (command) => + command === 'set_thinking_level' ? Promise.resolve({}) : undefined + await expect( + h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'restore', + executionOptions: { model: 'anthropic/model-1', effort: 'low' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'A again' }] } + }) + ).resolves.toEqual({ state: 'admitted' }) + expect(h.connection.requests.filter((command) => command === 'set_model')).toHaveLength(2) + unanswered.resolve({}) + await Promise.resolve() + expect((await h.adapter.readOptions({ sessionId, fence: 7 })).current.model).toBe( + 'anthropic/model-1' + ) + }) + it('rejects stale fences and a Stop for a different turn before sending abort', async () => { const h = await setup() await expect( diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index 4888c52c9951..f893ee8bfa0d 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -210,7 +210,14 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { ) setOption: StructuredAgentSessionAdapter['setOption'] = (input) => { const session = this.session(input.sessionId, input.fence) - return applyPiRpcSessionOption(session.connection, session.selected, input.key, input.value) + return applyPiRpcSessionOption( + session.connection, + session.selected, + input.key, + input.value, + { signal: input.signal }, + session.optionProof + ) } readOptions: NonNullable = async (input) => this.session(input.sessionId, input.fence).readOptions() diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts index 964ace72debb..9bb1e6cc3a68 100644 --- a/src/main/pi/rpc-session.ts +++ b/src/main/pi/rpc-session.ts @@ -63,6 +63,7 @@ export class PiRpcSession { readonly turns: PiRpcTurns readonly dialogs: PiRpcDialogCallbacks readonly selected = new Map() + readonly optionProof = { current: new Map(), writes: new Map() } readonly startup: PiRpcSessionStartup initialOptions: Readonly> = {} openedLink: AgentSessionProviderHandleLink | null = null @@ -164,6 +165,7 @@ export class PiRpcSession { ...(result.reportedOptions.effort ? { effort: result.reportedOptions.effort } : {}) } this.openedLink = result.link ?? null + this.optionProof.current = new Map(Object.entries(this.initialOptions)) void this.startup .readAfterOpen(this.generation, result.reportedOptions) .catch((error: unknown) => diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 02280803591f..9397d2f84d2d 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -122,7 +122,7 @@ export function useStructuredAgentSessionHostExecution( const next = { phase: summary?.hostExecutionPhase ?? null, stopping: summary?.stopping === true, - optionStamp: `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}`, + optionStamp: `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}|${JSON.stringify(summary?.optionSelection)}|${feed.getSnapshotSample(sessionId)}`, permissionMode: summary?.permissionMode, optionSelection: structuralValuesEqual(previous.optionSelection, summary?.optionSelection) ? previous.optionSelection diff --git a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx index c84294e914b5..35bc02265b68 100644 --- a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx @@ -33,6 +33,7 @@ vi.mock('@/runtime/structured-agent-session-status-feed', () => ({ } }, getSnapshot: () => feed.snapshot, + getSnapshotSample: () => 0, getSessionObservation: () => (feed.live ? 'live' : 'unverifiable'), subscribe: (listener: () => void) => { feed.listeners.add(listener) 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 b0b2a27d5dac..c02ec19f88c5 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 @@ -113,7 +113,7 @@ export function useStructuredAgentSessionOptionState(args: { optionIdentityRef.current = identity if (sameSession && selectionFromStatus) { optionMutationGeneration.current += 1 - updateOptionState((current) => ({ ...current, pendingPick: null })) + updateOptionState((current) => ({ ...current, pendingPick: null, reconciliation: undefined })) return } const launch = firstFrameLaunch.current @@ -190,8 +190,6 @@ export function useStructuredAgentSessionOptionState(args: { }, [ fence, optionCatalog, - // Another device's pick moves it. - hostOptionStamp, providerStarting, providerVisible, readsBeforeStart, @@ -204,13 +202,14 @@ export function useStructuredAgentSessionOptionState(args: { // Reads share the session's host queue with sends and interrupts, so a burst of // missed revisions keeps one read in flight and at most one behind it. - const seenContextRefresh = useRef(contextRefresh) + const refresh = `${contextRefresh}|${hostOptionStamp}` + const seenContextRefresh = useRef(refresh) useEffect(() => { - if (contextRefresh !== seenContextRefresh.current) { - seenContextRefresh.current = contextRefresh + if (refresh !== seenContextRefresh.current) { + seenContextRefresh.current = refresh optionsReadRef.current?.run() } - }, [contextRefresh]) + }, [refresh]) // An options answer made before the account's listing landed listed none of it. const rereadOptions = useCallback(() => optionsReadRef.current?.run(), []) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx index 08453c4a86f2..05f35c1e3860 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx @@ -141,7 +141,7 @@ it('uses the existing options read when an older host omits the selection field' view.unmount() }) -it('admits one pending pick, including after the picker re-renders', async () => { +it('keeps accepted B, adopts current C without its echo, then reads a fresh equal A snapshot', async () => { mocks.summary.mockImplementation(() => ({ optionSelection: { model: 'gpt-one' } })) const write = deferred<{ options: Record }>() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deferred result is the setOption reply the hook reads. @@ -155,6 +155,8 @@ it('admits one pending pick, including after the picker re-renders', async () => isVisible: true, providerVisible: true, optionSelection: mocks.summary()?.optionSelection, + hostOptionStamp: mocks.summary()?.stamp, + hostPermissionMode: 'ask', fence: 1, turnId: null, unloadedTurnRevisions: undefined, @@ -163,6 +165,16 @@ it('admits one pending pick, including after the picker re-renders', async () => }) ) await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) + const readback = deferred<{ models: typeof MODELS; current: { model: string; effort: string } }>() + const during = deferred<{ models: typeof MODELS; current: { model: string; effort: string } }>() + let reads = 0 + mocks.call.mockImplementation((_target: unknown, method: string) => + method === 'agentSession.options' + ? ++reads === 1 + ? during.promise + : readback.promise + : Promise.resolve({ models: MODELS }) + ) let first!: Promise let second!: Promise act(() => { @@ -170,12 +182,41 @@ it('admits one pending pick, including after the picker re-renders', async () => second = view.result.current.setStructuredOption('model', 'gpt-two') }) await expect(second).resolves.toBe(false) + mocks.summary.mockImplementation(() => ({ + optionSelection: { model: 'gpt-one' }, + stamp: 'during-write' + })) + view.rerender() + await waitFor(() => expect(reads).toBe(1)) await act(async () => { expect(await view.result.current.setStructuredOption('model', 'gpt-two')).toBe(false) write.resolve({ options: { model: 'gpt-two' } }) await first }) expect(mutate).toHaveBeenCalledOnce() + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') + expect( + view.result.current.optionSnapshot.find((option) => option.id === 'permissionMode')?.kind + .currentValue + ).toBe('ask') + await act(async () => + during.resolve({ models: MODELS, current: { model: 'gpt-one', effort: 'medium' } }) + ) + await waitFor(() => expect(reads).toBe(2)) + expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') + await act(async () => + readback.resolve({ models: MODELS, current: { model: 'gpt-one', effort: 'low' } }) + ) + await waitFor(() => + expect(pill(view.result.current.optionSnapshot)).toEqual({ model: 'gpt-one', effort: 'low' }) + ) + mocks.call.mockResolvedValue({ models: MODELS, current: { model: 'gpt-one', effort: 'medium' } }) + mocks.summary.mockImplementation(() => ({ + optionSelection: { model: 'gpt-one' }, + stamp: 'fresh-snapshot' + })) + view.rerender() + await waitFor(() => expect(pill(view.result.current.optionSnapshot).effort).toBe('medium')) view.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 a20171c94206..4070b024ee2c 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 @@ -1,14 +1,10 @@ import { toast } from 'sonner' import { useCallback, useMemo } from 'react' -import type { - AgentSessionOptionResult, - AgentSessionOptionsResult -} from '../../../../shared/agent-session-wire' +import type { AgentSessionOptionResult } from '../../../../shared/agent-session-wire' import type { AgentType } from '../../../../shared/agent-status-types' import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode' import { - applyStructuredAgentSessionOptions, canSetStructuredAgentSessionOption, commitStructuredAgentSessionOptionValues, lockedStructuredAgentSessionOptionSnapshot, @@ -18,7 +14,6 @@ import { } from '../../../../shared/structured-agent-session-options' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability' -import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-settings-write' import { encodeStructuredAgentSessionOptionValue } from '../../../../shared/structured-agent-session-option-codec' import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' @@ -138,7 +133,7 @@ export function useStructuredAgentSessionOptions(args: { const sendStructuredOption = useCallback( async (id: string, encoded: string): Promise> | null> => { const targetRecord = optionStateRef.current.record - const mutationGeneration = ++optionMutationGeneration.current + let mutationGeneration = ++optionMutationGeneration.current const isCurrent = (): boolean => activeOptionRecordRef.current === targetRecord && optionMutationGeneration.current === mutationGeneration @@ -158,48 +153,31 @@ export function useStructuredAgentSessionOptions(args: { { key: id, value: encoded } ) if (result && isCurrent()) { + mutationGeneration = ++optionMutationGeneration.current const acceptedValues = result.options ?? { [id]: encoded } committed = acceptedValues updateOptionState((current) => - current.record === targetRecord && !selectionFromStatus - ? commitStructuredAgentSessionOptionValues(current, acceptedValues) + current.record === targetRecord + ? { + ...commitStructuredAgentSessionOptionValues(current, { + ...(current.reconciliation ? {} : args.optionSelection), + ...acceptedValues + }), + reconciliation: { + permission: + current.reconciliation?.permission || + acceptedValues.permissionMode !== undefined + } + } : current ) rememberOptionPicks(optionView, acceptedValues) - if (!selectionFromStatus) { - void callStructuredAgentSession( - target, - 'agentSession.options', - { sessionId } - ) - .then((refreshed) => { - if (isCurrent()) { - updateOptionState((latest) => - latest.record === targetRecord && optionCatalog - ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) - : latest - ) - } - }) - .catch(() => {}) - } + rereadOptions() } return committed } finally { if (shownAtOnce && !result && !selectionFromStatus && isCurrent()) { - void callStructuredAgentSession( - target, - 'agentSession.options', - { sessionId } - ) - .then((refreshed) => { - if (isCurrent() && optionCatalog) { - updateOptionState((latest) => - applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) - ) - } - }) - .catch(() => {}) + rereadOptions() } if (isCurrent()) { updateOptionState((current) => @@ -212,15 +190,14 @@ export function useStructuredAgentSessionOptions(args: { }, [ activeOptionRecordRef, + args.optionSelection, mutate, - optionCatalog, optionMutationGeneration, optionStateRef, rememberOptionPicks, + rereadOptions, optionView, selectionFromStatus, - sessionId, - target, updateOptionState ] ) diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts index de1617cdb1b6..1f65f9da0334 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts @@ -131,11 +131,13 @@ describe('structured agent session status feed', () => { await vi.advanceTimersByTimeAsync(0) hostEmit()({ type: 'snapshot', sessions: [summary('one'), summary('two')] }) expect(feed.getSessionObservation('one')).toBe('live') + expect(feed.getSnapshotSample('one')).toBe(1) await vi.advanceTimersByTimeAsync(31 * 60_000) expect(feed.getSessionObservation('one')).toBe('live') expect(mocks.subscribeStatus).toHaveBeenCalledOnce() hostEmit()({ type: 'end' }) expect(feed.getSessionObservation('one')).toBe('unverifiable') + expect(feed.getSnapshotSample('one')).toBe(1) hostEmit()({ type: 'status', session: summary('one') }) expect(feed.getSessionObservation('one')).toBe('unverifiable') await vi.advanceTimersByTimeAsync(300) @@ -145,6 +147,9 @@ describe('structured agent session status feed', () => { expect(feed.getSnapshot().has('one')).toBe(true) hostEmit(1)({ type: 'status', session: summary('one') }) expect(feed.getSessionObservation('one')).toBe('live') + expect(feed.getSnapshotSample('one')).toBe(1) + hostEmit(1)({ type: 'snapshot', sessions: [summary('one')] }) + expect(feed.getSnapshotSample('one')).toBe(2) deactivate() expect(feed.getSessionObservation('one')).toBe('unverifiable') expect(vi.getTimerCount()).toBe(0) diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.ts index 82b741d2b59f..5cd77c945054 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.ts @@ -20,6 +20,7 @@ export type StructuredAgentSessionStatusSnapshot = AgentSessionStatusSnapshot export type StructuredAgentSessionStatusFeedOwner = { activate: () => () => void + getSnapshotSample: (sessionId: string) => number getSnapshot: () => StructuredAgentSessionStatusSnapshot getSessionObservation: (sessionId: string) => 'live' | 'unverifiable' subscribe: (listener: () => void) => () => void @@ -37,6 +38,7 @@ export function structuredAgentSessionStatusFeedKey(target: RuntimeClientTarget) } function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { + const snapshotSamples = new Map() let snapshot: StructuredAgentSessionStatusSnapshot = new Map() const confirmedSessions = new Set() const listeners = new Set<() => void>() @@ -59,6 +61,7 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { if (event.type === 'snapshot') { reconnectAttempt = 0 for (const session of event.sessions) { + snapshotSamples.set(session.sessionId, (snapshotSamples.get(session.sessionId) ?? 0) + 1) confirmedSessions.add(session.sessionId) } } else if (event.type === 'status') { @@ -197,6 +200,7 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { } } }, + getSnapshotSample: (sessionId) => snapshotSamples.get(sessionId) ?? 0, getSnapshot: () => snapshot, getSessionObservation: (sessionId) => confirmedSessions.has(sessionId) ? 'live' : 'unverifiable', diff --git a/src/shared/structured-agent-session-option-view.ts b/src/shared/structured-agent-session-option-view.ts index 7f97789bae20..26ca92c8149b 100644 --- a/src/shared/structured-agent-session-option-view.ts +++ b/src/shared/structured-agent-session-option-view.ts @@ -32,18 +32,23 @@ export function structuredAgentSessionOptionView( takesSendOptions: boolean, host?: { selection?: Readonly>; permissionMode?: string } ): StructuredAgentSessionOptionState { + const selection = state.reconciliation ? undefined : host?.selection const pending = state.pendingPick - if (host?.selection !== undefined || pending) { + if (selection !== undefined || pending) { state = { ...state, record: cloneNativeChatSessionOptionRecord(state.record) } - if (host?.selection !== undefined) { - state = commitStructuredAgentSessionOptionValues(state, host.selection) + if (selection !== undefined) { + state = commitStructuredAgentSessionOptionValues(state, selection) } if (pending) { state = commitStructuredAgentSessionOptionValues(state, { [pending.id]: pending.value }) } state = { ...state, pendingPick: pending } } - if (host?.permissionMode !== undefined && pending?.id !== AGENT_CHAT_PERMISSION_MODE_OPTION_ID) { + if ( + !state.reconciliation?.permission && + host?.permissionMode !== undefined && + pending?.id !== AGENT_CHAT_PERMISSION_MODE_OPTION_ID + ) { state = { ...state, permissionMode: host.permissionMode } } const { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: heldPermission, ...heldSelection } = held diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index aefbf5801695..b70fe5c25300 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -59,6 +59,8 @@ export type StructuredAgentSessionOptionState = { record: NativeChatSessionOptionRecord /** The permission mode the host or launch last stated; null shows no picker. */ permissionMode: string | null + /** Accepted or read-back facts supersede the preceding cached status. */ + reconciliation?: { permission: boolean } pendingPick: { id: string; value: string } | null } @@ -168,6 +170,11 @@ export function applyStructuredAgentSessionOptions( ? state.catalog : structuredAgentSessionOptionCatalog(seed, result), catalogSource: result.modelsKnown === false ? state.catalogSource : 'live', + reconciliation: state.reconciliation + ? { + permission: result.current.permissionMode !== undefined || state.reconciliation.permission + } + : undefined, permissionMode: result.current.permissionMode ?? state.permissionMode } } From c0a85f2dcf7fce9b4f70c0fd0e144eaca226df60 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:54:18 -0700 Subject: [PATCH 114/127] fix(native-chat): preserve accepted choices and cancellation authority --- ...claude-structured-dispatch-test-support.ts | 1 + ...claude-structured-effort-reporting.test.ts | 8 +- .../claude-structured-message-options.test.ts | 29 +++ .../claude-structured-message-options.ts | 32 +-- ...aude-structured-model-confirmation.test.ts | 1 + .../claude-structured-model-preflight.test.ts | 1 + ...ude-structured-option-confirmation.test.ts | 1 + .../claude/claude-structured-options.test.ts | 3 + src/main/claude/claude-structured-options.ts | 11 + .../claude-structured-session-acquisition.ts | 8 + .../claude-structured-session-options.ts | 5 + .../claude-structured-session-publication.ts | 1 + .../claude-structured-session-startup.ts | 10 + .../claude/claude-structured-session-state.ts | 2 + .../codex-structured-message-options.test.ts | 27 ++- .../codex/codex-structured-session-adapter.ts | 3 +- .../codex-structured-thread-goal.test.ts | 21 ++ .../codex/codex-structured-thread-goal.ts | 5 +- ...structured-agent-session-acquire-aborts.ts | 28 ++- .../structured-agent-session-adapter.ts | 1 + .../structured-agent-session-chat-stop.ts | 14 +- .../structured-agent-session-create.ts | 1 + ...structured-agent-session-host-mutations.ts | 3 +- ...ured-agent-session-message-choices.test.ts | 5 +- ...tructured-agent-session-message-options.ts | 4 +- ...ructured-agent-session-mutation-context.ts | 2 +- ...tructured-agent-session-operation-child.ts | 4 + ...gent-session-ready-operation-order.test.ts | 59 +++++ ...tructured-agent-session-ready-operation.ts | 51 ++++- .../structured-agent-session-thread-goal.ts | 12 +- ...tured-agent-session-first-frame-options.ts | 99 ++++++++ .../use-host-model-catalog-upgrade.test.tsx | 5 +- ...se-native-chat-provisional-launch.test.tsx | 6 +- .../use-native-chat-provisional-launch.ts | 11 +- ...e-structured-agent-session-option-state.ts | 211 +++++++++--------- ...session-options.current-selection.test.tsx | 54 ++++- ...-structured-agent-session-options.test.tsx | 13 +- .../use-structured-agent-session-options.ts | 32 ++- ...gent-session-launch-create-message.test.ts | 2 +- ...red-agent-session-launch-create-message.ts | 2 +- ...tured-agent-session-launch-options.test.ts | 15 +- ...structured-agent-session-launch-options.ts | 34 ++- .../structured-agent-session-option-view.ts | 18 +- .../structured-agent-session-options.ts | 4 +- 44 files changed, 651 insertions(+), 208 deletions(-) diff --git a/src/main/claude/claude-structured-dispatch-test-support.ts b/src/main/claude/claude-structured-dispatch-test-support.ts index 1ba74cca6b62..97fb9fa6d834 100644 --- a/src/main/claude/claude-structured-dispatch-test-support.ts +++ b/src/main/claude/claude-structured-dispatch-test-support.ts @@ -29,6 +29,7 @@ export function sessionFor(send: Mock = vi.fn().mockResolvedValue(undefined)): C reportedOptions: {}, reportedModelMutation: 0, confirmedOptions: new Set(), + reusableOptions: new Map(), launchedModel: null, fastModeAtStart: false, capabilities: [], diff --git a/src/main/claude/claude-structured-effort-reporting.test.ts b/src/main/claude/claude-structured-effort-reporting.test.ts index c5adc15c2d6f..98042025ecc4 100644 --- a/src/main/claude/claude-structured-effort-reporting.test.ts +++ b/src/main/claude/claude-structured-effort-reporting.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { setClaudeStructuredOption } from './claude-structured-options' -import { readClaudeSettingsEffort } from './claude-structured-session-options' +import { + readClaudeStructuredSessionOptions, + readClaudeSettingsEffort +} from './claude-structured-session-options' import type { ClaudeSession } from './claude-structured-session-state' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' import { USER_MESSAGE, acquired, fakeClaude } from './claude-structured-session-test-support' @@ -26,6 +29,7 @@ function sessionWith( optionMutationSequence: 0, reportedModelMutation: 0, confirmedOptions: new Set(), + reusableOptions: new Map(), connection: { supportedModels: async () => { calls.push('list_models') @@ -198,6 +202,8 @@ describe('Claude effort against the model that must run it', () => { setClaudeStructuredOption(session, { key: 'effort', value: 'max' }, undefined) ).resolves.toEqual({ model: 'sonnet', effort: 'max' }) expect(calls).toEqual(['list_models', 'apply:max']) + await readClaudeStructuredSessionOptions(session, undefined) + expect(session.reusableOptions.get('effort')).toBe('max') expect(session.confirmedOptions.has('effort')).toBe(false) }) diff --git a/src/main/claude/claude-structured-message-options.test.ts b/src/main/claude/claude-structured-message-options.test.ts index b2ad35ad21b4..11d0918d2117 100644 --- a/src/main/claude/claude-structured-message-options.test.ts +++ b/src/main/claude/claude-structured-message-options.test.ts @@ -98,6 +98,35 @@ describe('Claude accepted message choices', () => { expect(order).toEqual(['model:opus', 'effort:{"effortLevel":"high"}', 'prompt']) }) + it('restores A after an applied unanswered B and an uncorrelated old init', async () => { + let actual = 'sonnet' + const claude = fakeClaude({ initModels: MODELS, routes: { list_models: () => MODELS } }) + const adapter = await acquired(claude) + const connection = claude.connections[0]! + connection.setModel = async (model) => { + actual = model + if (model === 'opus') { + throw new Error('answer lost') + } + } + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'sonnet', fence: 7 }) + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 }) + ).rejects.toThrow('answer lost') + claudeFrame(connection, { type: 'system', subtype: 'init', model: 'sonnet' }) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'restore', + body: USER_MESSAGE, + executionOptions: { model: 'sonnet' }, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + expect(actual).toBe('sonnet') + expect(connection.sent).toHaveLength(1) + }) + it('keeps saved Fast for an older partial send, freezes it, and honors explicit false', async () => { let fastMode = true const supported = MODELS.map((model) => ({ ...model, supportsFastMode: true })) diff --git a/src/main/claude/claude-structured-message-options.ts b/src/main/claude/claude-structured-message-options.ts index 8f96f77e8753..3cef26e2dd16 100644 --- a/src/main/claude/claude-structured-message-options.ts +++ b/src/main/claude/claude-structured-message-options.ts @@ -4,7 +4,6 @@ import type { AgentSessionExecutionOptions } from '../../shared/agent-session-ex import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' import { listedModels } from './claude-structured-model-catalog' import { setClaudeStructuredOption } from './claude-structured-options' -import { readClaudeCurrentModel } from './claude-structured-session-options' import type { ClaudeSession } from './claude-structured-session-state' import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' @@ -17,7 +16,7 @@ export async function applyClaudeMessageOptions( ): Promise { throwIfSignalAborted(signal) const model = options.model ?? (await defaultModelForMessage(session, timeoutMs, signal)) - if (model && readClaudeCurrentModel(session).id !== model) { + if (model && session.reusableOptions.get('model') !== model) { await waitForPromiseWithSignal( setClaudeStructuredOption(session, { key: 'model', value: model }, timeoutMs), signal @@ -26,41 +25,26 @@ export async function applyClaudeMessageOptions( throwIfSignalAborted(signal) const effort = options.effort ?? session.configuredDefaults?.effort - if ( - effort && - (session.options.get('effort') !== effort || !session.confirmedOptions.has('effort')) - ) { + if (effort && session.reusableOptions.get('effort') !== effort) { await waitForPromiseWithSignal( setClaudeStructuredOption(session, { key: 'effort', value: effort }, timeoutMs), signal ) - if ( - effort !== 'max' && - !session.confirmedOptions.has('effort') && - session.reportedOptions.effort !== undefined && - session.reportedOptions.effort !== effort - ) { - throw new AgentSessionOptionRejectedError( - `Claude reported effort ${session.reportedOptions.effort} instead of ${effort}` - ) - } } else if (!effort && session.options.has('effort')) { throw new AgentSessionOptionRejectedError('Claude did not report its configured effort') } throwIfSignalAborted(signal) - const fastMode = options.fastMode ?? 'false' - if ( - (session.options.get('fastMode') !== fastMode || - (session.fastModeAtStart && !session.confirmedOptions.has('fastMode'))) && - (options.fastMode || session.options.has('fastMode')) - ) { + const fastMode = options.fastMode + if (fastMode !== undefined && session.reusableOptions.get('fastMode') !== fastMode) { await waitForPromiseWithSignal( setClaudeStructuredOption(session, { key: 'fastMode', value: fastMode }, timeoutMs), signal ) - if (session.options.get('fastMode') !== fastMode) { - throw new AgentSessionOptionRejectedError(`Claude did not apply Fast mode ${fastMode}`) + } + for (const [key, value] of Object.entries({ model, effort, fastMode })) { + if (value !== undefined && session.reusableOptions.get(key) !== value) { + throw new AgentSessionOptionRejectedError(`Claude did not apply ${key} ${value}`) } } throwIfSignalAborted(signal) diff --git a/src/main/claude/claude-structured-model-confirmation.test.ts b/src/main/claude/claude-structured-model-confirmation.test.ts index f4034400cbfc..dd821c426a3d 100644 --- a/src/main/claude/claude-structured-model-confirmation.test.ts +++ b/src/main/claude/claude-structured-model-confirmation.test.ts @@ -186,6 +186,7 @@ describe('Claude effort the settings readback cannot report', () => { reportedOptions: {}, optionMutationSequence: 0, confirmedOptions: new Set(), + reusableOptions: new Map(), connection: { supportedModels: async () => { calls.push('list_models') diff --git a/src/main/claude/claude-structured-model-preflight.test.ts b/src/main/claude/claude-structured-model-preflight.test.ts index c02a9e528c64..a2eb902e8dd3 100644 --- a/src/main/claude/claude-structured-model-preflight.test.ts +++ b/src/main/claude/claude-structured-model-preflight.test.ts @@ -22,6 +22,7 @@ function sessionWith(catalog: readonly Record[] | 'unavailable' optionMutationSequence: 0, reportedModelMutation: 0, confirmedOptions: new Set(), + reusableOptions: new Map(), connection: { supportedModels: async () => { calls.push('list_models') diff --git a/src/main/claude/claude-structured-option-confirmation.test.ts b/src/main/claude/claude-structured-option-confirmation.test.ts index d5ac5fd4d2dd..26b5e790d8fe 100644 --- a/src/main/claude/claude-structured-option-confirmation.test.ts +++ b/src/main/claude/claude-structured-option-confirmation.test.ts @@ -168,6 +168,7 @@ describe('confirmation never outlives the write it belongs to', () => { reportedOptions: {}, optionMutationSequence: 0, confirmedOptions: new Set(), + reusableOptions: new Map(), connection: { supportedModels: async () => CATALOG, applyFlagSettings: async (s: { effortLevel?: string }) => { diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index 2f4f84e48c8d..bb05f48d2b7f 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -38,6 +38,7 @@ function sessionFor(setModel: ClaudeSession['connection']['setModel']): ClaudeSe reportedOptions: {}, reportedModelMutation: 0, confirmedOptions: new Set(), + reusableOptions: new Map(), launchedModel: null, fastModeAtStart: false, capabilities: [], @@ -345,6 +346,8 @@ describe('Claude structured Fast mode', () => { ).resolves.toMatchObject({ model: 'haiku', fastMode: 'false' }) expect(session.reportedOptions.fastMode).toBe(true) expect(session.confirmedOptions.has('fastMode')).toBe(false) + expect(session.reusableOptions.get('model')).toBe('haiku') + expect(session.reusableOptions.has('fastMode')).toBe(false) }) it('treats an unrecognized provider disabled reason as unavailable', async () => { diff --git a/src/main/claude/claude-structured-options.ts b/src/main/claude/claude-structured-options.ts index 042c513f5972..d49296f07b04 100644 --- a/src/main/claude/claude-structured-options.ts +++ b/src/main/claude/claude-structured-options.ts @@ -131,6 +131,11 @@ export async function setClaudeStructuredOption( ? claudeModelFastModeSupport(session, listed, input.value) : null const modelWasConfirmed = readClaudeCurrentModel(session).confirmed + session.reusableOptions.delete(input.key) + if (input.key === 'model') { + session.reusableOptions.delete('effort') + session.reusableOptions.delete('fastMode') + } const mutationSequence = ++session.optionMutationSequence // Read with the fence bump, so a write that lands after an earlier one's bookkeeping compares against it. const held = session.options.get(input.key) @@ -160,6 +165,7 @@ export async function setClaudeStructuredOption( if (mutationSequence !== session.optionMutationSequence) { return Object.fromEntries(session.options) } + session.reusableOptions.set('model', input.value) session.options.set('model', input.value) session.options.set('fastMode', 'false') session.confirmedOptions.delete('effort') @@ -190,6 +196,11 @@ export async function setClaudeStructuredOption( if (mutationSequence !== session.optionMutationSequence) { return Object.fromEntries(session.options) } + if (input.key === 'model' || (input.key === 'effort' && UNREPORTED_EFFORTS.has(input.value))) { + session.reusableOptions.set(input.key, input.value) + } else if (adopted !== null) { + session.reusableOptions.set(input.key, String(adopted)) + } if (input.key === 'fastMode' && typeof adopted === 'boolean') { session.reportedOptions.fastMode = adopted } diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 07dcf0950459..3e1ec2efc340 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -97,6 +97,14 @@ export async function acquireClaudeSession({ // running; set_model answers success for a model it never resolves, so this // report is the session's only adoption evidence. if (liveSession && init.model) { + if (liveSession.reusableOptions.get('model') !== init.model) { + liveSession.reusableOptions.delete('model') + liveSession.reusableOptions.delete('effort') + liveSession.reusableOptions.delete('fastMode') + } + if (liveSession.optionMutationSequence === 0) { + liveSession.reusableOptions.set('model', init.model) + } liveSession.reportedOptions.model = init.model liveSession.reportedModelMutation = liveSession.optionMutationSequence } diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 2550b6bab18b..70c6c448fa9a 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -237,9 +237,13 @@ function observeClaudeSettingsReadback( const perSessionOptIn = readClaudeSettingsFastModePerSessionOptIn(settings) observeClaudeSettingsApplied(session, settings) if (effort) { + if (session.reusableOptions.get('effort') !== 'max') { + session.reusableOptions.set('effort', effort) + } session.reportedOptions.effort = effort } if (fastMode !== null) { + session.reusableOptions.set('fastMode', String(fastMode)) session.reportedOptions.fastMode = fastMode if (decodedFastMode(session) !== undefined) { session.options.set('fastMode', String(fastMode)) @@ -293,6 +297,7 @@ export function claudeStructuredSessionOptionsFrom( readMutationSequence === session.optionMutationSequence ) { session.options.set('fastMode', 'false') + session.reusableOptions.delete('fastMode') session.confirmedOptions.delete('fastMode') desiredFastMode = false } diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index 77e0cb83a36a..e7f8c3b877bd 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -63,6 +63,7 @@ export function createClaudeSessionPublication(input: { reportedOptions: {}, reportedModelMutation: 0, confirmedOptions: new Set(), + reusableOptions: new Map(), launchedModel: null, fastModeAtStart: false, translator: input.translator, diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index 80c94dfdebc1..a2937cab916a 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -119,6 +119,13 @@ function applyClaudeInitializeFacts(session: ClaudeSession, facts: ClaudeInitial const { init, initialization } = facts // A turn's own init frame may already have reported the running model. if (init?.model && session.reportedOptions.model === undefined) { + if (session.optionMutationSequence === 0) { + session.reusableOptions.set('model', init.model) + } else if (session.reusableOptions.get('model') !== init.model) { + session.reusableOptions.delete('model') + session.reusableOptions.delete('effort') + session.reusableOptions.delete('fastMode') + } session.reportedOptions.model = init.model session.reportedModelMutation = session.optionMutationSequence } @@ -161,6 +168,7 @@ function applyClaudeSettingsFacts( const agrees = (key: string, reported: string): boolean => !session.options.has(key) || session.options.get(key) === reported if (effort) { + session.reusableOptions.set('effort', effort) session.reportedOptions.effort = effort if (agrees('effort', effort)) { session.confirmedOptions.add('effort') @@ -169,9 +177,11 @@ function applyClaudeSettingsFacts( // A launch `--effort` shows only in `applied`, never in `effective` (measured on 2.1.280). const launchedEffort = session.options.get('effort') if (launchedEffort !== undefined && session.appliedOptions?.effort === launchedEffort) { + session.reusableOptions.set('effort', launchedEffort) session.confirmedOptions.add('effort') } if (published.fastMode !== null) { + session.reusableOptions.set('fastMode', String(published.fastMode)) session.reportedOptions.fastMode = published.fastMode if (agrees('fastMode', String(published.fastMode))) { session.confirmedOptions.add('fastMode') diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index ee8cec5d3a71..53ae113a2517 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -178,6 +178,8 @@ export type ClaudeSession = { reportedModelMutation: number /** Options whose recorded value the provider reported, not merely accepted. */ confirmedOptions: Set + /** Value-bound execution evidence, revoked before any possible write. */ + reusableOptions: Map /** The model the child was launched with, as `--model`; null when it runs the CLI's own. */ launchedModel: string | null /** Fast was omitted from launch settings and needs confirmation at message input. */ diff --git a/src/main/codex/codex-structured-message-options.test.ts b/src/main/codex/codex-structured-message-options.test.ts index 17038a8648a7..60203acc6642 100644 --- a/src/main/codex/codex-structured-message-options.test.ts +++ b/src/main/codex/codex-structured-message-options.test.ts @@ -70,17 +70,38 @@ describe('Codex captured message choices', () => { const turnOptions = await resolvedCodexMessageOptions(session, { model, effort }) await startCodexTurn(session, { clientMessageId: id, body, turnOptions }) } + session.activeTurnIds = new Set(['turn-1']) + await startCodexTurn(session, { + clientMessageId: 'steer', + body, + turnOptions: { personality: 'friendly', serviceTier: 'priority' } + }) + expect(request.mock.calls.at(-1)?.[0]).toBe('turn/steer') + expect(request.mock.calls.at(-1)?.[1]).not.toHaveProperty('personality') + expect(request.mock.calls.at(-1)?.[1]).not.toHaveProperty('serviceTier') + expect(session.appliedTurnOptions).toEqual({ model: 'model-b', effort: 'low' }) + session.activeTurnIds = new Set() + await startCodexTurn(session, { + clientMessageId: 'next', + body, + turnOptions: { personality: 'friendly', serviceTier: 'priority' } + }) + expect(request.mock.calls.at(-1)?.[1]).toMatchObject({ + personality: 'friendly', + serviceTier: 'priority' + }) expect( request.mock.calls .filter(([method]) => method === 'turn/start') .map(([, params]) => [params?.model, params?.effort]) ).toEqual([ ['model-a', 'high'], - ['model-b', 'low'] + ['model-b', 'low'], + [undefined, undefined] ]) expect(Object.fromEntries(session.options)).toEqual({ - model: 'model-b', - effort: 'low' + personality: 'friendly', + serviceTier: 'priority' }) }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 87cde9ec3cb7..3a3a97f174a5 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -291,7 +291,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap this.session(input.sessionId), input.change, input.replacesGoal, - this.deps.requestTimeoutMs + this.deps.requestTimeoutMs, + input.signal ) answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => diff --git a/src/main/codex/codex-structured-thread-goal.test.ts b/src/main/codex/codex-structured-thread-goal.test.ts index 97f4cb9fb73f..6170a94a2015 100644 --- a/src/main/codex/codex-structured-thread-goal.test.ts +++ b/src/main/codex/codex-structured-thread-goal.test.ts @@ -63,6 +63,27 @@ describe('codex thread goal requests', () => { ]) }) + it('awaits a revoked clear and never activates the replacement', async () => { + const clear = Promise.withResolvers() + const authority = new AbortController() + const request = vi.fn(() => clear.promise) + const result = changeCodexThreadGoal( + session(request), + { kind: 'set', objective: 'Ship it' }, + true, + undefined, + authority.signal + ) + const settled = vi.fn() + void result.then(settled, settled) + authority.abort(new Error('stopped')) + await Promise.resolve() + expect(settled).not.toHaveBeenCalled() + clear.resolve({ goal: null }) + await expect(result).rejects.toThrow('stopped') + expect(request).toHaveBeenCalledOnce() + }) + it('stops at a refused clear, so a refused replacement leaves the old goal alone', async () => { const request = vi.fn(async (method: unknown) => { if (method === 'thread/goal/clear') { diff --git a/src/main/codex/codex-structured-thread-goal.ts b/src/main/codex/codex-structured-thread-goal.ts index c521221390e5..b665a6747e83 100644 --- a/src/main/codex/codex-structured-thread-goal.ts +++ b/src/main/codex/codex-structured-thread-goal.ts @@ -1,3 +1,4 @@ +import { throwIfSignalAborted } from '../../shared/abort-signal-reason' import type { AgentSessionThreadGoalChange } from '../../shared/agent-session-thread-goal-wire' import { isCodexAppServerRequestError } from './codex-app-server-connection' import type { CodexSession } from './codex-structured-session-state' @@ -36,10 +37,12 @@ export async function changeCodexThreadGoal( session: Pick, change: AgentSessionThreadGoalChange, replacesGoal: boolean, - timeoutMs: number | undefined + timeoutMs: number | undefined, + signal?: AbortSignal ): Promise<{ ok: true } | { ok: false; rejected: string }> { try { for (const request of codexThreadGoalRequests(session.threadId, change, replacesGoal)) { + throwIfSignalAborted(signal) await session.connection.request(request.method, request.params, { timeoutMs }) } return { ok: true } diff --git a/src/main/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 1d28be9b0679..59bcc5b200ec 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 @@ -3,7 +3,7 @@ export class StructuredAgentSessionAcquireAborts { private readonly inFlight = new Map< string, - Set<{ controller: AbortController; handover: boolean }> + Set<{ controller: AbortController; handover: boolean; goal: boolean }> >() /** Set by quit: the host is going away, so an attach that begins after it starts aborted. */ private quitReason: Error | null = null @@ -11,15 +11,17 @@ export class StructuredAgentSessionAcquireAborts { /** For the provider wait about to run; `end` once it settles. */ begin( sessionId: string, - handover = false + handover = false, + goal = false ): { signal: AbortSignal; abort: () => void; end: () => void } { const controller = new AbortController() if (this.quitReason) { controller.abort(this.quitReason) } const waits = - this.inFlight.get(sessionId) ?? new Set<{ controller: AbortController; handover: boolean }>() - const active = { controller, handover } + this.inFlight.get(sessionId) ?? + new Set<{ controller: AbortController; handover: boolean; goal: boolean }>() + const active = { controller, handover, goal } waits.add(active) this.inFlight.set(sessionId, waits) return { @@ -34,14 +36,24 @@ export class StructuredAgentSessionAcquireAborts { } } + captureOperations(sessionId: string): (() => void)[] { + return [...(this.inFlight.get(sessionId) ?? [])] + .filter(({ controller, goal }) => goal && !controller.signal.aborted) + .map( + ({ controller }) => + () => + controller.abort(new Error('stopped during goal change')) + ) + } + /** A no-op when the session has nothing in flight. */ abort(sessionId: string, reason: string | Error): boolean { - let stoppedHandover = false - for (const { controller, handover } of this.inFlight.get(sessionId) ?? []) { - stoppedHandover ||= handover && !controller.signal.aborted + let reached = false + for (const { controller, handover, goal } of this.inFlight.get(sessionId) ?? []) { + reached ||= (handover || goal) && !controller.signal.aborted controller.abort(typeof reason === 'string' ? new Error(reason) : reason) } - return stoppedHandover + return reached } /** Quit: every provider wait under way stops, including those queued in the attach drain. */ 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 111af5e67c08..0d5ffbfdedf4 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 @@ -329,6 +329,7 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** True when the journal records a goal, whatever its status: a `set` must * start a new goal rather than rewrite that one's objective in place. */ replacesGoal: boolean + signal?: AbortSignal }): Promise<{ ok: true } | { ok: false; rejected: string }> /** Stops exactly the tasks `taskIds` names, which the host resolves from its child records. * `stillRunning` is the provider's own answer that one survived its stop; a throw leaves the diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index 102c63bd6fb5..f69677845e4c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -62,6 +62,7 @@ export function mutateWithChatStop( let windDown: | { owed: StructuredAgentSessionStopWindDown; ctx: AgentSessionTurnContext } | undefined + let reached = params.stoppedPrePrompt === true const named = turnId !== undefined ? { turnId } : {} // Its own step wrote the Stop's event first. const stopChild = () => context.stopAgent(sessionId, { recorded: 'user-stop' }) @@ -92,7 +93,7 @@ export function mutateWithChatStop( ? { ok: true, value: { - settled: target.queued || params.stoppedPrePrompt === true, + settled: target.queued || reached, turnId: accepted.value.turnId } } @@ -144,12 +145,13 @@ export function mutateWithChatStop( ) } const stop = async (ctx: AgentSessionTurnContext): Promise => { + const operations = context.acquireAborts.captureOperations(sessionId) const flight = context.startFlights.get(sessionId) const target = captureChatStopTarget(ctx, { child: context.sessions.get(ctx.sessionId)?.child ?? null, ...named, endsSession: ctx.adapter.stopEndsSession?.(ctx.sessionId) === true, - stoppedPrePrompt: params.stoppedPrePrompt || flight !== undefined + stoppedPrePrompt: reached || operations.length > 0 || flight !== undefined }) if (!target) { // Late, or nothing to stop: answered as a no-op, its receipt committed before the answer. @@ -159,11 +161,15 @@ export function mutateWithChatStop( if (!accepted.ok) { return accepted } + for (const abort of operations) { + abort() + reached = true + } // Invalidation belongs to the accepted Stop, including a claim made after its early abort. if (flight && (target.queued || target.reach === 'withdraw' || target.reach === 'starting')) { flight.abort() } - if (params.stoppedPrePrompt && target.reach === 'withdraw') { + if (reached && target.reach === 'withdraw') { await writeStructuredAgentSessionStopNote( ctx, accepted.value.turnId ?? envelope.clientOperationId, @@ -172,7 +178,7 @@ export function mutateWithChatStop( ) } try { - return await act(ctx, target, accepted.value.settled || flight !== undefined) + return await act(ctx, target, accepted.value.settled || reached || flight !== undefined) } catch (error) { // Saved, then failed: noted on the chat, then thrown so the answer is the Stop's receipt. // On the turn its event named, else the one it named, else the one live now. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts index 1b2410d0d06e..0240ab411d18 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts @@ -200,6 +200,7 @@ async function foundConversation( agent: params.agent, logger: deps.logger, resolvedBy: callerKey, + persistedOptions: candidate.record.options, persistOptions: async () => {}, publish: () => {}, operationReceipt: observedReceipt(store, store.founding.receipt(request)), 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 020295e87dfc..4b4d02b1a479 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 @@ -238,10 +238,11 @@ export async function changeStructuredAgentSessionThreadGoal( context, caller, params.envelope, - plan, + threadGoalPlan({ ...params, signal: selection.signal }), openWithReadyStructuredAgentSessionChild(context, params.envelope, selection) ), { + goal: true, beforeAgent: (record) => refuseThreadGoalUnsupported(context.deps.adapter, context.deps.agents, record?.provider) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts index 16cbc6dea3b2..03050db82ed6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts @@ -127,9 +127,12 @@ it('steers a matching choice but holds an empty default choice for a new turn', ) await rig.host.flushStreamedEvents(SESSION) - const matching = rig.send('same choices', undefined, { carried: choices }) + const extras = { ...choices, personality: 'friendly', serviceTier: 'priority', fastMode: 'true' } + const matching = rig.send('same choices', undefined, { carried: extras }) + await matching.result await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(2)) + expect(rig.dispatch.mock.calls[1]?.[0].executionOptions).toEqual(extras) await rig.host.settleLateDispatch({ sessionId: SESSION, clientMessageId: matching.id, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts index 4a2da862c153..f52e3fd71162 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts @@ -3,7 +3,7 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journ import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -const OPTION_ORDER = ['model', 'effort', 'personality', 'fastMode', 'serviceTier'] as const +const OPTION_ORDER = ['model', 'effort'] as const function sameOptions( a: AgentSessionExecutionOptions | undefined, @@ -15,7 +15,7 @@ function sameOptions( return OPTION_ORDER.every((key) => a?.[key] === b?.[key]) } -/** A different Codex choice needs a new turn: steer has no choice parameters. */ +/** A different Codex model or effort needs a new turn: steer has no choice parameters. */ export function waitsForCodexTurnOptions( journal: Pick, next: AgentJournalSubmission 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 a0541a571fca..4b7d40382e04 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 @@ -62,7 +62,7 @@ export type StructuredAgentSessionMutationContext = { wakeQueuedDrain?: (sessionId: string) => void /** The provider wait each session's serialize is on (a start, an option write), which a caller * outside that serialize aborts. */ - acquireAborts: Pick + acquireAborts: Pick /** Moved by every pick a running child takes, so a report it read before is never persisted. */ now: () => number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-child.ts index d37fe7b4f854..b90a476149f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-child.ts @@ -18,6 +18,7 @@ export type ReadyOperationSelection = { ok: true child: ReadyOperationChild replay?: true + signal?: AbortSignal deadline?: number } @@ -75,6 +76,9 @@ export function openWithReadyStructuredAgentSessionChild( if (!opened.ok || ledger === 'replay') { return opened } + if (selection.signal?.aborted) { + return operationDidNotRun() + } if (!selection.child) { return selection.replay ? { ok: false, refusal: agentSessionOperationOutcomeUnknown(envelope.clientOperationId) } 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 4400679b6108..012081ac321e 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 @@ -126,3 +126,62 @@ it.each([false, true])('keeps a goal behind active dispatch (ready child: %s)', expect(await goal).toMatchObject({ ok: true }) expect(changeThreadGoal).toHaveBeenCalledOnce() }) + +it.each(['append', 'wait'] as const)( + 'Stop revokes a goal during %s through settlement', + async (phase) => { + const { host, dispatch } = hostTestState() + const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) + host.deps.adapter.changeThreadGoal = changeThreadGoal + await attach() + const change = { kind: 'set' as const, objective: 'Warm child' } + await host.changeThreadGoal(CALLER, { + envelope: envelope('agentSession.threadGoal', { change }), + change + }) + changeThreadGoal.mockClear() + const held = Promise.withResolvers() + if (phase === 'wait') { + dispatch.mockImplementationOnce(async () => { + await held.promise + return { state: 'admitted' } + }) + const body = hostTestMessage('Earlier message') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + } + const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + const append = vi.spyOn(journal, 'appendItem') + const withdraw = vi.spyOn(journal, 'appendTombstone') + if (phase === 'append') { + const original = journal.appendItem.bind(journal) + append.mockImplementationOnce(async (...args) => { + await held.promise + return original(...args) + }) + } + const next = { ...change, objective: 'Later goal' } + const goal = host.changeThreadGoal(CALLER, { + envelope: envelope('agentSession.threadGoal', { change: next }), + change: next + }) + const result = goal.catch((error: unknown) => error) + await (phase === 'append' + ? vi.waitFor(() => expect(append).toHaveBeenCalledOnce()) + : host.collaboratorsForTests().serialize(SESSION, async () => {})) + const stopped = host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + expect(changeThreadGoal).not.toHaveBeenCalled() + held.resolve() + if (phase === 'append') { + expect(await result).toBeInstanceOf(Error) + expect(withdraw).toHaveBeenCalledOnce() + } else { + expect(await result).toMatchObject({ ok: false }) + } + expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } }) + expect(changeThreadGoal).not.toHaveBeenCalled() + if (phase === 'append') { + expect(append.mock.calls.some(([, body]) => body.kind === 'status')).toBe(true) + } + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts index 7ba3b715f8c6..dbbc008a2a0b 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,6 +25,8 @@ import type { StructuredAgentSessionDeliveryPlace } from './structured-agent-ses import type { StructuredAgentSessionStartClaim } from './structured-agent-session-agent-start' import type { StructuredAgentSessionStartReadiness } from './structured-agent-session-startup-attempt' +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' + import { operationDidNotRun, selectStructuredAgentSessionOperationChild, @@ -79,6 +81,7 @@ export async function runWithReadyStructuredAgentSessionOperation( beforeAgent?: ( record: AgentSessionRecord | null ) => AgentSessionMutationSessionPreparation | null + goal?: boolean needsAgent?: (record: AgentSessionRecord | null) => boolean } = {} ): Promise> { @@ -92,9 +95,13 @@ export async function runWithReadyStructuredAgentSessionOperation( return { ok: false, refusal: conflict } } const deadline = Date.now() + STRUCTURED_AGENT_SESSION_START_WAIT_MS - const selected = await context.serialize>( + let authority: ReturnType | undefined + const preparedOperation = context.serialize>( envelope.sessionId, async () => { + authority = options.goal + ? context.acquireAborts.begin(envelope.sessionId, false, true) + : undefined const record = context.deps.store.getRecord(envelope.sessionId) if (options.needsAgent && !options.needsAgent(record)) { return { result: await run({ ok: true, child: null }) } @@ -203,6 +210,9 @@ export async function runWithReadyStructuredAgentSessionOperation( }) return operationDidNotRun() }) + if (authority?.signal.aborted) { + return { result: operationDidNotRun() } + } if (!child.ok) { return { result: child } } @@ -211,7 +221,9 @@ export async function runWithReadyStructuredAgentSessionOperation( !child.readiness && !context.deliveryOrder.hasActiveSend(envelope.sessionId) ) { - return { result: await run({ ok: true, child: child.child, deadline }) } + return { + result: await run({ ok: true, child: child.child, deadline, signal: authority?.signal }) + } } context.deliveryOrder.reconcile( envelope.sessionId, @@ -226,13 +238,33 @@ export async function runWithReadyStructuredAgentSessionOperation( } } ) + const selected = await preparedOperation.then( + (selection) => { + if ('result' in selection) { + authority?.end() + } + return selection + }, + (error: unknown) => { + authority?.end() + throw error + } + ) if ('result' in selected) { return selected.result } try { let { child, readiness } = selected if (!child) { - const started = await selected.start?.outcome + const started = await waitForPromiseWithSignal( + selected.start?.outcome ?? Promise.resolve(undefined), + authority?.signal + ).catch((error: unknown) => { + if (authority?.signal.aborted) { + return undefined + } + throw error + }) if (!started?.ok) { return started ?? operationDidNotRun() } @@ -244,12 +276,20 @@ export async function runWithReadyStructuredAgentSessionOperation( readiness = selection.readiness } const ready = readiness?.wait() ?? Promise.resolve('ready' as const) - const outcome = await waitForStructuredAgentSessionStart( + const waiting = waitForStructuredAgentSessionStart( Promise.all([ready, selected.place.before]).then(([outcome]) => outcome === 'ready' ? 'ready' : 'ended' ), deadline - Date.now() ) + const outcome = await waitForPromiseWithSignal(waiting, authority?.signal).catch( + (error: unknown) => { + if (authority?.signal.aborted) { + return 'ended' as const + } + throw error + } + ) if (outcome !== 'ready') { return operationDidNotRun(outcome === 'timeout') } @@ -259,9 +299,10 @@ export async function runWithReadyStructuredAgentSessionOperation( const ran = child return await context.serialize(envelope.sessionId, () => // Admission re-reads the journal here after readiness and answers a competing settlement. - run({ ok: true, child: ran, deadline }) + run({ ok: true, child: ran, deadline, signal: authority?.signal }) ) } finally { + authority?.end() selected.place.release() } } 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 3101059e85c7..8a886e5ab889 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -19,6 +19,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-a import type { StructuredAgentRegistry } from './structured-agent-registry' import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' +import { throwIfSignalAborted } from '../../../shared/abort-signal-reason' import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' function refused( @@ -66,7 +67,7 @@ export function journalRecordsThreadGoalChange( export async function performThreadGoalChange( ctx: AgentSessionTurnContext, - input: { clientOperationId: string; change: AgentSessionThreadGoalChange } + input: { clientOperationId: string; change: AgentSessionThreadGoalChange; signal?: AbortSignal } ): Promise> { if ( !ctx.adapter.changeThreadGoal || @@ -84,6 +85,7 @@ export async function performThreadGoalChange( } // Journal first: an active goal starts provider work at once, and the objective // must land ahead of that work in the transcript. + throwIfSignalAborted(input.signal) if (change.kind === 'set') { await ctx.journal.appendItem( identity, @@ -105,11 +107,13 @@ export async function performThreadGoalChange( } let result: Awaited> try { + throwIfSignalAborted(input.signal) result = await ctx.adapter.changeThreadGoal({ sessionId: ctx.sessionId, fence: ctx.fence, change, - replacesGoal + replacesGoal, + signal: input.signal }) } catch (error) { await withdrawObjective() @@ -125,6 +129,7 @@ export async function performThreadGoalChange( export function threadGoalPlan(params: { envelope: AgentSessionMutationEnvelope change: AgentSessionThreadGoalChange + signal?: AbortSignal }): MutationPlan { const value: AgentSessionThreadGoalResult = { change: params.change.kind } return { @@ -133,7 +138,8 @@ export function threadGoalPlan(params: { run: (ctx) => performThreadGoalChange(ctx, { clientOperationId: params.envelope.clientOperationId, - change: params.change + change: params.change, + signal: params.signal }), // A lost response is answered from the journal, which the provider keeps // current; otherwise the change runs again, which is safe for every kind. diff --git a/src/renderer/src/components/native-chat/structured-agent-session-first-frame-options.ts b/src/renderer/src/components/native-chat/structured-agent-session-first-frame-options.ts index bc4917b4ab67..e6e3d4809ed0 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-first-frame-options.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-first-frame-options.ts @@ -1,3 +1,4 @@ +import type { AgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' import type { AgentType } from '../../../../shared/agent-status-types' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' @@ -6,6 +7,7 @@ import { createStructuredAgentSessionOptionState, type StructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' +import { structuredAgentSessionOptionView } from '../../../../shared/structured-agent-session-option-view' import { readHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots' /** The first frame: the host's saved list when the renderer already holds it, else the seed, @@ -20,3 +22,100 @@ export function firstFrameOptionState( const snapshot = readHostModelCatalogSnapshot(target, agent, launch) return snapshot ? applyStructuredAgentSessionModelCatalog(seeded, seed, snapshot, launch) : seeded } + +export function structuredAgentSessionConfiguredOptionView( + args: { + agent: AgentType + target: RuntimeClientTarget + optionCatalog: AgentSessionOptionCatalog | null + newLaunch: boolean + worktree?: string + seedsModel: boolean + hostSelection: Readonly> | undefined + hostPermissionMode?: string + launchSeedOptions: Readonly> | undefined + held: Readonly> + }, + optionState: StructuredAgentSessionOptionState, + takesSendOptions: boolean, + currentIdentity: boolean +): StructuredAgentSessionOptionState { + const { + agent, + target, + optionCatalog, + newLaunch, + worktree, + seedsModel, + hostSelection, + hostPermissionMode, + launchSeedOptions, + held + } = args + const selectionFromStatus = hostSelection !== undefined + const configured = readHostModelCatalogSnapshot(target, agent, { + newLaunch, + worktree, + seedsModel + }) + // Live rows supply choices; the latest scoped host answer supplies the launch default. + const scoped = + configured && optionCatalog + ? applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState(agent), + optionCatalog, + configured, + { newLaunch } + ).catalog + : null + const defaultModel = scoped?.defaultModelIsCliDefault + ? scoped.models.find((model) => model.isDefault) + : undefined + const defaultEffort = defaultModel?.options.find((option) => option.id === 'effort')?.kind + const catalog = + optionState.catalog && selectionFromStatus && defaultModel + ? { + ...optionState.catalog, + defaultModelIsCliDefault: defaultModel ? (true as const) : undefined, + models: optionState.catalog.models.map((model) => ({ + ...model, + isDefault: model.id === defaultModel?.id, + options: model.options.map((option) => + model.id === defaultModel?.id && + option.id === 'effort' && + option.kind.type === 'select' && + defaultEffort?.type === 'select' && + defaultEffort.defaultIsCliDefault && + option.kind.choices.some((choice) => choice.value === defaultEffort.defaultValue) + ? { + ...option, + kind: { + ...option.kind, + defaultValue: defaultEffort.defaultValue, + defaultIsCliDefault: true as const + } + } + : option + ) + })) + } + : optionState.catalog + return structuredAgentSessionOptionView( + { + ...optionState, + catalog, + executionUnknown: + !optionState.record.model && + !hostSelection?.model && + !launchSeedOptions?.model && + !held.model && + !optionState.pendingPick && + !catalog?.defaultModelIsCliDefault, + pendingPick: currentIdentity ? optionState.pendingPick : null + }, + selectionFromStatus ? undefined : launchSeedOptions, + held, + takesSendOptions, + { selection: hostSelection, permissionMode: hostPermissionMode } + ) +} diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.test.tsx b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.test.tsx index 7ee82b8228da..d3b310776f6c 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.test.tsx +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.test.tsx @@ -3,7 +3,7 @@ import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ call: vi.fn() })) +const mocks = vi.hoisted(() => ({ call: vi.fn(), token: {} })) vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) @@ -17,7 +17,8 @@ vi.mock('./native-chat-session-option-settings-write', () => ({ vi.mock('@/lib/structured-agent-session-launch-options', () => ({ holdStructuredAgentSessionLaunchOption: vi.fn(() => Promise.resolve({ kind: 'held' })), - getStructuredAgentSessionLaunchSelection: () => null + getStructuredAgentSessionLaunchSelection: () => null, + getStructuredAgentSessionLaunchOptionToken: () => mocks.token })) import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' 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 8712f0877fa8..9d6f9c72202b 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 @@ -187,7 +187,11 @@ describe('a chat pane over its own launch', () => { return Promise.resolve(catalog) } if (method === 'agentSession.setOption') { - return Promise.resolve({ ok: true, value: { options: { model: 'opus', effort: 'low' } } }) + return Promise.resolve({ + ok: true, + fence: 1, + value: { options: { model: 'opus', effort: 'low' } } + }) } return new Promise(() => {}) }) 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 276968920557..1936f53f75bc 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 @@ -6,7 +6,10 @@ import { useStructuredAgentSessionLaunchFailure, useStructuredAgentSessionLaunchLifecycle } from '@/lib/structured-agent-session-launch' -import { getStructuredAgentSessionLaunchSelection } from '@/lib/structured-agent-session-launch-options' +import { + type StructuredLaunchAcceptedOptions, + getStructuredAgentSessionLaunchSelection +} from '@/lib/structured-agent-session-launch-options' import { subscribeStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch-registry' import { relaunchFailedStructuredAgentSessionWithMessage } from '@/lib/structured-agent-session-launch-message' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' @@ -18,6 +21,7 @@ export type StructuredAgentSessionLaunchView = { seedOptions?: Readonly> /** Picks the launch holds and applies before it publishes. */ heldOptions: Readonly> + acceptedOptions?: StructuredLaunchAcceptedOptions /** Where the chat runs; its own config may replace the listed default. */ worktree?: string } @@ -36,7 +40,7 @@ function useLaunchView( if (current) { snapshot = current } else if (snapshot && snapshot.held !== NO_HELD_OPTIONS) { - snapshot = { seed: snapshot.seed, held: NO_HELD_OPTIONS } + snapshot = { seed: snapshot.seed, held: NO_HELD_OPTIONS, accepted: snapshot.accepted } } return snapshot } @@ -76,10 +80,11 @@ function useLaunchView( kind: intent.kind, ...(selection?.seed ? { seedOptions: selection.seed } : {}), heldOptions: held, + acceptedOptions: selection?.accepted, ...(worktreeId ? { worktree: toRuntimeWorktreeSelector(worktreeId) } : {}) } : undefined, - [held, intent.kind, selection?.seed, worktreeId] + [held, intent.kind, selection?.seed, selection?.accepted, worktreeId] ) } 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 c02ec19f88c5..4c812ddb65a6 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 @@ -1,3 +1,4 @@ +import type { StructuredLaunchAcceptedOptions } from '@/lib/structured-agent-session-launch-options' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { AgentSessionConversationCommand } from '../../../../shared/agent-session-conversation-command' import type { AgentSessionOptionsResult } from '../../../../shared/agent-session-wire' @@ -5,18 +6,18 @@ import type { AgentSessionRewindSupport } from '../../../../shared/agent-session import type { AgentType } from '../../../../shared/agent-status-types' import type { AgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' import { - applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, - createStructuredAgentSessionOptionState, type StructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' -import { firstFrameOptionState } from './structured-agent-session-first-frame-options' -import { structuredAgentSessionOptionView } from '../../../../shared/structured-agent-session-option-view' +import { + firstFrameOptionState, + structuredAgentSessionConfiguredOptionView +} from './structured-agent-session-first-frame-options' +import { acceptStructuredAgentSessionOptionValues } from '../../../../shared/structured-agent-session-option-view' import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' import { useStructuredAgentSessionHostCapability } from '@/runtime/structured-agent-session-host-capability' 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, @@ -45,6 +46,7 @@ export function useStructuredAgentSessionOptionState(args: { hostSelection: Readonly> | undefined launchSeedOptions: Readonly> | undefined held: Readonly> + acceptedOptions?: StructuredLaunchAcceptedOptions turnId: string | null unloadedTurnRevisions: number | undefined }) { @@ -60,9 +62,6 @@ export function useStructuredAgentSessionOptionState(args: { providerVisible, readsBeforeStart, hostSelection, - hostPermissionMode, - launchSeedOptions, - held, sessionId, target, turnId, @@ -106,14 +105,59 @@ export function useStructuredAgentSessionOptionState(args: { }, [] ) + const heldPick = useRef<{ + token: object | undefined + record: typeof optionState.record + generation: number + identity: string + fence: number | null + } | null>(null) + const beginLaunchPick = useCallback( + (token: object | undefined) => { + const pick = { + token, + record: activeOptionRecordRef.current, + generation: ++optionMutationGeneration.current, + identity, + fence + } + heldPick.current = pick + return () => + heldPick.current === pick && + activeOptionRecordRef.current === pick.record && + optionMutationGeneration.current === pick.generation + }, + [identity, fence] + ) + const optionsReadRef = useRef(null) + const acceptedPacket = useRef(undefined) + if (args.acceptedOptions && acceptedPacket.current !== args.acceptedOptions) { + acceptedPacket.current = args.acceptedOptions + const accepted = args.acceptedOptions + const pick = heldPick.current + if ( + pick && + pick.token === args.acceptedOptions.token && + pick.identity === identity && + pick.record === activeOptionRecordRef.current && + pick.generation === optionMutationGeneration.current && + ((fence === null && pick.fence === null) || + (fence === accepted.fence && (pick.fence === null || pick.fence === fence))) + ) { + pick.fence = accepted.fence + pick.generation = ++optionMutationGeneration.current + updateOptionState((current) => + acceptStructuredAgentSessionOptionValues(current, accepted.options, hostSelection) + ) + optionsReadRef.current?.run() + } + } const optionIdentityRef = useRef(identity) useEffect(() => { const previous = optionStateRef.current const sameSession = optionIdentityRef.current === identity optionIdentityRef.current = identity - if (sameSession && selectionFromStatus) { - optionMutationGeneration.current += 1 - updateOptionState((current) => ({ ...current, pendingPick: null, reconciliation: undefined })) + if (sameSession && (selectionFromStatus || heldPick.current?.fence === null)) { return } const launch = firstFrameLaunch.current @@ -141,7 +185,8 @@ export function useStructuredAgentSessionOptionState(args: { updateOptionState(() => next) }, [agent, fence, identity, selectionFromStatus, updateOptionState]) - const optionsReadRef = useRef(null) + const currentHostStamp = useRef(hostOptionStamp) + currentHostStamp.current = hostOptionStamp // The read also carries commands, goal, context, image and rewind support. A capable host's // selection comes from status even when this metadata read returns an older model. useEffect(() => { @@ -151,35 +196,46 @@ export function useStructuredAgentSessionOptionState(args: { let stale = false const runner = createCoalescedPollRunner(async () => { const readGeneration = optionMutationGeneration.current + const readRecord = activeOptionRecordRef.current + const readStamp = currentHostStamp.current const result = await callStructuredAgentSession( target, 'agentSession.options', { sessionId } ) - if (!stale && optionMutationGeneration.current === readGeneration) { - setConversationSupport({ - sessionId, - commands: result.conversationCommands ?? [], - threadGoal: result.threadGoal, - reportedThreadGoal: result.reportedThreadGoal, - contextUsage: result.contextUsage, - acceptsImages: result.acceptsImages, - // A host that predates rewind does not name it. - rewind: result.rewind ?? { supported: false, reason: 'unsupported' }, - fence - }) - updateOptionState((current) => { - if (current.record !== activeOptionRecordRef.current) { - return current - } - return applyStructuredAgentSessionOptions( - current, - optionCatalog, - result, - selectionFromStatus - ) - }) + if (stale) { + return + } + if ( + optionMutationGeneration.current !== readGeneration || + readRecord !== activeOptionRecordRef.current || + readStamp !== currentHostStamp.current + ) { + runner.run() + return } + setConversationSupport({ + sessionId, + commands: result.conversationCommands ?? [], + threadGoal: result.threadGoal, + reportedThreadGoal: result.reportedThreadGoal, + contextUsage: result.contextUsage, + acceptsImages: result.acceptsImages, + // A host that predates rewind does not name it. + rewind: result.rewind ?? { supported: false, reason: 'unsupported' }, + fence + }) + updateOptionState((current) => { + if (current.record !== activeOptionRecordRef.current) { + return current + } + return applyStructuredAgentSessionOptions( + current, + optionCatalog, + result, + selectionFromStatus + ) + }) }) optionsReadRef.current = runner runner.run() @@ -218,84 +274,23 @@ export function useStructuredAgentSessionOptionState(args: { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY ) - const configured = readHostModelCatalogSnapshot(target, agent, { - newLaunch, - worktree, - seedsModel - }) return { + beginLaunchPick, takesSendOptions, conversationSupport, rereadOptions, optionState, - optionView: useMemo(() => { - // Live rows supply choices; the latest scoped host answer supplies the launch default. - const scoped = - configured && optionCatalog - ? applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState(agent), - optionCatalog, - configured, - { newLaunch } - ).catalog - : null - const defaultModel = scoped?.defaultModelIsCliDefault - ? scoped.models.find((model) => model.isDefault) - : undefined - const defaultEffort = defaultModel?.options.find((option) => option.id === 'effort')?.kind - const catalog = - optionState.catalog && selectionFromStatus && defaultModel - ? { - ...optionState.catalog, - defaultModelIsCliDefault: defaultModel ? (true as const) : undefined, - models: optionState.catalog.models.map((model) => ({ - ...model, - isDefault: model.id === defaultModel?.id, - options: model.options.map((option) => - model.id === defaultModel?.id && - option.id === 'effort' && - option.kind.type === 'select' && - defaultEffort?.type === 'select' && - defaultEffort.defaultIsCliDefault && - option.kind.choices.some((choice) => choice.value === defaultEffort.defaultValue) - ? { - ...option, - kind: { - ...option.kind, - defaultValue: defaultEffort.defaultValue, - defaultIsCliDefault: true as const - } - } - : option - ) - })) - } - : optionState.catalog - return structuredAgentSessionOptionView( - { - ...optionState, - catalog, - pendingPick: optionIdentityRef.current === identity ? optionState.pendingPick : null - }, - selectionFromStatus ? undefined : launchSeedOptions, - held, - takesSendOptions, - { selection: hostSelection, permissionMode: hostPermissionMode } - ) - }, [ - agent, - configured, - identity, - newLaunch, - optionCatalog, - held, - hostSelection, - hostPermissionMode, - launchSeedOptions, - optionState, - selectionFromStatus, - takesSendOptions - ]), + optionView: useMemo( + () => + structuredAgentSessionConfiguredOptionView( + args, + optionState, + takesSendOptions, + optionIdentityRef.current === identity + ), + [args, identity, optionState, takesSendOptions] + ), + optionStateRef, activeOptionRecordRef, optionMutationGeneration, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx index 05f35c1e3860..220ca5431f45 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx @@ -3,7 +3,12 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ call: vi.fn(), summary: vi.fn(), settings: vi.fn() })) +const mocks = vi.hoisted(() => ({ + call: vi.fn(), + summary: vi.fn(), + settings: vi.fn(), + hold: vi.fn() +})) vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) vi.mock('@/runtime/structured-agent-session-client', () => ({ @@ -13,10 +18,12 @@ vi.mock('./native-chat-session-option-settings-write', () => ({ enqueueSessionOptionSettingsWrite: mocks.settings })) vi.mock('@/lib/structured-agent-session-launch-options', () => ({ - holdStructuredAgentSessionLaunchOption: vi.fn(), + holdStructuredAgentSessionLaunchOption: mocks.hold, + getStructuredAgentSessionLaunchOptionToken: () => mocks.hold, getStructuredAgentSessionLaunchSelection: () => null })) +import type { StructuredLaunchAcceptedOptions } from '@/lib/structured-agent-session-launch-options' import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' @@ -59,6 +66,7 @@ beforeEach(() => { mocks.call.mockReset() mocks.summary.mockReset() mocks.settings.mockReset() + mocks.hold.mockReset() resetHostModelCatalogSnapshotsForTests() mocks.call.mockImplementation(async (_target: unknown, method: string) => method === 'agentSession.modelCatalog' @@ -141,31 +149,39 @@ it('uses the existing options read when an older host omits the selection field' view.unmount() }) -it('keeps accepted B, adopts current C without its echo, then reads a fresh equal A snapshot', async () => { +it.each([false, true])('reconciles choices (held: %s)', async (held) => { mocks.summary.mockImplementation(() => ({ optionSelection: { model: 'gpt-one' } })) const write = deferred<{ options: Record }>() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deferred result is the setOption reply the hook reads. const mutate = vi.fn(() => write.promise) as unknown as StructuredAgentSessionMutate + let created = !held + let acceptedOptions: StructuredLaunchAcceptedOptions | undefined + mocks.hold.mockImplementation(() => + write.promise.then(({ options }) => ({ kind: 'accepted', options, fence: 1 })) + ) const view = renderHook(() => useStructuredAgentSessionOptions({ agent: 'codex', sessionId: 'session-1', target: LOCAL, - transportEnabled: true, + transportEnabled: created, isVisible: true, providerVisible: true, optionSelection: mocks.summary()?.optionSelection, hostOptionStamp: mocks.summary()?.stamp, hostPermissionMode: 'ask', - fence: 1, + fence: created ? 1 : null, turnId: null, unloadedTurnRevisions: undefined, mutate, - launch: { kind: 'new', heldOptions: {} } + launch: { kind: 'new', heldOptions: {}, acceptedOptions } }) ) await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) - const readback = deferred<{ models: typeof MODELS; current: { model: string; effort: string } }>() + const readback = deferred<{ + models: typeof MODELS + current: { model: string; effort: string } + }>() const during = deferred<{ models: typeof MODELS; current: { model: string; effort: string } }>() let reads = 0 mocks.call.mockImplementation((_target: unknown, method: string) => @@ -179,9 +195,13 @@ it('keeps accepted B, adopts current C without its echo, then reads a fresh equa let second!: Promise act(() => { first = view.result.current.setStructuredOption('model', 'gpt-two') - second = view.result.current.setStructuredOption('model', 'gpt-two') + if (!held) { + second = view.result.current.setStructuredOption('model', 'gpt-two') + } }) - await expect(second).resolves.toBe(false) + if (!held) { + await expect(second).resolves.toBe(false) + } mocks.summary.mockImplementation(() => ({ optionSelection: { model: 'gpt-one' }, stamp: 'during-write' @@ -189,11 +209,18 @@ it('keeps accepted B, adopts current C without its echo, then reads a fresh equa view.rerender() await waitFor(() => expect(reads).toBe(1)) await act(async () => { - expect(await view.result.current.setStructuredOption('model', 'gpt-two')).toBe(false) + if (!held) { + expect(await view.result.current.setStructuredOption('model', 'gpt-two')).toBe(false) + } + if (held) { + acceptedOptions = { options: { model: 'gpt-two' }, fence: 1, token: mocks.hold } + } + created = true + view.rerender() write.resolve({ options: { model: 'gpt-two' } }) await first }) - expect(mutate).toHaveBeenCalledOnce() + expect(held ? mocks.hold : mutate).toHaveBeenCalledOnce() expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') expect( view.result.current.optionSnapshot.find((option) => option.id === 'permissionMode')?.kind @@ -210,7 +237,10 @@ it('keeps accepted B, adopts current C without its echo, then reads a fresh equa await waitFor(() => expect(pill(view.result.current.optionSnapshot)).toEqual({ model: 'gpt-one', effort: 'low' }) ) - mocks.call.mockResolvedValue({ models: MODELS, current: { model: 'gpt-one', effort: 'medium' } }) + mocks.call.mockResolvedValue({ + models: MODELS, + current: { model: 'gpt-one', effort: 'medium' } + }) mocks.summary.mockImplementation(() => ({ optionSelection: { model: 'gpt-one' }, stamp: 'fresh-snapshot' 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 6f2447a30e1a..04ece8c75f4b 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 @@ -22,7 +22,8 @@ vi.mock('./native-chat-session-option-settings-write', () => ({ vi.mock('@/lib/structured-agent-session-launch-options', () => ({ holdStructuredAgentSessionLaunchOption: mocks.hold, - getStructuredAgentSessionLaunchSelection: () => null + getStructuredAgentSessionLaunchSelection: () => null, + getStructuredAgentSessionLaunchOptionToken: () => mocks.hold })) import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' @@ -422,7 +423,11 @@ describe('useStructuredAgentSessionOptions', () => { it('remembers a pick the launch applied as the next launch default', async () => { answer({ modelCatalog: () => Promise.resolve(SAVED_LIST) }) - mocks.hold.mockResolvedValue({ kind: 'accepted', options: { model: 'gpt-5.6-luna' } }) + mocks.hold.mockResolvedValue({ + kind: 'accepted', + fence: 1, + options: { model: 'gpt-5.6-luna' } + }) const { result, unmount } = renderOptions( { ...PROVISIONAL, launchSeedOptions: SEED }, mutateWith(async () => null).mutate @@ -584,7 +589,7 @@ describe('useStructuredAgentSessionOptions', () => { describe("a paired server's new chat", () => { it("shows the server's seed once its list answers and remembers a pick on the server under its model", async () => { answer({ modelCatalog: () => Promise.resolve(SAVED_LIST) }) - mocks.hold.mockResolvedValue({ kind: 'accepted', options: { effort: 'high' } }) + mocks.hold.mockResolvedValue({ kind: 'accepted', fence: 1, options: { effort: 'high' } }) const { result, unmount } = renderOptions( { ...PROVISIONAL, paired: true, launchSeedOptions: SEED }, mutateWith(async () => null).mutate @@ -610,7 +615,7 @@ describe('useStructuredAgentSessionOptions', () => { // A server whose user never saved a model starts the CLI default, as a local chat does. it('shows the CLI default and takes picks when the server saved no model', async () => { answer({ modelCatalog: () => Promise.resolve(HOST_CATALOG) }) - mocks.hold.mockResolvedValue({ kind: 'accepted', options: { model: 'gpt-hosted' } }) + mocks.hold.mockResolvedValue({ kind: 'accepted', fence: 1, options: { model: 'gpt-hosted' } }) const { result, unmount } = renderOptions( { ...PROVISIONAL, paired: true }, mutateWith(async () => null).mutate 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 4070b024ee2c..9c2f87e1dd4d 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 @@ -12,6 +12,7 @@ import { structuredAgentSessionOptionSnapshot, type StructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' +import { acceptStructuredAgentSessionOptionValues } from '../../../../shared/structured-agent-session-option-view' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability' import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-settings-write' @@ -23,6 +24,7 @@ import { useStructuredAgentSessionOptionSurface } from './use-structured-agent-s import { agentSessionWriteFailureText } from './agent-session-write-notice-text' import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch' import { + getStructuredAgentSessionLaunchOptionToken, holdStructuredAgentSessionLaunchOption, type StructuredLaunchOptionOutcome } from '@/lib/structured-agent-session-launch-options' @@ -66,6 +68,7 @@ export function useStructuredAgentSessionOptions(args: { const identity = `${agent}:${sessionId}:${structuredAgentSessionHostKey(target)}` const selectionFromStatus = args.optionSelection !== undefined const { + beginLaunchPick, takesSendOptions, optionState, optionView, @@ -95,6 +98,7 @@ export function useStructuredAgentSessionOptions(args: { hostPermissionMode: args.hostPermissionMode, launchSeedOptions, held, + acceptedOptions: launch?.acceptedOptions, turnId, unloadedTurnRevisions: args.unloadedTurnRevisions }) @@ -158,17 +162,11 @@ export function useStructuredAgentSessionOptions(args: { committed = acceptedValues updateOptionState((current) => current.record === targetRecord - ? { - ...commitStructuredAgentSessionOptionValues(current, { - ...(current.reconciliation ? {} : args.optionSelection), - ...acceptedValues - }), - reconciliation: { - permission: - current.reconciliation?.permission || - acceptedValues.permissionMode !== undefined - } - } + ? acceptStructuredAgentSessionOptionValues( + current, + acceptedValues, + args.optionSelection + ) : current ) rememberOptionPicks(optionView, acceptedValues) @@ -235,12 +233,22 @@ export function useStructuredAgentSessionOptions(args: { if (!transportEnabled || fence === null) { // No fence yet: the launch holds it and applies it before anything else is sent. const applied = holdStructuredAgentSessionLaunchOption(sessionId, id, encoded) - void applied?.then(settleLaunchOptionPick) + if (applied) { + const isCurrent = beginLaunchPick( + getStructuredAgentSessionLaunchOptionToken(sessionId, id) + ) + void applied.then((outcome) => { + if (isCurrent()) { + settleLaunchOptionPick(outcome) + } + }) + } return applied === null ? null : { [id]: encoded } } return sendStructuredOption(id, encoded) }, [ + beginLaunchPick, fence, optionCatalog, optionView, diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts index 74d4a7b663fd..45e359785337 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts @@ -406,7 +406,7 @@ describe('the first message owned by create', () => { 'agentSession.create', 'agentSession.setOption' ]) - option.resolve({ ok: true, value: { selected: 'later-pick' } }) + option.resolve({ ok: true, fence: 1, value: { selected: 'later-pick' } }) }) it('recovers a reload by replaying the frozen create when the old row is outside history', async () => { diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts index 42918e981177..efbc9d2804b2 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts @@ -83,7 +83,7 @@ export function publishStructuredLaunchCreateOptions( if (state.intent.createMessageSupport !== true) { return applyStructuredLaunchHeldOptions(state, receipt) } - settleStructuredLaunchCreateOptions(state, state.intent.params.options ?? {}) + settleStructuredLaunchCreateOptions(state, state.intent.params.options ?? {}, receipt.fence) void applyStructuredLaunchHeldOptions(state, receipt).catch((error: unknown) => { console.warn('[native-chat] applying a launch option failed', error) }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts index 8d700ba9227f..b991ad022a37 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts @@ -69,6 +69,7 @@ import { startStructuredAgentLaunch } from './structured-agent-session-launch' import { + getStructuredAgentSessionLaunchOptionToken, getStructuredAgentSessionLaunchSelection, holdStructuredAgentSessionLaunchOption } from './structured-agent-session-launch-options' @@ -130,7 +131,7 @@ function deferred(): Deferred { return { promise, resolve } } -const ACCEPTED = { ok: true, value: { key: 'model', value: 'x' } } +const ACCEPTED = { ok: true, fence: 1, value: { key: 'model', value: 'x' } } function mutations(): { method: string; fence?: unknown; key?: unknown; value?: unknown }[] { return mocks.call.mock.calls @@ -206,15 +207,17 @@ describe('picks made while a chat launches', () => { ]) setOptionReplies[0]!.resolve({ ok: true, + fence: 7, value: { key: 'model', value: 'gpt-picked', options: { model: 'gpt-picked' } } }) await expect(modelPick).resolves.toEqual({ kind: 'accepted', + fence: 7, options: { model: 'gpt-picked' } }) await settle() expect(lifecycle()).toBe('pending') - setOptionReplies[1]!.resolve(ACCEPTED) + setOptionReplies[1]!.resolve({ ...ACCEPTED, fence: 7 }) await launch.promptDeliveryResult expect(mutations()).toEqual([ @@ -349,17 +352,23 @@ describe('picks made while a chat launches', () => { startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-7' }) holdStructuredAgentSessionLaunchOption(SESSION_ID, 'effort', 'high') await settle() + const token = getStructuredAgentSessionLaunchOptionToken(SESSION_ID, 'effort') const seen: unknown[] = [] subscribeStructuredAgentLaunchStatus(() => seen.push(getStructuredAgentSessionLaunchSelection(SESSION_ID)) ) setOptionReplies[0]!.resolve({ ok: true, + fence: 1, value: { key: 'effort', value: 'high', options: { model: 'gpt-seeded', effort: 'high' } } }) await settle() - expect(seen[0]).toEqual({ seed: { model: 'gpt-seeded', effort: 'high' }, held: {} }) + expect(seen[0]).toEqual({ + seed: { model: 'gpt-seeded', effort: 'high' }, + held: {}, + accepted: { options: { model: 'gpt-seeded', effort: 'high' }, fence: 1, token } + }) expect(lifecycle()).toBeNull() }) }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index 0b18e0c253ce..8306c87bc81e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -35,13 +35,20 @@ export type StructuredLaunchSelection = { seed?: Readonly> /** Encoded picks made before publish; the launch applies them before it publishes. */ held: Readonly> + accepted?: StructuredLaunchAcceptedOptions } export type StructuredLaunchOptionOutcome = - | { kind: 'accepted'; options: Readonly> } + | { kind: 'accepted'; options: Readonly>; fence: number } | { kind: 'refused'; failure: AgentSessionWriteFailure } | { kind: 'superseded' } +export type StructuredLaunchAcceptedOptions = { + options: Readonly> + fence: number + token: object +} + type OptionReply = (outcome: StructuredLaunchOptionOutcome) => void const optionReplies = new WeakMap>() @@ -110,7 +117,7 @@ async function setLaunchOption( ...fields }) return result.ok - ? { kind: 'accepted', options: result.value.options ?? { [key]: value } } + ? { kind: 'accepted', options: result.value.options ?? { [key]: value }, fence: result.fence } : { kind: 'refused', failure: agentSessionRefusalFailure(result.refusal) } } catch (error) { return { @@ -135,7 +142,17 @@ function settleHeldOption( } = state.selection // A pick made while this one was in flight superseded it and stays held for the next pass. const settled = current === encoded + const token = settled ? repliesFor(state).get(id) : undefined + const accepted = + outcome.kind === 'accepted' && token + ? { + options: { ...state.selection.accepted?.options, ...outcome.options }, + fence: outcome.fence, + token + } + : state.selection.accepted state.selection = { + accepted, seed: outcome.kind === 'accepted' ? { ...seed, ...outcome.options } : seed, held: settled ? rest : state.selection.held } @@ -151,10 +168,11 @@ function settleHeldOption( /** Selections included in the durable create are already the host's option intent. */ export function settleStructuredLaunchCreateOptions( state: StructuredLaunchState, - options: Readonly> + options: Readonly>, + fence: number ): void { for (const [id, encoded] of Object.entries(options)) { - settleHeldOption(state, id, encoded, { kind: 'accepted', options }) + settleHeldOption(state, id, encoded, { kind: 'accepted', options, fence }) } } @@ -188,6 +206,14 @@ export async function applyStructuredLaunchHeldOptions( } } +export function getStructuredAgentSessionLaunchOptionToken( + sessionId: string, + id: string +): object | undefined { + const state = getStructuredLaunchStateBySessionId(sessionId) + return state ? optionReplies.get(state)?.get(id) : undefined +} + export function getStructuredAgentSessionLaunchSelection( sessionId: string ): StructuredLaunchSelection | null { diff --git a/src/shared/structured-agent-session-option-view.ts b/src/shared/structured-agent-session-option-view.ts index 26ca92c8149b..82d566ba191f 100644 --- a/src/shared/structured-agent-session-option-view.ts +++ b/src/shared/structured-agent-session-option-view.ts @@ -7,6 +7,22 @@ import type { SessionOptionDescriptor } from './native-chat-session-options' import { cloneNativeChatSessionOptionRecord } from './native-chat-session-option-state' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode' +export function acceptStructuredAgentSessionOptionValues( + state: StructuredAgentSessionOptionState, + values: Readonly>, + predecessor?: Readonly> +): StructuredAgentSessionOptionState { + return { + ...commitStructuredAgentSessionOptionValues(state, { + ...(state.reconciliation ? {} : predecessor), + ...values + }), + reconciliation: { + permission: state.reconciliation?.permission === true || values.permissionMode !== undefined + } + } +} + function catalogListsSeedModel( state: StructuredAgentSessionOptionState, seed: Readonly> @@ -32,7 +48,7 @@ export function structuredAgentSessionOptionView( takesSendOptions: boolean, host?: { selection?: Readonly>; permissionMode?: string } ): StructuredAgentSessionOptionState { - const selection = state.reconciliation ? undefined : host?.selection + const selection = state.reconciliation || state.executionUnknown ? undefined : host?.selection const pending = state.pendingPick if (selection !== undefined || pending) { state = { ...state, record: cloneNativeChatSessionOptionRecord(state.record) } diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index b70fe5c25300..8d246f957372 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -61,6 +61,7 @@ export type StructuredAgentSessionOptionState = { permissionMode: string | null /** Accepted or read-back facts supersede the preceding cached status. */ reconciliation?: { permission: boolean } + executionUnknown?: boolean pendingPick: { id: string; value: string } | null } @@ -172,7 +173,8 @@ export function applyStructuredAgentSessionOptions( catalogSource: result.modelsKnown === false ? state.catalogSource : 'live', reconciliation: state.reconciliation ? { - permission: result.current.permissionMode !== undefined || state.reconciliation.permission + permission: + result.current.permissionMode !== undefined || state.reconciliation?.permission === true } : undefined, permissionMode: result.current.permissionMode ?? state.permissionMode From c48e772bb1618e2138abcd02abe6bb7b88ffa840 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:05:54 -0700 Subject: [PATCH 115/127] Restore established client selection flow --- .../mobile-structured-agent-options-input.ts | 18 - ...e-structured-agent-session-host-support.ts | 6 +- .../mobile-structured-option-snapshot.ts | 47 --- .../mobile-structured-session-status-feed.ts | 8 - ...ructured-agent-options.first-frame.test.ts | 44 +-- ...se-mobile-structured-agent-options.test.ts | 34 -- .../use-mobile-structured-agent-options.ts | 195 +++++----- .../use-mobile-structured-agent-session.ts | 51 ++- .../use-mobile-structured-option-surface.ts | 12 +- ...use-mobile-structured-send-with-outcome.ts | 16 +- .../use-mobile-structured-sends.test.tsx | 18 +- ...-mobile-structured-session-option-stamp.ts | 24 ++ ...mobile-structured-session-option-status.ts | 44 --- .../structured-agent-session-options-read.ts | 6 +- ...d-agent-session-status-summary-equality.ts | 2 - ...structured-agent-session-status-summary.ts | 1 - .../NativeChatStructuredSession.tsx | 21 +- .../StructuredAgentSessionStatusBridge.tsx | 78 ++-- ...hat-agent-message-sender-identity.test.tsx | 1 - ...ured-agent-session-creation-picks.test.tsx | 267 -------------- ...tured-agent-session-first-frame-options.ts | 121 ------ .../use-host-model-catalog-upgrade.test.tsx | 5 +- .../use-host-model-catalog-upgrade.ts | 9 +- ...se-native-chat-provisional-launch.test.tsx | 6 +- .../use-native-chat-provisional-launch.ts | 11 +- ...e-structured-agent-session-option-state.ts | 193 ++++------ ...structured-agent-session-option-surface.ts | 55 --- ...session-options.current-selection.test.tsx | 344 ------------------ ...-structured-agent-session-options.test.tsx | 47 +-- .../use-structured-agent-session-options.ts | 199 +++++----- ...ctured-agent-session-send-options.test.tsx | 81 +++++ ...e-structured-agent-session-send-options.ts | 34 ++ ...se-structured-agent-session-sends.test.tsx | 37 +- .../use-structured-agent-session-sends.ts | 23 +- .../use-structured-agent-session.ts | 12 +- ...gent-session-launch-create-message.test.ts | 2 +- ...red-agent-session-launch-create-message.ts | 4 +- ...tured-agent-session-launch-options.test.ts | 15 +- ...structured-agent-session-launch-options.ts | 34 +- ...ructured-agent-session-status-feed.test.ts | 5 - .../structured-agent-session-status-feed.ts | 4 - ...-session-execution-option-snapshot.test.ts | 82 ----- ...agent-session-execution-option-snapshot.ts | 26 -- .../agent-session-finished-model-listing.ts | 26 -- src/shared/agent-session-options-result.ts | 6 +- src/shared/agent-session-wire.ts | 2 - ...ructured-agent-session-option-view.test.ts | 85 ++--- .../structured-agent-session-option-view.ts | 55 +-- .../structured-agent-session-options.ts | 99 ++--- 49 files changed, 692 insertions(+), 1823 deletions(-) delete mode 100644 mobile/src/session/mobile-structured-agent-options-input.ts delete mode 100644 mobile/src/session/mobile-structured-option-snapshot.ts create mode 100644 mobile/src/session/use-mobile-structured-session-option-stamp.ts delete mode 100644 mobile/src/session/use-mobile-structured-session-option-status.ts delete mode 100644 src/renderer/src/components/native-chat/structured-agent-session-creation-picks.test.tsx delete mode 100644 src/renderer/src/components/native-chat/structured-agent-session-first-frame-options.ts delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-option-surface.ts delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-send-options.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts delete mode 100644 src/shared/agent-session-execution-option-snapshot.test.ts delete mode 100644 src/shared/agent-session-execution-option-snapshot.ts delete mode 100644 src/shared/agent-session-finished-model-listing.ts diff --git a/mobile/src/session/mobile-structured-agent-options-input.ts b/mobile/src/session/mobile-structured-agent-options-input.ts deleted file mode 100644 index 8d27250555f5..000000000000 --- a/mobile/src/session/mobile-structured-agent-options-input.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { RpcClient } from '../transport/rpc-client' -import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' -import type { StructuredAgentSessionMutate } from './mobile-structured-agent-session-rpc' - -export type MobileStructuredAgentOptionsInput = { - agent: string | null - client: RpcClient | null - sessionId: string | null - enabled: boolean - fence: number | null - mutate: StructuredAgentSessionMutate - /** Moves when the host's status says a session option changed; each move re-reads them. */ - optionStamp?: string - /** What the host advertised; null until its status probe answers. */ - hostSupport: Pick | null - hostSelection?: Readonly> - hostPermissionMode?: string -} diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts index 61d49337c56a..c58ac06176f7 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -4,6 +4,7 @@ import { AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, + AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY, AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' @@ -18,12 +19,14 @@ export type StructuredAgentSessionHostSupport = { queuedMessages: boolean /** A /compact sent while the agent works waits as a card; an older host refuses it. */ queuedCommands: boolean + /** Absent on older hosts: queued Edit uses the legacy composer copy there. */ + queuedMessageEdit?: boolean /** The host publishes every session's status on one stream. A host from before phones could * read it refuses the call, which reads the same as its absence. */ statusFeed: boolean /** A Stop that stopped nothing adds no row, so a repeated Stop is quiet. */ quietRepeatedStop: boolean - /** A send may carry the choices its turn runs on. */ + /** A send may carry the model and effort its turn runs on. */ sendOptions: boolean /** A Stop naming no turn stops what the chat has in flight; an older host needs the turn. */ conversationStop: boolean @@ -39,6 +42,7 @@ export function structuredAgentSessionHostSupport( questionAnswers: capabilities.includes(AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY), queuedMessages: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY), queuedCommands: capabilities.includes(AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY), + queuedMessageEdit: capabilities.includes(AGENT_SESSION_QUEUED_MESSAGE_EDIT_RUNTIME_CAPABILITY), statusFeed: capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY), quietRepeatedStop: capabilities.includes(AGENT_SESSION_REPEATED_STOP_RUNTIME_CAPABILITY), sendOptions: capabilities.includes(AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY), diff --git a/mobile/src/session/mobile-structured-option-snapshot.ts b/mobile/src/session/mobile-structured-option-snapshot.ts deleted file mode 100644 index 6ad0e636f622..000000000000 --- a/mobile/src/session/mobile-structured-option-snapshot.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options' -import { - commitStructuredAgentSessionOptionValues, - structuredAgentSessionOptionSnapshot, - type StructuredAgentSessionOptionState -} from '../../../src/shared/structured-agent-session-options' -import { structuredAgentSessionOptionView } from '../../../src/shared/structured-agent-session-option-view' - -export function commitMobileStructuredOptions( - state: StructuredAgentSessionOptionState, - hostSelection: Readonly> | undefined, - values: Readonly> -): StructuredAgentSessionOptionState { - return { - ...commitStructuredAgentSessionOptionValues(state, { - ...(state.reconciliation ? {} : hostSelection), - ...values - }), - reconciliation: { - permission: state.reconciliation?.permission || values.permissionMode !== undefined - } - } -} - -/** Display, validation and send share the host selection and this phone's pending pick. */ -export function mobileStructuredOptionView( - state: StructuredAgentSessionOptionState, - hostSelection: Readonly> | undefined, - takesSendOptions: boolean, - permissionMode?: string -): StructuredAgentSessionOptionState { - return structuredAgentSessionOptionView(state, undefined, {}, takesSendOptions, { - selection: hostSelection, - permissionMode - }) -} - -export function mobileStructuredOptionSnapshot( - state: StructuredAgentSessionOptionState, - hostSelection: Readonly> | undefined, - takesSendOptions: boolean, - permissionMode?: string -): SessionOptionDescriptor[] { - return structuredAgentSessionOptionSnapshot( - mobileStructuredOptionView(state, hostSelection, takesSendOptions, permissionMode) - ) -} diff --git a/mobile/src/session/mobile-structured-session-status-feed.ts b/mobile/src/session/mobile-structured-session-status-feed.ts index 762a6c74dcad..12db68c71772 100644 --- a/mobile/src/session/mobile-structured-session-status-feed.ts +++ b/mobile/src/session/mobile-structured-session-status-feed.ts @@ -15,7 +15,6 @@ import type { RpcClient } from '../transport/rpc-client' export type MobileStructuredSessionStatusFeed = { subscribe: (listener: () => void) => () => void - getSnapshotSample: (sessionId: string) => number getSnapshot: () => AgentSessionStatusSnapshot } @@ -50,7 +49,6 @@ function streamRefusal(value: unknown): { code?: string; message?: string } | nu } function createFeed(client: RpcClient): MobileStructuredSessionStatusFeed { - const snapshotSamples = new Map() let snapshot: AgentSessionStatusSnapshot = new Map() // `unavailable`: the host refused the method to phones; asked again only on a new connection. let stream: 'closed' | 'open' | 'unavailable' = 'closed' @@ -80,11 +78,6 @@ function createFeed(client: RpcClient): MobileStructuredSessionStatusFeed { revokeLive() return } - if (raw.type === 'snapshot') { - for (const session of raw.sessions) { - snapshotSamples.set(session.sessionId, (snapshotSamples.get(session.sessionId) ?? 0) + 1) - } - } setSnapshot(foldAgentSessionStatusEvent(snapshot, raw)) return } @@ -130,7 +123,6 @@ function createFeed(client: RpcClient): MobileStructuredSessionStatusFeed { listeners.delete(listener) } }, - getSnapshotSample: (sessionId) => snapshotSamples.get(sessionId) ?? 0, getSnapshot: () => snapshot } } diff --git a/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts b/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts index fff2a9b9fb60..60eaa6d75a5d 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.first-frame.test.ts @@ -1,6 +1,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { AgentSessionModelCatalogResult } from '../../../src/shared/agent-session-wire' import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options' import type { RpcClient } from '../transport/rpc-client' @@ -9,10 +9,7 @@ import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-o const HOST_LIST: AgentSessionModelCatalogResult = { origin: 'probe', - models: [ - { id: 'gpt-host', label: 'GPT Host', isDefault: true, efforts: [] }, - { id: 'gpt-alt', label: 'GPT Alt', isDefault: false, efforts: [] } - ], + models: [{ id: 'gpt-host', label: 'GPT Host', isDefault: true, efforts: [] }], fetchedAt: 1_000 } @@ -39,14 +36,11 @@ function modelChoices(snapshot: readonly SessionOptionDescriptor[]): string[] { return model?.kind.type === 'select' ? model.kind.choices.map((choice) => choice.value) : [] } -type Props = { rpc: RpcClient; fence: number; hostSelection?: Readonly> } +type Props = { rpc: RpcClient; fence: number } async function mount(initial: Props) { const frames: string[][] = [] - const snapshots: SessionOptionDescriptor[][] = [] - const mutate: StructuredAgentSessionMutate = async () => new Promise(() => {}) - let pick: ReturnType['setStructuredOption'] = async () => - false + const mutate: StructuredAgentSessionMutate = vi.fn() function Probe(props: Props): null { const options = useMobileStructuredAgentOptions({ agent: 'codex', @@ -54,13 +48,10 @@ async function mount(initial: Props) { sessionId: 'session-1', enabled: true, fence: props.fence, - hostSelection: props.hostSelection, mutate, hostSupport: { sendOptions: true, permissionModes: true } }) frames.push(modelChoices(options.optionSnapshot)) - snapshots.push(options.optionSnapshot) - pick = options.setStructuredOption return null } let renderer: ReactTestRenderer | null = null @@ -69,8 +60,6 @@ async function mount(initial: Props) { }) return { frames, - snapshots, - pick: (id: string, value: string) => pick(id, value), update: (next: Props) => act(async () => { renderer?.update(createElement(Probe, next)) @@ -86,10 +75,10 @@ describe('the phone picker’s first frame', () => { const harness = await mount({ rpc, fence: 1 }) expect(harness.frames.every((choices) => choices.length === 0)).toBe(true) await act(async () => answer(HOST_LIST)) - expect(harness.frames.at(-1)).toEqual(['gpt-host', 'gpt-alt']) + expect(harness.frames.at(-1)).toEqual(['gpt-host']) // Only the host's list was ever offered. expect(harness.frames.filter((choices) => choices.length > 0)).toEqual( - harness.frames.filter((choices) => choices.length > 0).map(() => ['gpt-host', 'gpt-alt']) + harness.frames.filter((choices) => choices.length > 0).map(() => ['gpt-host']) ) await harness.unmount() }) @@ -98,7 +87,7 @@ describe('the phone picker’s first frame', () => { let reads = 0 const rpc = client(() => (reads++ === 0 ? Promise.resolve(HOST_LIST) : new Promise(() => {}))) const harness = await mount({ rpc, fence: 1 }) - expect(harness.frames.at(-1)).toEqual(['gpt-host', 'gpt-alt']) + expect(harness.frames.at(-1)).toEqual(['gpt-host']) const before = harness.frames.length await harness.update({ rpc, fence: 2 }) expect(harness.frames.slice(before).every((choices) => choices[0] === 'gpt-host')).toBe(true) @@ -111,23 +100,4 @@ describe('the phone picker’s first frame', () => { expect(harness.frames.at(-1)?.length).toBeGreaterThan(0) await harness.unmount() }) - - it('uses the host’s live selection when the options read has not answered', async () => { - const rpc = client(() => Promise.resolve(HOST_LIST)) - const harness = await mount({ rpc, fence: 1, hostSelection: { model: 'gpt-host' } }) - const model = harness.snapshots.at(-1)?.find((entry) => entry.id === 'model') - expect(model?.kind.currentValue).toBe('gpt-host') - await harness.unmount() - }) - - it('includes a pick still waiting for the host in the next message snapshot', async () => { - const rpc = client(() => Promise.resolve(HOST_LIST)) - const harness = await mount({ rpc, fence: 1, hostSelection: { model: 'gpt-host' } }) - await act(async () => { - void harness.pick('model', 'gpt-alt') - }) - const model = harness.snapshots.at(-1)?.find((entry) => entry.id === 'model') - expect(model?.kind.currentValue).toBe('gpt-alt') - await harness.unmount() - }) }) diff --git a/mobile/src/session/use-mobile-structured-agent-options.test.ts b/mobile/src/session/use-mobile-structured-agent-options.test.ts index 2e85c161083f..918c614e962f 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.test.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.test.ts @@ -12,7 +12,6 @@ import type { StructuredAgentSessionMutate, StructuredAgentSessionMutationResult } from './mobile-structured-agent-session-rpc' -import { agentSessionExecutionOptionsFromSnapshot } from '../../../src/shared/agent-session-execution-option-snapshot' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' import { rememberMobileCreatedStructuredSession } from './mobile-created-structured-sessions' @@ -147,7 +146,6 @@ type ProbeProps = { sessionId: string | null fence: number | null mutate: StructuredAgentSessionMutate - hostSelection?: Readonly> onRender: (controller: Controller) => void } @@ -160,7 +158,6 @@ function Probe(props: ProbeProps): null { enabled: true, fence: props.fence, mutate: props.mutate, - hostSelection: props.hostSelection, hostSupport: { sendOptions: true, permissionModes: true } }) ) @@ -674,34 +671,3 @@ describe('useMobileStructuredAgentOptions host catalog', () => { await harness.unmount() }) }) - -it('validates and sends the effort offered for the status model while its read waits', async () => { - const stale = deferred() - const first = optionsClient( - queuedReads({ ...OPTIONS, current: { model: 'gpt-fast', effort: 'low' } }) - ) - const delayed = optionsClient(() => stale.promise) - const { calls, mutate } = recordingMutate(async () => - accepted( - { key: 'effort', value: 'high', options: { model: 'gpt-live', effort: 'high' } }, - false - ) - ) - const harness = await mountOptions({ ...BASE, client: first.client, mutate }) - await harness.rerender({ client: delayed.client, hostSelection: { model: 'gpt-live' } }) - expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('gpt-live') - expect(descriptorFor(harness.current().optionSnapshot, 'effort')?.kind).toMatchObject({ - choices: expect.arrayContaining([{ value: 'high', label: 'High' }]) - }) - await act(async () => - expect(await harness.current().setStructuredOption('effort', 'high')).toBe(true) - ) - expect(calls).toEqual([ - { method: 'agentSession.setOption', fields: { key: 'effort', value: 'high' } } - ]) - expect(agentSessionExecutionOptionsFromSnapshot(harness.current().optionSnapshot)).toMatchObject({ - model: 'gpt-live', - effort: 'high' - }) - await harness.unmount() -}) diff --git a/mobile/src/session/use-mobile-structured-agent-options.ts b/mobile/src/session/use-mobile-structured-agent-options.ts index f9112b7a7cc5..788451e164d9 100644 --- a/mobile/src/session/use-mobile-structured-agent-options.ts +++ b/mobile/src/session/use-mobile-structured-agent-options.ts @@ -1,8 +1,4 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - createCoalescedPollRunner, - type CoalescedPollRunner -} from '../../../src/renderer/src/components/right-sidebar/coalesced-poll-runner' import type { AgentSessionConversationCommand } from '../../../src/shared/agent-session-conversation-command' import { isAgentSessionHandleProvider } from '../../../src/shared/agent-session-provider-handle' import { structuredAgentSessionSeedCatalog } from '../../../src/shared/structured-agent-session-seed-catalog' @@ -15,17 +11,26 @@ import { applyStructuredAgentSessionOptions, canSetStructuredAgentSessionOption, commitStructuredAgentSessionOption, + commitStructuredAgentSessionOptionValues, createStructuredAgentSessionOptionState, structuredAgentSessionOptionPicks, type StructuredAgentSessionOptionState } from '../../../src/shared/structured-agent-session-options' +import { + structuredAgentSessionOptionView, + structuredAgentSessionShownSnapshot +} from '../../../src/shared/structured-agent-session-option-view' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID, agentChatPermissionModes } from '../../../src/shared/agent-chat-permission-mode' import { legacyHostPermissionDescriptor } from '../../../src/shared/structured-agent-session-permission-option' -import type { MobileStructuredAgentOptionsInput } from './mobile-structured-agent-options-input' -import { callAgentSession } from './mobile-structured-agent-session-rpc' +import type { RpcClient } from '../transport/rpc-client' +import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' +import { + callAgentSession, + type StructuredAgentSessionMutate +} from './mobile-structured-agent-session-rpc' import { persistMobileStructuredOptionPicks } from './mobile-native-chat-session-option-persistence' import { useMobileHostModelCatalogUpgrade } from './use-mobile-host-model-catalog-upgrade' import { @@ -33,28 +38,22 @@ import { mobileCreatedStructuredSession } from './mobile-created-structured-sessions' import { encodeStructuredAgentSessionOptionValue } from '../../../src/shared/structured-agent-session-option-codec' -import { - commitMobileStructuredOptions, - mobileStructuredOptionSnapshot, - mobileStructuredOptionView -} from './mobile-structured-option-snapshot' import { useMobileStructuredOptionSurface } from './use-mobile-structured-option-surface' -export function useMobileStructuredAgentOptions(args: MobileStructuredAgentOptionsInput) { - const { - agent, - client, - enabled, - fence, - mutate, - optionStamp, - hostSelection, - hostPermissionMode, - sessionId, - hostSupport - } = args +export function useMobileStructuredAgentOptions(args: { + agent: string | null + client: RpcClient | null + sessionId: string | null + enabled: boolean + fence: number | null + mutate: StructuredAgentSessionMutate + /** Moves when the host's status says a session option changed; each move re-reads them. */ + optionStamp?: string + /** What the host advertised; null until its status probe answers. */ + hostSupport: Pick | null +}) { + const { agent, client, enabled, fence, mutate, optionStamp, sessionId, hostSupport } = args const takesSendOptions = hostSupport?.sendOptions === true - const observed = hostSelection !== undefined // Every agent's seed, as on the desktop: a built-in list or the provider-default pill. const optionCatalog = useMemo( () => (agent ? structuredAgentSessionSeedCatalog(agent) : null), @@ -65,6 +64,7 @@ export function useMobileStructuredAgentOptions(args: MobileStructuredAgentOptio ) const optionStateRef = useRef(optionState) const activeOptionRecordRef = useRef(optionState.record) + const pendingOptionRef = useRef(null) const optionMutationGeneration = useRef(0) const updateOptionState = useCallback( (update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState) => { @@ -100,6 +100,7 @@ export function useMobileStructuredAgentOptions(args: MobileStructuredAgentOptio // The chat's own, which a restart for a new mode keeps: the pill never blanks between them. const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept optionMutationGeneration.current += 1 + pendingOptionRef.current = null optionStateRef.current = next activeOptionRecordRef.current = next.record setOptionState(next) @@ -127,55 +128,47 @@ export function useMobileStructuredAgentOptions(args: MobileStructuredAgentOptio onListingLanded }) - const optionsReadRef = useRef(null) useEffect(() => { if (!client || !sessionId || !enabled || !optionCatalog) { return } let stale = false - const runner = createCoalescedPollRunner(() => { - const readGeneration = optionMutationGeneration.current - return callAgentSession(client, 'agentSession.options', { - sessionId + const readGeneration = optionMutationGeneration.current + void callAgentSession(client, 'agentSession.options', { sessionId }) + .then((result) => { + // This view keeps its latch; a later one may run a model picked here. + forgetMobileCreatedStructuredSession(sessionId) + // Not an option value: a pick made during the read leaves it standing. + if (!stale) { + setImageSupport({ + sessionId, + acceptsImages: result.acceptsImages, + statesPermissionMode: result.current?.permissionMode !== undefined + }) + } + if (!stale && optionMutationGeneration.current === readGeneration) { + setConversationSupport({ sessionId, commands: result.conversationCommands ?? [] }) + updateOptionState((current) => + current.record === activeOptionRecordRef.current + ? applyStructuredAgentSessionOptions(current, optionCatalog, result) + : current + ) + } }) - .then((result) => { - // This view keeps its latch; a later one may run a model picked here. - forgetMobileCreatedStructuredSession(sessionId) - // Not an option value: a pick made during the read leaves it standing. - if (!stale) { - setImageSupport({ - sessionId, - acceptsImages: result.acceptsImages, - statesPermissionMode: result.current?.permissionMode !== undefined - }) - } - if (!stale && optionMutationGeneration.current === readGeneration) { - setConversationSupport({ sessionId, commands: result.conversationCommands ?? [] }) - updateOptionState((current) => - current.record === activeOptionRecordRef.current - ? applyStructuredAgentSessionOptions(current, optionCatalog, result, observed) - : current - ) - } - }) - .catch(() => undefined) - }) - optionsReadRef.current = runner - runner.run() + .catch(() => undefined) return () => { stale = true - runner.dispose() - } - }, [client, enabled, optionCatalog, observed, sessionId, fence, updateOptionState]) - - const refresh = `${optionStamp}|${listingsLanded}` - const seenRefresh = useRef(refresh) - useEffect(() => { - if (seenRefresh.current !== refresh) { - seenRefresh.current = refresh - optionsReadRef.current?.run() } - }, [refresh]) + }, [ + client, + enabled, + optionCatalog, + optionStamp, + listingsLanded, + sessionId, + fence, + updateOptionState + ]) // A host predating per-chat modes (1.4.224 and older) states none: say what its chats run instead. const legacyHostPermissions = @@ -185,36 +178,31 @@ export function useMobileStructuredAgentOptions(args: MobileStructuredAgentOptio imageSupport?.sessionId === sessionId && !imageSupport.statesPermissionMode && optionState.permissionMode === null - const shownSnapshot = useCallback( - (state: StructuredAgentSessionOptionState) => - mobileStructuredOptionSnapshot(state, hostSelection, takesSendOptions, hostPermissionMode), - [hostSelection, takesSendOptions, hostPermissionMode] - ) const optionSnapshot = useMemo(() => { - const shown = shownSnapshot(optionState) + const shown = structuredAgentSessionShownSnapshot(optionState, takesSendOptions) return legacyHostPermissions ? [...shown, legacyHostPermissionDescriptor()] : shown - }, [legacyHostPermissions, optionState, shownSnapshot]) + }, [legacyHostPermissions, optionState, takesSendOptions]) const setStructuredOption = useCallback( async (id: string, value: SessionOptionValue): Promise => { - const currentState = mobileStructuredOptionView( - optionStateRef.current, - hostSelection, - takesSendOptions, - hostPermissionMode - ) - const targetRecord = optionStateRef.current.record + const currentState = optionStateRef.current const encoded = encodeStructuredAgentSessionOptionValue(id, value) if ( + pendingOptionRef.current !== null || !client || !sessionId || !optionCatalog || encoded === null || - !canSetStructuredAgentSessionOption(currentState, id, value) + !canSetStructuredAgentSessionOption( + structuredAgentSessionOptionView(currentState, undefined, {}, takesSendOptions), + id, + value + ) ) { return false } - let mutationGeneration = ++optionMutationGeneration.current + const targetRecord = currentState.record + const mutationGeneration = ++optionMutationGeneration.current const isCurrent = (): boolean => activeOptionRecordRef.current === targetRecord && optionMutationGeneration.current === mutationGeneration @@ -226,8 +214,10 @@ export function useMobileStructuredAgentOptions(args: MobileStructuredAgentOptio ? commitStructuredAgentSessionOption(current, id, encoded) : current ) + } else { + pendingOptionRef.current = id + updateOptionState((current) => ({ ...current, pendingId: id })) } - updateOptionState((current) => ({ ...current, pendingPick: { id, value: encoded } })) // A same-fence accepted write, or a shown one that failed, reads back what the host now holds. let readsBack = shownAtOnce try { @@ -240,11 +230,10 @@ export function useMobileStructuredAgentOptions(args: MobileStructuredAgentOptio return result.status !== 'rejected' } if (result.status === 'accepted') { - mutationGeneration = ++optionMutationGeneration.current const committed = result.value.options ?? { [id]: encoded } updateOptionState((current) => current.record === targetRecord && result.sameFence - ? commitMobileStructuredOptions(current, hostSelection, committed) + ? commitStructuredAgentSessionOptionValues(current, committed) : current ) // Only an accepted pick: an `unknown` outcome commits optimistically to the @@ -264,7 +253,7 @@ export function useMobileStructuredAgentOptions(args: MobileStructuredAgentOptio readsBack = false updateOptionState((current) => current.record === targetRecord - ? commitMobileStructuredOptions(current, hostSelection, { [id]: encoded }) + ? commitStructuredAgentSessionOption(current, id, encoded) : current ) return true @@ -272,29 +261,31 @@ export function useMobileStructuredAgentOptions(args: MobileStructuredAgentOptio return false } finally { if (readsBack && isCurrent()) { - optionsReadRef.current?.run() + void callAgentSession(client, 'agentSession.options', { + sessionId + }) + .then((refreshed) => { + if (isCurrent()) { + updateOptionState((latest) => + latest.record === targetRecord + ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) + : latest + ) + } + }) + .catch(() => undefined) } if (isCurrent()) { + pendingOptionRef.current = null updateOptionState((current) => - current.record === targetRecord && current.pendingPick?.id === id - ? { ...current, pendingPick: null } + current.record === targetRecord && current.pendingId === id + ? { ...current, pendingId: null } : current ) } } }, - [ - agent, - client, - mutate, - optionCatalog, - sessionId, - takesSendOptions, - hostSelection, - hostPermissionMode, - observed, - updateOptionState - ] + [agent, client, mutate, optionCatalog, sessionId, takesSendOptions, updateOptionState] ) const { optionPickerRequest, invokeStructuredOption, optionSurface } = @@ -302,7 +293,7 @@ export function useMobileStructuredAgentOptions(args: MobileStructuredAgentOptio optionSnapshot, setStructuredOption, optionStateRef, - shownSnapshot + takesSendOptions }) return { @@ -315,7 +306,7 @@ export function useMobileStructuredAgentOptions(args: MobileStructuredAgentOptio (agent !== null && isAgentSessionHandleProvider(agent)), optionSnapshot, optionSurface, - pendingOptionId: optionState.pendingPick?.id ?? null, + pendingOptionId: optionState.pendingId, setStructuredOption, invokeStructuredOption } diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 7362803f8341..66fd76fda000 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -23,16 +23,17 @@ import { projectStructuredPermission, projectStructuredQuestion } from './mobile-structured-agent-prompts' -import type { StructuredMobileSession } from './mobile-structured-agent-session-contract' +import type { + MobileStructuredAgentSessionArgs, + StructuredMobileSession +} from './mobile-structured-agent-session-contract' import type { MobileNativeChatVisualSource } from './mobile-native-chat-visual-read' -import type { RpcClient } from '../transport/rpc-client' import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state' import { useMobileStructuredStopPress } from './use-mobile-structured-stop-press' import { useMobileStructuredSessionHostStopping } from './use-mobile-structured-session-host-stopping' import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses' -import type { StructuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options' -import { useMobileStructuredSessionOptionStatus } from './use-mobile-structured-session-option-status' +import { useMobileStructuredSessionOptionStamp } from './use-mobile-structured-session-option-stamp' import { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing' import { pendingStructuredPromptIdentity, @@ -44,28 +45,13 @@ import { mobileStructuredSendQueues, useMobileStructuredSendWithOutcome } from './use-mobile-structured-send-with-outcome' -import type { MobileNativeChatSendErrorReporter } from './use-mobile-native-chat-send-error' import { useMobileStructuredQueuedMessageControls } from './use-mobile-structured-queued-message-controls' import { useMobileStructuredBackgroundTasks } from './use-mobile-structured-background-tasks' import { useMobileStructuredSends } from './use-mobile-structured-sends' -export function useMobileStructuredAgentSession(args: { - client: RpcClient | null - sessionId: string | null - /** Host/workspace scope used to keep same provider ids isolated. */ - sourceIdentity?: string - enabled: boolean - /** Live transport only; gates the connection-scoped hold, and whether child rows may read live. */ - connected: boolean - /** Capability facts from the shared runtime status probe; null follows the legacy wire. */ - hostSupport: StructuredAgentSessionHostSupport | null - agent: string | null - /** The active pane's live composer; Edit copies a card's text through it. */ - appendComposerText?: (text: string) => boolean - onSendError: MobileNativeChatSendErrorReporter - /** Called on any accepted queued-card action; retires the route's failure banner. */ - onActionResolved?: () => void -}): StructuredMobileSession { +export function useMobileStructuredAgentSession( + args: MobileStructuredAgentSessionArgs +): StructuredMobileSession { const { agent, appendComposerText, @@ -102,7 +88,7 @@ export function useMobileStructuredAgentSession(args: { ...(agent ? { agentName: tuiAgentDisplayName(agent) ?? agent } : {}) }) - const optionStatus = useMobileStructuredSessionOptionStatus({ + const optionStamp = useMobileStructuredSessionOptionStamp({ client, sessionId, enabled: enabled && connected && hostSupport?.statusFeed === true @@ -114,9 +100,7 @@ export function useMobileStructuredAgentSession(args: { enabled, fence: state.fence, mutate, - optionStamp: optionStatus.stamp, - hostSelection: optionStatus.selection, - hostPermissionMode: optionStatus.permissionMode, + optionStamp, hostSupport }) const { conversationCommands, invokeStructuredOption, optionSnapshot, setStructuredOption } = @@ -142,10 +126,12 @@ export function useMobileStructuredAgentSession(args: { // What the transcript reads, as desktop does: the journal plus the one notice a cut turn with no // row gets. - const agentName = agent ? (tuiAgentDisplayName(agent) ?? agent) : undefined const transcriptItems = useMemo( - () => withNativeChatCutTurnNotices(state.items, { agentName }), - [agentName, state.items] + () => + withNativeChatCutTurnNotices(state.items, { + agentName: agent ? (tuiAgentDisplayName(agent) ?? agent) : undefined + }), + [agent, state.items] ) const sends = useMobileStructuredSends({ sessionKey, @@ -246,12 +232,17 @@ export function useMobileStructuredAgentSession(args: { ) const queued = useMobileStructuredQueuedMessageControls({ sessionKey, - agentName, + agentName: agent ? (tuiAgentDisplayName(agent) ?? agent) : undefined, journalItems: state.items, queuedMessages, queuePause, submissions: state.submissions, agentWorking: isWorking, + client, + sessionId, + editCapable: hostSupport ? hostSupport.queuedMessageEdit === true : null, + chatActive: enabled, + enabled: enabled && connected, mutate, appendComposerText, onSendError, diff --git a/mobile/src/session/use-mobile-structured-option-surface.ts b/mobile/src/session/use-mobile-structured-option-surface.ts index 82536a8ace47..9568e8060aa1 100644 --- a/mobile/src/session/use-mobile-structured-option-surface.ts +++ b/mobile/src/session/use-mobile-structured-option-surface.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo, useState, type RefObject } from 'react' +import { structuredAgentSessionShownSnapshot } from '../../../src/shared/structured-agent-session-option-view' import type { StructuredAgentSessionOptionState } from '../../../src/shared/structured-agent-session-options' import type { SessionOptionDescriptor, @@ -12,10 +13,9 @@ export function useMobileStructuredOptionSurface(args: { setStructuredOption: (id: string, value: SessionOptionValue) => Promise /** The latest option state, which a settled pick's snapshot is read from. */ optionStateRef: RefObject - /** What the picker shows for a state. */ - shownSnapshot: (state: StructuredAgentSessionOptionState) => SessionOptionDescriptor[] + takesSendOptions: boolean }) { - const { optionSnapshot, optionStateRef, setStructuredOption, shownSnapshot } = args + const { optionSnapshot, optionStateRef, setStructuredOption, takesSendOptions } = args const [optionPickerRequest, setOptionPickerRequest] = useState<{ id: string sequence: number @@ -35,9 +35,11 @@ export function useMobileStructuredOptionSurface(args: { const setOption = useCallback( async (id: string, value: SessionOptionValue) => { await setStructuredOption(id, value) - return { snapshot: shownSnapshot(optionStateRef.current) } + return { + snapshot: structuredAgentSessionShownSnapshot(optionStateRef.current, takesSendOptions) + } }, - [optionStateRef, setStructuredOption, shownSnapshot] + [optionStateRef, setStructuredOption, takesSendOptions] ) const optionSurface = useMemo( diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index 91be0af3e10b..72565d7863d8 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -5,8 +5,6 @@ import { selectAgentSessionCurrentRequests } from '../../../src/shared/agent-ses // host advertises the queue and no pending prompt is one this build cannot answer. import { useCallback } from 'react' -import { agentSessionExecutionOptionsFromSnapshot } from '../../../src/shared/agent-session-execution-option-snapshot' -import { agentSessionExecutionOptionsRecord } from '../../../src/shared/agent-session-execution-options' import { runningStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' import { pendingPromptsAllUnanswerableHere } from '../../../src/shared/agent-session-approval-subject' import { @@ -23,6 +21,7 @@ import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-age import type { MobileStructuredSends } from './use-mobile-structured-sends' import { structuredSessionOperationId } from './structured-session-operation-id' import { timeoutForDeadline } from './mobile-structured-agent-session-rpc' +import { structuredAgentSessionSendSelection } from '../../../src/shared/structured-agent-session-option-view' import { actionableStructuredApproval, actionableStructuredQuestion @@ -62,7 +61,7 @@ export function useMobileStructuredSendWithOutcome(args: { 'snapshot' | 'setOption' | 'invokeAction' | 'conversationCommands' > onSendError: MobileNativeChatSendErrorReporter - /** Each send carries the choices the picker shows; an older host takes none. */ + /** Each send carries the model and effort the picker shows; an older host takes neither. */ carriesOptions?: boolean }): ( text: string, @@ -146,9 +145,10 @@ export function useMobileStructuredSendWithOutcome(args: { ...(stopping ? { sentWhileStopping: true as const } : {}) } // A pick on its way holds the send, so the picker shows what the chat holds. - const executionOptions = carriesOptions - ? agentSessionExecutionOptionsFromSnapshot(controller.snapshot) - : undefined + const options = + carriesOptions && agent + ? structuredAgentSessionSendSelection(agent, controller.snapshot) + : undefined const outcome = await sendMobileStructuredAgentSessionMessage({ client, sessionId, @@ -158,9 +158,7 @@ export function useMobileStructuredSendWithOutcome(args: { ...(mobileStructuredSendQueues(queueCapable, stateRef.current) ? { delivery: 'queue-if-active' as const } : {}), - ...(executionOptions - ? { options: agentSessionExecutionOptionsRecord(executionOptions) } - : {}), + ...(options ? { options } : {}), deadline, clientMessageId: send.clientMessageId, onAccepted: (cursor) => diff --git a/mobile/src/session/use-mobile-structured-sends.test.tsx b/mobile/src/session/use-mobile-structured-sends.test.tsx index ece6a3e1b3e2..4bd4eb42179b 100644 --- a/mobile/src/session/use-mobile-structured-sends.test.tsx +++ b/mobile/src/session/use-mobile-structured-sends.test.tsx @@ -22,8 +22,6 @@ import { } from './use-mobile-structured-agent-session-queued.test-fixture' import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' import { useMobileStructuredSends } from './use-mobile-structured-sends' -import { structuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support' -import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' const asyncStorage = vi.hoisted(() => ({ getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() })) vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) @@ -83,7 +81,7 @@ describe('a structured send in the open chat', () => { close: () => {} } - function Harness(props: { sendOptions?: boolean }): null { + function Harness(): null { hook = useMobileStructuredAgentSession({ client, sessionId: 'session-1', @@ -91,17 +89,15 @@ describe('a structured send in the open chat', () => { enabled: true, connected: true, agent: 'codex', - hostSupport: props.sendOptions - ? structuredAgentSessionHostSupport([AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY]) - : null, + hostSupport: null, onSendError }) return null } - async function mount(sendOptions = false): Promise { + async function mount(): Promise { act(() => { - renderer = create(createElement(Harness, { sendOptions })) + renderer = create(createElement(Harness)) }) await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) act(() => listener?.(snapshotEvent())) @@ -172,12 +168,6 @@ describe('a structured send in the open chat', () => { expect(onSendError).not.toHaveBeenCalled() }) - it('omits choices when Send precedes the picker’s model answer', async () => { - await mount(true) - await send('before picker answer') - const call = sendRequest.mock.calls.find(([method]) => method === 'agentSession.send') - expect(call?.[1]).not.toHaveProperty('options') - }) it.each([false, true])( 'forgets an acknowledged off-page echo after reconnect (snapshot beats reply: %s)', async (snapshotFirst) => { diff --git a/mobile/src/session/use-mobile-structured-session-option-stamp.ts b/mobile/src/session/use-mobile-structured-session-option-stamp.ts new file mode 100644 index 000000000000..c1696161e89b --- /dev/null +++ b/mobile/src/session/use-mobile-structured-session-option-stamp.ts @@ -0,0 +1,24 @@ +import { useCallback, useSyncExternalStore } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed' + +const noSubscription = (): (() => void) => () => {} + +/** The host status's option values as one stamp; another device's pick moves it, off `enabled` never. */ +export function useMobileStructuredSessionOptionStamp(args: { + client: RpcClient | null + sessionId: string | null + enabled: boolean +}): string { + const { client, sessionId, enabled } = args + const feed = enabled && client && sessionId ? mobileStructuredSessionStatusFeed(client) : null + const subscribe = useCallback( + (listener: () => void) => (feed ? feed.subscribe(listener) : noSubscription()), + [feed] + ) + const readStamp = (): string => { + const summary = feed && sessionId ? feed.getSnapshot().get(sessionId) : undefined + return `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}` + } + return useSyncExternalStore(subscribe, readStamp, readStamp) +} diff --git a/mobile/src/session/use-mobile-structured-session-option-status.ts b/mobile/src/session/use-mobile-structured-session-option-status.ts deleted file mode 100644 index 904877aa365b..000000000000 --- a/mobile/src/session/use-mobile-structured-session-option-status.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useCallback, useMemo, useSyncExternalStore } from 'react' -import { structuralValuesEqual } from '../../../src/shared/structural-value-equality' -import type { AgentSessionStatusSummary } from '../../../src/shared/agent-session-wire' -import type { RpcClient } from '../transport/rpc-client' -import { mobileStructuredSessionStatusFeed } from './mobile-structured-session-status-feed' - -const noSubscription = (): (() => void) => () => {} - -type OptionStatus = { - stamp: string - selection: AgentSessionStatusSummary['optionSelection'] - permissionMode?: string -} - -const EMPTY_OPTION_STATUS: OptionStatus = { stamp: '', selection: undefined } - -/** A live selection from a capable host, with the legacy stamp for older hosts. */ -export function useMobileStructuredSessionOptionStatus(args: { - client: RpcClient | null - sessionId: string | null - enabled: boolean -}): OptionStatus { - const { client, sessionId, enabled } = args - const feed = enabled && client && sessionId ? mobileStructuredSessionStatusFeed(client) : null - const subscribe = useCallback( - (listener: () => void) => (feed ? feed.subscribe(listener) : noSubscription()), - [feed] - ) - const readStatus = useMemo(() => { - let previous = EMPTY_OPTION_STATUS - return (): OptionStatus => { - const summary = feed && sessionId ? feed.getSnapshot().get(sessionId) : undefined - const stamp = `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}|${JSON.stringify(summary?.optionSelection)}|${feed?.getSnapshotSample(sessionId ?? '')}` - const selection = structuralValuesEqual(previous.selection, summary?.optionSelection) - ? previous.selection - : summary?.optionSelection - if (previous.stamp !== stamp || previous.selection !== selection) { - previous = { stamp, selection, permissionMode: summary?.permissionMode } - } - return previous - } - }, [feed, sessionId]) - return useSyncExternalStore(subscribe, readStatus, () => EMPTY_OPTION_STATUS) -} 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 8f87491b94b0..21542ec2337f 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 @@ -31,10 +31,7 @@ import { structuredAgentSessionLaunchPermission } from './structured-agent-sessi import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -type RestingOptions = Pick< - AgentSessionOptionsResult, - 'models' | 'modelsKnown' | 'fastModeSupport' | 'current' -> +type RestingOptions = Pick /** What the asking client takes: whether it reads an answer that names no model. */ export type StructuredAgentSessionOptionsReader = { readsWithoutModel: boolean } @@ -139,7 +136,6 @@ async function readStructuredAgentSessionOptionsAtRest( : undefined) return { models: listed ? structuredAgentSessionOptionModels(listed, model, (row) => row) : [], - modelsKnown: catalog.origin !== 'unknown', ...(catalog.origin !== 'unknown' && catalog.fastModeSupport ? { fastModeSupport: catalog.fastModeSupport } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts index 7cad7d0d9363..46a62a05dc49 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts @@ -1,7 +1,6 @@ // Which status-summary changes reach every session list. The summary is broadcast to remote // subscribers too, so only a change a session list shows may re-send it. -import { structuralValuesEqual } from '../../../shared/structural-value-equality' import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' import { agentSessionBackgroundTasksEqual, @@ -33,7 +32,6 @@ export function structuredStatusSummariesEqual( a.latestPrompt === b.latestPrompt && a.model === b.model && a.permissionMode === b.permissionMode && - structuralValuesEqual(a.optionSelection, b.optionSelection) && a.toolName === b.toolName && a.toolInput === b.toolInput && a.lastAssistantMessage === b.lastAssistantMessage && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts index 516d8ca29679..786fa8b246e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts @@ -59,7 +59,6 @@ export function structuredAgentSessionStatusSummary({ : {}), ...(model ? { model } : {}), ...(isAgentChatPermissionMode(permissionMode) ? { permissionMode } : {}), - ...(record ? { optionSelection: record.options ?? {} } : {}), ...childWork, ...(providerSession ? { providerSession } : {}), ...(launchDirectory ? { launchDirectory } : {}), diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index ca1fba31a525..e412bf2cf4d2 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -35,6 +35,7 @@ import { useNativeChatInterruptedContinuation } from './NativeChatInterruptedCon import { NativeChatOrcaStopContext } from './native-chat-orca-stop-context' import { structuredSessionNotices } from './native-chat-structured-session-notices' import { NativeChatPromptSlotNotices } from './NativeChatComposerNotices' +import { NativeChatInstallSlot, useNativeChatInstallSlot } from './NativeChatInstallSlot' import { useNativeChatComposerError } from './use-native-chat-composer-notice' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' import { useStructuredAgentSessionHostExecution } from './StructuredAgentSessionStatusBridge' @@ -87,8 +88,6 @@ export function NativeChatStructuredSession( composerScopeKey: structuredAgentSessionDraftScopeKey(props.sessionId), queueFollowUps, hostStopping: hostExecution.stopping, - optionSelection: hostExecution.optionSelection, - hostPermissionMode: hostExecution.permissionMode, hostOptionStamp: hostExecution.optionStamp, providerStarting: hostExecution.phase === 'starting', // A start's own verdict (a signed-out agent) exists only once that start is proven. @@ -127,7 +126,6 @@ export function NativeChatStructuredSession( const hostPermission = useNativeChatHostPermissionGate(props.target, props.agent, agentLabel) const hostHeld = hostPermission.sendBlockedReason !== null const submits = useStructuredNativeChatSubmitReveal(controller, provisionalLaunch.retry, hostHeld) - const { revealLatest } = submits const promptResponse = useStructuredPromptResponseHold(submits.respond) const continuation = useNativeChatInterruptedContinuation({ target: props.target, @@ -201,6 +199,9 @@ export function NativeChatStructuredSession( // cancel then works. const promptsUnanswerable = pendingPromptsAllUnanswerableHere(controller.prompts) const composerShown = (prompt === null || promptsUnanswerable) && !readFailedFinally + // An owned prompt keeps its card; otherwise a missing program takes the composer's place. + const install = useNativeChatInstallSlot(props.target, props.agent, props.isVisible, agentLabel) + const composerOpen = composerShown && install.slot.kind !== 'install' const approval = prompt?.body.kind === 'approval' ? chatApprovalFromJournal(prompt.body) : null const cancelPrompt = () => { if (prompt && (controller.turnId || props.agent === 'pi')) { @@ -217,7 +218,7 @@ export function NativeChatStructuredSession( questionAnswerInputRef, isVisible: props.isVisible, isFocusedGroup: props.isFocusedGroup, - composerReady: composerShown + composerReady: composerOpen }) const questionBody = prompt?.body.kind === 'question' ? prompt.body : null const questions = questionBody ? agentSessionPromptQuestions(questionBody) : [] @@ -230,8 +231,8 @@ export function NativeChatStructuredSession( optionPickerRequest, setOptionPickerRequest, onError: reportComposerError, - onSubmitted: revealLatest, - sendBlockedReason: hostPermission.sendBlockedReason + onSubmitted: submits.revealLatest, + sendBlockedReason: hostPermission.sendBlockedReason ?? install.sendBlockedReason }) // Said once: on the pane when the failure took it, else in the composer's notices. @@ -253,8 +254,9 @@ export function NativeChatStructuredSession( failure: provisionalLaunch.failure, failureRows: startFailures.filter((fact) => isClaudeSignInFailureKind(fact.kind)).length }) + // While Install shows, it says what to do; a start failure's "send again" or Retry can't help. const notices = structuredSessionNotices({ - launch, + launch: install.slot.kind === 'install' ? { ...launch, lifecycle: null } : launch, agentLabel, sessionError, composerError: composerError ?? continuation.continueError, @@ -363,7 +365,7 @@ export function NativeChatStructuredSession( /> )} {/* Prompt cards take the composer's slot, below the background-task dock. */} - {composerShown ? null : } + {composerOpen ? null : } {prompt && approval ? ( ) : null} - {composerShown ? ( + {composerShown && } + {composerOpen ? ( getStructuredAgentSessionStatusFeed(target), [target]) + useEffect(() => feed.activate(), [feed]) + return useSyncExternalStore( + feed.subscribe, + () => { + const summary = feed.getSnapshot().get(sessionId) + return `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}` + }, + () => '' + ) +} + /** Only the host's startup phase, so a chat re-renders when that changes, not on every status. */ export function useStructuredAgentSessionHostExecutionPhase( sessionId: string, @@ -78,6 +94,7 @@ export function useStructuredAgentSessionHostExecutionPhase( ) } +/** Whether the host says a person's Stop is still ending the work; an older host never does. */ export function useStructuredAgentSessionHostStopping( sessionId: string, target: RuntimeClientTarget @@ -91,57 +108,20 @@ export function useStructuredAgentSessionHostStopping( ) } -type HostExecutionSelection = { - phase: AgentSessionStatusSummary['hostExecutionPhase'] | null - stopping: boolean - /** The option values as one stamp: another device's pick moves it, so options re-read. */ - optionStamp: string - permissionMode: AgentSessionStatusSummary['permissionMode'] - optionSelection: AgentSessionStatusSummary['optionSelection'] -} - -const EMPTY_HOST_EXECUTION: HostExecutionSelection = { - phase: null, - stopping: false, - optionStamp: '', - permissionMode: undefined, - optionSelection: undefined -} - -/** Select only the host facts the chat renders. */ +/** Startup phase, Stop and option stamp, each re-rendering the chat only on a change. */ export function useStructuredAgentSessionHostExecution( sessionId: string, target: RuntimeClientTarget -): HostExecutionSelection { - const feed = useMemo(() => getStructuredAgentSessionStatusFeed(target), [target]) - useEffect(() => feed.activate(), [feed]) - const snapshot = useMemo(() => { - let previous = EMPTY_HOST_EXECUTION - return (): HostExecutionSelection => { - const summary = feed.getSnapshot().get(sessionId) - const next = { - phase: summary?.hostExecutionPhase ?? null, - stopping: summary?.stopping === true, - optionStamp: `${summary?.model ?? ''}|${summary?.permissionMode ?? ''}|${JSON.stringify(summary?.optionSelection)}|${feed.getSnapshotSample(sessionId)}`, - permissionMode: summary?.permissionMode, - optionSelection: structuralValuesEqual(previous.optionSelection, summary?.optionSelection) - ? previous.optionSelection - : summary?.optionSelection - } - if ( - previous.phase === next.phase && - previous.stopping === next.stopping && - previous.optionStamp === next.optionStamp && - previous.permissionMode === next.permissionMode && - previous.optionSelection === next.optionSelection - ) { - return previous - } - previous = next - return next - } - }, [feed, sessionId]) - return useSyncExternalStore(feed.subscribe, snapshot, () => EMPTY_HOST_EXECUTION) +): { + phase: ReturnType + stopping: boolean + optionStamp: string +} { + return { + phase: useStructuredAgentSessionHostExecutionPhase(sessionId, target), + stopping: useStructuredAgentSessionHostStopping(sessionId, target), + optionStamp: useStructuredAgentSessionOptionStamp(sessionId, target) + } } /** Only the host's rewind recovery latch, so a chat re-renders when that changes, not on every status. */ diff --git a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx index 35bc02265b68..c84294e914b5 100644 --- a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx @@ -33,7 +33,6 @@ vi.mock('@/runtime/structured-agent-session-status-feed', () => ({ } }, getSnapshot: () => feed.snapshot, - getSnapshotSample: () => 0, getSessionObservation: () => (feed.live ? 'live' : 'unverifiable'), subscribe: (listener: () => void) => { feed.listeners.add(listener) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-creation-picks.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-creation-picks.test.tsx deleted file mode 100644 index 24cccaca5c26..000000000000 --- a/src/renderer/src/components/native-chat/structured-agent-session-creation-picks.test.tsx +++ /dev/null @@ -1,267 +0,0 @@ -// @vitest-environment happy-dom - -import { act, renderHook, waitFor } from '@testing-library/react' -import { useSyncExternalStore } from 'react' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { HostCreateSupport } from '@/lib/structured-agent-session-host-admission' - -const mocks = vi.hoisted(() => ({ - call: vi.fn<(target: unknown, method: string, params: unknown) => Promise>() -})) -vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.call, - supportsStructuredAgentSessionCreateMessage: async () => true -})) -vi.mock('@/lib/structured-agent-session-host-admission', () => ({ - askHostCreateSupport: async (): Promise => ({ kind: 'admitted' }) -})) -vi.mock('@/runtime/structured-session-tab-inventory', () => ({ - readStructuredSessionTabInventory: async () => [] -})) -vi.mock('@/runtime/structured-agent-session-host-capability', async (importOriginal) => ({ - ...(await importOriginal()), - useStructuredAgentSessionHostCapability: () => true -})) -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => ({ - // The account's saved pick, which create seeds. - settings: { nativeChatSessionOptions: { claude: { model: 'sonnet' } } }, - unifiedTabsByWorktree: {}, - seedNativeChatLaunchDraft: vi.fn(), - clearNativeChatLaunchDraft: vi.fn() - }), - subscribe: () => () => {} - } -})) -vi.mock('./native-chat-session-option-settings-write', () => ({ - enqueueSessionOptionSettingsWrite: vi.fn() -})) -vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) - -import { - CreateIntentParams, - SendParams, - SetOptionParams -} from '../../../../shared/rpc-contract/structured-agent-session-params' -import type { - AgentSessionHistoryPage, - AgentSessionModelCatalogResult -} from '../../../../shared/agent-session-wire' -import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' -import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' -import { resetStructuredAgentLaunchRegistryForTests } from '@/lib/structured-agent-session-launch-registry' -import { resetStructuredAgentLaunchPersistenceForTests } from '@/lib/structured-agent-session-launch-persistence' -import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' -import { - recordHostModelCatalogSnapshot, - resetHostModelCatalogSnapshotsForTests -} from '@/runtime/host-model-catalog-snapshots' -import { - getStructuredAgentSessionReadOwner, - resetStructuredAgentSessionReadOwnersForTests -} from './structured-agent-session-read-owner' -import { resetStructuredAgentSessionSendsForTests } from './structured-agent-session-message-sender' -import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' -import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' -import { useStructuredAgentSessionOptions } from './use-structured-agent-session-options' -import { useStructuredAgentSessionSends } from './use-structured-agent-session-sends' - -const target = { kind: 'local' } as const -const efforts = ['low', 'high'].map((value) => ({ value, label: value })) -const LISTING: AgentSessionModelCatalogResult = { - origin: 'probe', - fetchedAt: 1, - models: [ - { id: 'sonnet', label: 'Sonnet', isDefault: true, defaultEffort: 'low', efforts }, - { id: 'opus', label: 'Opus', isDefault: false, defaultEffort: 'low', efforts } - ] -} - -function shown(snapshot: readonly SessionOptionDescriptor[], id: string): string | undefined { - const kind = snapshot.find((entry) => entry.id === id)?.kind - return kind?.type === 'select' ? kind.currentValue : undefined -} - -function deferred() { - let resolve = (_value: T): void => {} - const promise = new Promise((yes) => { - resolve = yes - }) - return { promise, resolve } -} - -function createdReply(sessionId: string) { - const cursor = { epoch: 'epoch-1', sequence: 0 } - const page: AgentSessionHistoryPage = { - sessionId, - epoch: cursor.epoch, - fence: 1, - direction: 'tail', - items: [], - removedItemIds: [], - submissions: [], - window: { oldest: null, newest: null, nextCursor: cursor }, - liveCursor: cursor, - hasOlder: false, - hasNewer: false, - latestTurn: null - } - return { ok: true, replayed: false, value: { sessionId, fence: 1, page } } -} - -/** The new chat's pane: its launch, its picker and its composer, as the session view wires them. */ -function useNewChat( - sessionId: string, - optionSelection: Readonly> | undefined -) { - const { launch, transportEnabled } = useNativeChatProvisionalLaunch('workspace-1', sessionId) - const owner = getStructuredAgentSessionReadOwner(sessionId, target) - const fence = useSyncExternalStore(owner.subscribe, () => owner.getSnapshot().state.fence) - const options = useStructuredAgentSessionOptions({ - agent: 'claude', - sessionId, - target, - transportEnabled, - isVisible: true, - providerVisible: transportEnabled, - ...(optionSelection ? { optionSelection } : {}), - fence: transportEnabled ? fence : null, - turnId: null, - unloadedTurnRevisions: undefined, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every pick here is made before publish, which the launch holds; mutate is never called. - mutate: vi.fn() as unknown as StructuredAgentSessionMutate, - ...(launch ? { launch } : {}) - }) - const sends = useStructuredAgentSessionSends({ - sessionId, - target, - fence: transportEnabled ? fence : null, - submissions: [], - queuedMessageIds: [], - queue: { capability: 'supported', enabled: false }, - historyLoaded: false, - executionOptionSnapshot: options.takesSendOptions ? options.optionSnapshot : undefined - }) - return { transportEnabled, options, sends } -} - -beforeEach(() => { - localStorage.clear() - resetStructuredAgentLaunchRegistryForTests() - resetStructuredAgentLaunchPersistenceForTests() - resetStructuredAgentSessionSendsForTests() - resetStructuredAgentSessionReadOwnersForTests() - resetHostModelCatalogSnapshotsForTests() - recordHostModelCatalogSnapshot(target, 'claude', null, LISTING) -}) -afterEach(() => { - resetStructuredAgentSessionSendsForTests() - resetStructuredAgentSessionReadOwnersForTests() - mocks.call.mockReset() -}) - -// A slow host: the chat exists, and its status shows the create's selection, before create replies. -// The real host saves a pick, publishes its status, then replies with every saved value. -it.each([ - { host: 'an older host', statusCarriesSelection: false, sendDuringReplay: false }, - { - host: 'a host whose status shows its selection', - statusCarriesSelection: true, - sendDuringReplay: false - }, - { - host: 'a host still taking the last pick', - statusCarriesSelection: true, - sendDuringReplay: true - } -])( - 'sends the model and effort picked while the chat was being created, on $host', - async (timing) => { - const create = deferred() - const lastPick = deferred() - const sent: unknown[] = [] - let saved: Record = {} - const status: { selection?: Readonly>; listeners: Set<() => void> } = { - listeners: new Set() - } - const publish = (): void => { - if (timing.statusCarriesSelection) { - status.selection = { ...saved } - } - status.listeners.forEach((listener) => listener()) - } - mocks.call.mockImplementation(async (_target, method, params) => { - if (method === 'agentSession.create') { - saved = { ...CreateIntentParams.parse(params).options } - publish() - return create.promise - } - if (method === 'agentSession.setOption') { - const { key, value } = SetOptionParams.parse(params) - if (key === 'effort' && timing.sendDuringReplay) { - await lastPick.promise - } - saved = { ...saved, [key]: value } - act(publish) - return { ok: true, value: { key, value, options: saved } } - } - if (method === 'agentSession.modelCatalog') { - return LISTING - } - if (method === 'agentSession.send') { - sent.push(params) - } - return new Promise(() => {}) - }) - const launch = startStructuredAgentLaunch('workspace-1', 'claude', { - requestId: newAgentLaunchRequestId(), - executionHostId: 'local' - }) - await waitFor(() => expect(saved).toEqual({ model: 'sonnet' })) - const { result, unmount } = renderHook(() => - useNewChat( - launch.sessionId, - useSyncExternalStore( - (listener) => { - status.listeners.add(listener) - return () => status.listeners.delete(listener) - }, - () => status.selection - ) - ) - ) - await waitFor(() => - expect(shown(result.current.options.optionSnapshot, 'model')).toBe('sonnet') - ) - - await act(async () => { - expect(await result.current.options.setStructuredOption('model', 'opus')).toBe(true) - }) - await act(async () => { - expect(await result.current.options.setStructuredOption('effort', 'high')).toBe(true) - }) - create.resolve(createdReply(launch.sessionId)) - await act(async () => { - await launch.launchResult - }) - await waitFor(() => expect(result.current.transportEnabled).toBe(true)) - await waitFor(() => - expect( - mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.setOption') - ).toHaveLength(2) - ) - await waitFor(() => expect(saved.effort === 'high').toBe(!timing.sendDuringReplay)) - - // Never re-seeded over: the picker still shows the picks, and the first message carries them. - expect(shown(result.current.options.optionSnapshot, 'model')).toBe('opus') - expect(shown(result.current.options.optionSnapshot, 'effort')).toBe('high') - act(() => { - expect(result.current.sends.send('first message')).toBe(true) - }) - await waitFor(() => expect(sent).toHaveLength(1)) - expect(SendParams.parse(sent[0]).options).toMatchObject({ model: 'opus', effort: 'high' }) - lastPick.resolve() - unmount() - } -) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-first-frame-options.ts b/src/renderer/src/components/native-chat/structured-agent-session-first-frame-options.ts deleted file mode 100644 index e6e3d4809ed0..000000000000 --- a/src/renderer/src/components/native-chat/structured-agent-session-first-frame-options.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { AgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' -import type { AgentType } from '../../../../shared/agent-status-types' -import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' -import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' -import { - applyStructuredAgentSessionModelCatalog, - createStructuredAgentSessionOptionState, - type StructuredAgentSessionOptionState -} from '../../../../shared/structured-agent-session-options' -import { structuredAgentSessionOptionView } from '../../../../shared/structured-agent-session-option-view' -import { readHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots' - -/** The first frame: the host's saved list when the renderer already holds it, else the seed, - * which shows the quiet placeholder until the host answers. */ -export function firstFrameOptionState( - agent: AgentType, - target: RuntimeClientTarget, - launch: { newLaunch: boolean; worktree?: string; seedsModel: boolean } -): StructuredAgentSessionOptionState { - const seed = structuredAgentSessionSeedCatalog(agent) - const seeded = createStructuredAgentSessionOptionState(agent, seed) - const snapshot = readHostModelCatalogSnapshot(target, agent, launch) - return snapshot ? applyStructuredAgentSessionModelCatalog(seeded, seed, snapshot, launch) : seeded -} - -export function structuredAgentSessionConfiguredOptionView( - args: { - agent: AgentType - target: RuntimeClientTarget - optionCatalog: AgentSessionOptionCatalog | null - newLaunch: boolean - worktree?: string - seedsModel: boolean - hostSelection: Readonly> | undefined - hostPermissionMode?: string - launchSeedOptions: Readonly> | undefined - held: Readonly> - }, - optionState: StructuredAgentSessionOptionState, - takesSendOptions: boolean, - currentIdentity: boolean -): StructuredAgentSessionOptionState { - const { - agent, - target, - optionCatalog, - newLaunch, - worktree, - seedsModel, - hostSelection, - hostPermissionMode, - launchSeedOptions, - held - } = args - const selectionFromStatus = hostSelection !== undefined - const configured = readHostModelCatalogSnapshot(target, agent, { - newLaunch, - worktree, - seedsModel - }) - // Live rows supply choices; the latest scoped host answer supplies the launch default. - const scoped = - configured && optionCatalog - ? applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState(agent), - optionCatalog, - configured, - { newLaunch } - ).catalog - : null - const defaultModel = scoped?.defaultModelIsCliDefault - ? scoped.models.find((model) => model.isDefault) - : undefined - const defaultEffort = defaultModel?.options.find((option) => option.id === 'effort')?.kind - const catalog = - optionState.catalog && selectionFromStatus && defaultModel - ? { - ...optionState.catalog, - defaultModelIsCliDefault: defaultModel ? (true as const) : undefined, - models: optionState.catalog.models.map((model) => ({ - ...model, - isDefault: model.id === defaultModel?.id, - options: model.options.map((option) => - model.id === defaultModel?.id && - option.id === 'effort' && - option.kind.type === 'select' && - defaultEffort?.type === 'select' && - defaultEffort.defaultIsCliDefault && - option.kind.choices.some((choice) => choice.value === defaultEffort.defaultValue) - ? { - ...option, - kind: { - ...option.kind, - defaultValue: defaultEffort.defaultValue, - defaultIsCliDefault: true as const - } - } - : option - ) - })) - } - : optionState.catalog - return structuredAgentSessionOptionView( - { - ...optionState, - catalog, - executionUnknown: - !optionState.record.model && - !hostSelection?.model && - !launchSeedOptions?.model && - !held.model && - !optionState.pendingPick && - !catalog?.defaultModelIsCliDefault, - pendingPick: currentIdentity ? optionState.pendingPick : null - }, - selectionFromStatus ? undefined : launchSeedOptions, - held, - takesSendOptions, - { selection: hostSelection, permissionMode: hostPermissionMode } - ) -} diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.test.tsx b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.test.tsx index d3b310776f6c..7ee82b8228da 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.test.tsx +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.test.tsx @@ -3,7 +3,7 @@ import { act, renderHook } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ call: vi.fn(), token: {} })) +const mocks = vi.hoisted(() => ({ call: vi.fn() })) vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) @@ -17,8 +17,7 @@ vi.mock('./native-chat-session-option-settings-write', () => ({ vi.mock('@/lib/structured-agent-session-launch-options', () => ({ holdStructuredAgentSessionLaunchOption: vi.fn(() => Promise.resolve({ kind: 'held' })), - getStructuredAgentSessionLaunchSelection: () => null, - getStructuredAgentSessionLaunchOptionToken: () => mocks.token + getStructuredAgentSessionLaunchSelection: () => null })) import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' diff --git a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts index f160b47b6fe5..1722720a7b3f 100644 --- a/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts +++ b/src/renderer/src/components/native-chat/use-host-model-catalog-upgrade.ts @@ -128,16 +128,13 @@ export function useHostModelCatalogUpgrade(args: { if (catalog && newLaunch) { recordHostModelCatalogSnapshot(target, agent, worktree ?? '', catalog) } - // A new scoped default must refresh the view even when its live choices already won. updateOptionState((current) => current.record !== activeOptionRecordRef.current ? current : catalog - ? { - ...applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, { - newLaunch - }) - } + ? applyStructuredAgentSessionModelCatalog(current, optionCatalog, catalog, { + newLaunch + }) : settleStructuredAgentSessionBuiltinCatalog(current) ) } 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 9d6f9c72202b..8712f0877fa8 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 @@ -187,11 +187,7 @@ describe('a chat pane over its own launch', () => { return Promise.resolve(catalog) } if (method === 'agentSession.setOption') { - return Promise.resolve({ - ok: true, - fence: 1, - value: { options: { model: 'opus', effort: 'low' } } - }) + return Promise.resolve({ ok: true, value: { options: { model: 'opus', effort: 'low' } } }) } return new Promise(() => {}) }) 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 1936f53f75bc..276968920557 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 @@ -6,10 +6,7 @@ import { useStructuredAgentSessionLaunchFailure, useStructuredAgentSessionLaunchLifecycle } from '@/lib/structured-agent-session-launch' -import { - type StructuredLaunchAcceptedOptions, - getStructuredAgentSessionLaunchSelection -} from '@/lib/structured-agent-session-launch-options' +import { getStructuredAgentSessionLaunchSelection } from '@/lib/structured-agent-session-launch-options' import { subscribeStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch-registry' import { relaunchFailedStructuredAgentSessionWithMessage } from '@/lib/structured-agent-session-launch-message' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' @@ -21,7 +18,6 @@ export type StructuredAgentSessionLaunchView = { seedOptions?: Readonly> /** Picks the launch holds and applies before it publishes. */ heldOptions: Readonly> - acceptedOptions?: StructuredLaunchAcceptedOptions /** Where the chat runs; its own config may replace the listed default. */ worktree?: string } @@ -40,7 +36,7 @@ function useLaunchView( if (current) { snapshot = current } else if (snapshot && snapshot.held !== NO_HELD_OPTIONS) { - snapshot = { seed: snapshot.seed, held: NO_HELD_OPTIONS, accepted: snapshot.accepted } + snapshot = { seed: snapshot.seed, held: NO_HELD_OPTIONS } } return snapshot } @@ -80,11 +76,10 @@ function useLaunchView( kind: intent.kind, ...(selection?.seed ? { seedOptions: selection.seed } : {}), heldOptions: held, - acceptedOptions: selection?.accepted, ...(worktreeId ? { worktree: toRuntimeWorktreeSelector(worktreeId) } : {}) } : undefined, - [held, intent.kind, selection?.seed, selection?.accepted, worktreeId] + [held, intent.kind, selection?.seed, worktreeId] ) } 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 4c812ddb65a6..e8d7040b3939 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 @@ -1,30 +1,42 @@ -import type { StructuredLaunchAcceptedOptions } from '@/lib/structured-agent-session-launch-options' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import type { AgentSessionConversationCommand } from '../../../../shared/agent-session-conversation-command' import type { AgentSessionOptionsResult } from '../../../../shared/agent-session-wire' import type { AgentSessionRewindSupport } from '../../../../shared/agent-session-rewind' import type { AgentType } from '../../../../shared/agent-status-types' import type { AgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' import { + applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState, type StructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' -import { - firstFrameOptionState, - structuredAgentSessionConfiguredOptionView -} from './structured-agent-session-first-frame-options' -import { acceptStructuredAgentSessionOptionValues } from '../../../../shared/structured-agent-session-option-view' +import { structuredAgentSessionOptionView } from '../../../../shared/structured-agent-session-option-view' import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' import { useStructuredAgentSessionHostCapability } from '@/runtime/structured-agent-session-host-capability' 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 } from '../right-sidebar/coalesced-poll-runner' -/** The picker's catalog and selection for one conversation on its execution host. */ +/** The first frame: the host's saved list when the renderer already holds it, else the seed, + * which shows the quiet placeholder until the host answers. */ +export function firstFrameOptionState( + agent: AgentType, + target: RuntimeClientTarget, + launch: { newLaunch: boolean; worktree?: string; seedsModel: boolean } +): StructuredAgentSessionOptionState { + const seed = structuredAgentSessionSeedCatalog(agent) + const seeded = createStructuredAgentSessionOptionState(agent, seed) + const snapshot = readHostModelCatalogSnapshot(target, agent, launch) + return snapshot ? applyStructuredAgentSessionModelCatalog(seeded, seed, snapshot, launch) : seeded +} + +/** 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 @@ -42,11 +54,6 @@ export function useStructuredAgentSessionOptionState(args: { /** The option values the host's status carries: a pick made on another device moves it. */ hostOptionStamp: string readsBeforeStart: boolean - hostPermissionMode?: string - hostSelection: Readonly> | undefined - launchSeedOptions: Readonly> | undefined - held: Readonly> - acceptedOptions?: StructuredLaunchAcceptedOptions turnId: string | null unloadedTurnRevisions: number | undefined }) { @@ -61,13 +68,11 @@ export function useStructuredAgentSessionOptionState(args: { providerStarting, providerVisible, readsBeforeStart, - hostSelection, sessionId, target, turnId, worktree } = args - const selectionFromStatus = hostSelection !== undefined const [conversationSupport, setConversationSupport] = useState<{ sessionId: string commands: readonly AgentSessionConversationCommand[] @@ -90,12 +95,15 @@ export function useStructuredAgentSessionOptionState(args: { ...(worktree ? { worktree } : {}) }) ) + // Read only when a fence or session resets the state, so a new target alone resets nothing. const firstFrameLaunch = useRef({ target, newLaunch, worktree, seedsModel }) useEffect(() => { firstFrameLaunch.current = { target, newLaunch, worktree, seedsModel } }, [newLaunch, seedsModel, target, worktree]) const optionStateRef = useRef(optionState) const activeOptionRecordRef = useRef(optionState.record) + // A pick on its way to the host, which every send carries too. + const pendingOptionsRef = useRef>({}) const optionMutationGeneration = useRef(0) const updateOptionState = useCallback( (update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState) => { @@ -105,61 +113,11 @@ export function useStructuredAgentSessionOptionState(args: { }, [] ) - const heldPick = useRef<{ - token: object | undefined - record: typeof optionState.record - generation: number - identity: string - fence: number | null - } | null>(null) - const beginLaunchPick = useCallback( - (token: object | undefined) => { - const pick = { - token, - record: activeOptionRecordRef.current, - generation: ++optionMutationGeneration.current, - identity, - fence - } - heldPick.current = pick - return () => - heldPick.current === pick && - activeOptionRecordRef.current === pick.record && - optionMutationGeneration.current === pick.generation - }, - [identity, fence] - ) - const optionsReadRef = useRef(null) - const acceptedPacket = useRef(undefined) - if (args.acceptedOptions && acceptedPacket.current !== args.acceptedOptions) { - acceptedPacket.current = args.acceptedOptions - const accepted = args.acceptedOptions - const pick = heldPick.current - if ( - pick && - pick.token === args.acceptedOptions.token && - pick.identity === identity && - pick.record === activeOptionRecordRef.current && - pick.generation === optionMutationGeneration.current && - ((fence === null && pick.fence === null) || - (fence === accepted.fence && (pick.fence === null || pick.fence === fence))) - ) { - pick.fence = accepted.fence - pick.generation = ++optionMutationGeneration.current - updateOptionState((current) => - acceptStructuredAgentSessionOptionValues(current, accepted.options, hostSelection) - ) - optionsReadRef.current?.run() - } - } const optionIdentityRef = useRef(identity) useEffect(() => { const previous = optionStateRef.current const sameSession = optionIdentityRef.current === identity optionIdentityRef.current = identity - if (sameSession && (selectionFromStatus || heldPick.current?.fence === null)) { - return - } const launch = firstFrameLaunch.current const seeded = firstFrameOptionState(agent, launch.target, { newLaunch: launch.newLaunch, @@ -169,26 +127,25 @@ export function useStructuredAgentSessionOptionState(args: { // 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 kept = - sameSession && - (previous.record.model !== undefined || - (previous.catalogSource !== 'seed' && previous.catalogSource !== null)) + sameSession && previous.catalogSource !== 'seed' && previous.catalogSource !== null ? { ...previous, record: cloneNativeChatSessionOptionRecord(previous.record), - pendingPick: null + pendingId: null } : seeded // The chat's own, which a restart for a new mode keeps: the picker never blanks between them. const next = sameSession ? { ...kept, permissionMode: previous.permissionMode } : kept optionMutationGeneration.current += 1 + pendingOptionsRef.current = {} + optionStateRef.current = next activeOptionRecordRef.current = next.record - updateOptionState(() => next) - }, [agent, fence, identity, selectionFromStatus, updateOptionState]) + setOptionState(next) + }, [agent, fence, identity]) - const currentHostStamp = useRef(hostOptionStamp) - currentHostStamp.current = hostOptionStamp - // The read also carries commands, goal, context, image and rewind support. A capable host's - // selection comes from status even when this metadata read returns an older model. + const optionsReadRef = useRef(null) + // Refresh options each turn to confirm which model the provider actually selected, and once + // the provider starts: only then has the host read what it will run. useEffect(() => { if (!providerVisible || (providerStarting && !readsBeforeStart) || !optionCatalog) { return @@ -196,46 +153,29 @@ export function useStructuredAgentSessionOptionState(args: { let stale = false const runner = createCoalescedPollRunner(async () => { const readGeneration = optionMutationGeneration.current - const readRecord = activeOptionRecordRef.current - const readStamp = currentHostStamp.current const result = await callStructuredAgentSession( target, 'agentSession.options', { sessionId } ) - if (stale) { - return - } - if ( - optionMutationGeneration.current !== readGeneration || - readRecord !== activeOptionRecordRef.current || - readStamp !== currentHostStamp.current - ) { - runner.run() - return - } - setConversationSupport({ - sessionId, - commands: result.conversationCommands ?? [], - threadGoal: result.threadGoal, - reportedThreadGoal: result.reportedThreadGoal, - contextUsage: result.contextUsage, - acceptsImages: result.acceptsImages, - // A host that predates rewind does not name it. - rewind: result.rewind ?? { supported: false, reason: 'unsupported' }, - fence - }) - updateOptionState((current) => { - if (current.record !== activeOptionRecordRef.current) { - return current - } - return applyStructuredAgentSessionOptions( - current, - optionCatalog, - result, - selectionFromStatus + if (!stale && optionMutationGeneration.current === readGeneration) { + setConversationSupport({ + sessionId, + commands: result.conversationCommands ?? [], + threadGoal: result.threadGoal, + reportedThreadGoal: result.reportedThreadGoal, + contextUsage: result.contextUsage, + acceptsImages: result.acceptsImages, + // A host that predates rewind does not name it. + rewind: result.rewind ?? { supported: false, reason: 'unsupported' }, + fence + }) + updateOptionState((current) => + current.record === activeOptionRecordRef.current + ? applyStructuredAgentSessionOptions(current, optionCatalog, result) + : current ) - }) + } }) optionsReadRef.current = runner runner.run() @@ -246,10 +186,11 @@ export function useStructuredAgentSessionOptionState(args: { }, [ fence, optionCatalog, + // Another device's pick moves it. + hostOptionStamp, providerStarting, providerVisible, readsBeforeStart, - selectionFromStatus, sessionId, target, turnId, @@ -258,14 +199,13 @@ export function useStructuredAgentSessionOptionState(args: { // Reads share the session's host queue with sends and interrupts, so a burst of // missed revisions keeps one read in flight and at most one behind it. - const refresh = `${contextRefresh}|${hostOptionStamp}` - const seenContextRefresh = useRef(refresh) + const seenContextRefresh = useRef(contextRefresh) useEffect(() => { - if (refresh !== seenContextRefresh.current) { - seenContextRefresh.current = refresh + if (contextRefresh !== seenContextRefresh.current) { + seenContextRefresh.current = contextRefresh optionsReadRef.current?.run() } - }, [refresh]) + }, [contextRefresh]) // An options answer made before the account's listing landed listed none of it. const rereadOptions = useCallback(() => optionsReadRef.current?.run(), []) @@ -273,26 +213,25 @@ export function useStructuredAgentSessionOptionState(args: { target, AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY ) + // What the picker shows and a send carries, for this session's host. + const optionView = useCallback( + ( + state: StructuredAgentSessionOptionState, + seed: Readonly> | undefined, + held: Readonly> + ) => structuredAgentSessionOptionView(state, seed, held, takesSendOptions), + [takesSendOptions] + ) return { - beginLaunchPick, + optionView, takesSendOptions, conversationSupport, rereadOptions, optionState, - optionView: useMemo( - () => - structuredAgentSessionConfiguredOptionView( - args, - optionState, - takesSendOptions, - optionIdentityRef.current === identity - ), - [args, identity, optionState, takesSendOptions] - ), - optionStateRef, activeOptionRecordRef, + pendingOptionsRef, optionMutationGeneration, updateOptionState } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-surface.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-surface.ts deleted file mode 100644 index 2d2852ad044b..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-option-surface.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useCallback, useMemo } from 'react' -import type { - SessionOptionDescriptor, - SessionOptionsSurface -} from '../../../../shared/native-chat-session-options' -import { cloneNativeChatSessionOptionRecord } from '../../../../shared/native-chat-session-option-state' -import { - commitStructuredAgentSessionOptionValues, - structuredAgentSessionOptionSnapshot, - type StructuredAgentSessionOptionState -} from '../../../../shared/structured-agent-session-options' -import { structuredAgentSessionOptionView } from '../../../../shared/structured-agent-session-option-view' -import { getStructuredAgentSessionLaunchSelection } from '@/lib/structured-agent-session-launch-options' - -/** A menu pick returns the latest held launch value even before React renders it. */ -export function useStructuredAgentSessionOptionSurface(args: { - sessionId: string - optionView: StructuredAgentSessionOptionState - optionSnapshot: SessionOptionDescriptor[] - /** The host keeps a send's choices, so the picker shows a gone model's replacement. */ - takesSendOptions: boolean - changeOption: ( - id: string, - value: string | boolean - ) => Promise> | null> -}): SessionOptionsSurface { - const { sessionId, optionView, optionSnapshot, takesSendOptions, changeOption } = args - const setOption = useCallback( - async (id: string, value: string | boolean) => { - const committed = await changeOption(id, value) - const held = getStructuredAgentSessionLaunchSelection(sessionId)?.held - const selected = committed - ? commitStructuredAgentSessionOptionValues( - { ...optionView, record: cloneNativeChatSessionOptionRecord(optionView.record) }, - committed - ) - : optionView - return { - snapshot: structuredAgentSessionOptionSnapshot( - structuredAgentSessionOptionView(selected, undefined, held ?? {}, takesSendOptions) - ) - } - }, - [changeOption, optionView, sessionId, takesSendOptions] - ) - return useMemo( - () => ({ - getSnapshot: () => optionSnapshot, - setOption, - invokeAction: async () => ({ snapshot: optionSnapshot }), - subscribe: () => () => {} - }), - [setOption, optionSnapshot] - ) -} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx deleted file mode 100644 index 220ca5431f45..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.current-selection.test.tsx +++ /dev/null @@ -1,344 +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(), - summary: vi.fn(), - settings: vi.fn(), - hold: 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: mocks.settings -})) -vi.mock('@/lib/structured-agent-session-launch-options', () => ({ - holdStructuredAgentSessionLaunchOption: mocks.hold, - getStructuredAgentSessionLaunchOptionToken: () => mocks.hold, - getStructuredAgentSessionLaunchSelection: () => null -})) - -import type { StructuredLaunchAcceptedOptions } from '@/lib/structured-agent-session-launch-options' -import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' -import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' -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' - -const EFFORTS = [ - { value: 'low', label: 'Low' }, - { value: 'medium', label: 'Medium' }, - { value: 'high', label: 'High' } -] -const MODELS = [ - { - id: 'gpt-one', - label: 'One', - isDefault: true, - defaultEffort: 'medium', - efforts: EFFORTS.filter((effort) => effort.value !== 'high') - }, - { id: 'gpt-two', label: 'Two', efforts: EFFORTS } -] -const PICK = { model: 'gpt-two', effort: 'high' } -const LOCAL = { kind: 'local' } as const - -function pill(snapshot: readonly SessionOptionDescriptor[]) { - const current = (id: string): string | null => { - const entry = snapshot.find((option) => option.id === id) - return entry?.kind.type === 'select' ? (entry.kind.currentValue ?? null) : null - } - return { model: current('model'), effort: current('effort') } -} - -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((settle) => (resolve = settle)) - return { promise, resolve } -} - -beforeEach(() => { - mocks.call.mockReset() - mocks.summary.mockReset() - mocks.settings.mockReset() - mocks.hold.mockReset() - resetHostModelCatalogSnapshotsForTests() - mocks.call.mockImplementation(async (_target: unknown, method: string) => - method === 'agentSession.modelCatalog' - ? { - origin: 'live-session', - models: MODELS, - fetchedAt: 1, - listingNamesConfiguredModel: true - } - : { - models: MODELS, - current: { model: 'gpt-one', effort: 'medium' }, - conversationCommands: [{ name: '/help', description: 'Help' }], - rewind: { supported: false, reason: 'unsupported' } - } - ) -}) - -function renderSelection(options: { - capable: boolean - selection?: Readonly> -}) { - const frames: ReturnType[] = [] - const launch = { kind: 'new' as const, heldOptions: {}, seedOptions: PICK } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this proof makes no option mutation. - const mutate = vi.fn() as unknown as StructuredAgentSessionMutate - mocks.summary.mockImplementation(() => - options.capable ? { optionSelection: options.selection ?? PICK } : { status: 'idle' } - ) - const initialProps: { fence: number; turnId: string | null } = { fence: 1, turnId: null } - const view = renderHook( - (props: { fence: number; turnId: string | null }) => { - const current = useStructuredAgentSessionOptions({ - agent: 'codex', - sessionId: 'session-1', - target: LOCAL, - transportEnabled: true, - isVisible: true, - providerVisible: true, - optionSelection: mocks.summary()?.optionSelection, - fence: props.fence, - turnId: props.turnId, - unloadedTurnRevisions: undefined, - mutate, - launch - }) - frames.push(pill(current.optionSnapshot)) - return current - }, - { initialProps } - ) - return { frames, ...view } -} - -it('keeps the host selection through fence, turn, and stale metadata reads', async () => { - const view = renderSelection({ capable: true }) - await waitFor(() => expect(pill(view.result.current.optionSnapshot)).toEqual(PICK)) - const nextFrame = view.frames.length - view.rerender({ fence: 2, turnId: 'turn-1' }) - await waitFor(() => - expect(mocks.call).toHaveBeenCalledWith({ kind: 'local' }, 'agentSession.options', { - sessionId: 'session-1' - }) - ) - await waitFor(() => expect(view.result.current.conversationCommands).toHaveLength(1)) - expect(view.frames.slice(nextFrame)).toEqual(expect.arrayContaining([PICK])) - expect(view.frames.slice(nextFrame).every((frame) => frame.model === PICK.model)).toBe(true) - expect(view.frames.slice(nextFrame).every((frame) => frame.effort === PICK.effort)).toBe(true) - view.unmount() -}) - -it('uses the existing options read when an older host omits the selection field', async () => { - const view = renderSelection({ capable: false }) - await waitFor(() => - expect(pill(view.result.current.optionSnapshot)).toEqual({ - model: 'gpt-one', - effort: 'medium' - }) - ) - view.unmount() -}) - -it.each([false, true])('reconciles choices (held: %s)', async (held) => { - mocks.summary.mockImplementation(() => ({ optionSelection: { model: 'gpt-one' } })) - const write = deferred<{ options: Record }>() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deferred result is the setOption reply the hook reads. - const mutate = vi.fn(() => write.promise) as unknown as StructuredAgentSessionMutate - let created = !held - let acceptedOptions: StructuredLaunchAcceptedOptions | undefined - mocks.hold.mockImplementation(() => - write.promise.then(({ options }) => ({ kind: 'accepted', options, fence: 1 })) - ) - const view = renderHook(() => - useStructuredAgentSessionOptions({ - agent: 'codex', - sessionId: 'session-1', - target: LOCAL, - transportEnabled: created, - isVisible: true, - providerVisible: true, - optionSelection: mocks.summary()?.optionSelection, - hostOptionStamp: mocks.summary()?.stamp, - hostPermissionMode: 'ask', - fence: created ? 1 : null, - turnId: null, - unloadedTurnRevisions: undefined, - mutate, - launch: { kind: 'new', heldOptions: {}, acceptedOptions } - }) - ) - await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) - const readback = deferred<{ - models: typeof MODELS - current: { model: string; effort: string } - }>() - const during = deferred<{ models: typeof MODELS; current: { model: string; effort: string } }>() - let reads = 0 - mocks.call.mockImplementation((_target: unknown, method: string) => - method === 'agentSession.options' - ? ++reads === 1 - ? during.promise - : readback.promise - : Promise.resolve({ models: MODELS }) - ) - let first!: Promise - let second!: Promise - act(() => { - first = view.result.current.setStructuredOption('model', 'gpt-two') - if (!held) { - second = view.result.current.setStructuredOption('model', 'gpt-two') - } - }) - if (!held) { - await expect(second).resolves.toBe(false) - } - mocks.summary.mockImplementation(() => ({ - optionSelection: { model: 'gpt-one' }, - stamp: 'during-write' - })) - view.rerender() - await waitFor(() => expect(reads).toBe(1)) - await act(async () => { - if (!held) { - expect(await view.result.current.setStructuredOption('model', 'gpt-two')).toBe(false) - } - if (held) { - acceptedOptions = { options: { model: 'gpt-two' }, fence: 1, token: mocks.hold } - } - created = true - view.rerender() - write.resolve({ options: { model: 'gpt-two' } }) - await first - }) - expect(held ? mocks.hold : mutate).toHaveBeenCalledOnce() - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') - expect( - view.result.current.optionSnapshot.find((option) => option.id === 'permissionMode')?.kind - .currentValue - ).toBe('ask') - await act(async () => - during.resolve({ models: MODELS, current: { model: 'gpt-one', effort: 'medium' } }) - ) - await waitFor(() => expect(reads).toBe(2)) - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') - await act(async () => - readback.resolve({ models: MODELS, current: { model: 'gpt-one', effort: 'low' } }) - ) - await waitFor(() => - expect(pill(view.result.current.optionSnapshot)).toEqual({ model: 'gpt-one', effort: 'low' }) - ) - mocks.call.mockResolvedValue({ - models: MODELS, - current: { model: 'gpt-one', effort: 'medium' } - }) - mocks.summary.mockImplementation(() => ({ - optionSelection: { model: 'gpt-one' }, - stamp: 'fresh-snapshot' - })) - view.rerender() - await waitFor(() => expect(pill(view.result.current.optionSnapshot).effort).toBe('medium')) - view.unmount() -}) - -it('fences a late pick when the same session moves to another host', async () => { - mocks.summary.mockImplementation(() => ({ optionSelection: { model: 'gpt-one' } })) - const oldWrite = deferred<{ options: Record }>() - const newWrite = deferred() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each promise is a setOption reply. - const mutate = vi - .fn() - .mockReturnValueOnce(oldWrite.promise) - .mockReturnValueOnce(newWrite.promise) as unknown as StructuredAgentSessionMutate - const launch = { kind: 'new' as const, heldOptions: {} } - const remote = { kind: 'environment', environmentId: 'server-1' } as const - const initialTarget: RuntimeClientTarget = LOCAL - const view = renderHook, RuntimeClientTarget>( - (target: RuntimeClientTarget) => - useStructuredAgentSessionOptions({ - agent: 'codex', - sessionId: 'session-1', - target, - transportEnabled: true, - isVisible: true, - providerVisible: true, - optionSelection: mocks.summary()?.optionSelection, - fence: 1, - turnId: null, - unloadedTurnRevisions: undefined, - mutate, - launch - }), - { initialProps: initialTarget } - ) - await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) - let first!: Promise - act(() => { - first = view.result.current.setStructuredOption('model', 'gpt-two') - }) - view.rerender(remote) - await waitFor(() => expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one')) - let second!: Promise - act(() => { - second = view.result.current.setStructuredOption('model', 'gpt-two') - }) - expect(mutate).toHaveBeenCalledTimes(2) - await act(async () => { - oldWrite.resolve({ options: { model: 'gpt-two' } }) - await first - }) - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-two') - await act(async () => { - newWrite.resolve(null) - await second - }) - expect(pill(view.result.current.optionSnapshot).model).toBe('gpt-one') - view.unmount() -}) - -it('restores an unpicked chat’s observed model/effort and independent status permission', async () => { - const mutate = vi.fn() - const selection = {} - const view = renderHook( - ({ mode }: { mode: string }) => - useStructuredAgentSessionOptions({ - agent: 'codex', - sessionId: 'restored', - target: LOCAL, - transportEnabled: true, - isVisible: true, - providerVisible: true, - optionSelection: selection, - hostPermissionMode: mode, - fence: 1, - turnId: null, - unloadedTurnRevisions: undefined, - mutate - }), - { initialProps: { mode: 'ask' } } - ) - await waitFor(() => - expect(pill(view.result.current.optionSnapshot)).toEqual({ model: 'gpt-one', effort: 'medium' }) - ) - const permission = () => - view.result.current.optionSnapshot.find((option) => option.id === 'permissionMode')?.kind - .currentValue - expect(permission()).toBe('ask') - expect( - view.result.current.optionSnapshot.find((option) => option.id === 'model')?.valueSource - ).toBe('reported') - view.rerender({ mode: 'full-access' }) - expect(permission()).toBe('full-access') - expect(mutate).not.toHaveBeenCalled() - expect(mocks.settings).not.toHaveBeenCalled() - view.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 04ece8c75f4b..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 @@ -22,8 +22,7 @@ vi.mock('./native-chat-session-option-settings-write', () => ({ vi.mock('@/lib/structured-agent-session-launch-options', () => ({ holdStructuredAgentSessionLaunchOption: mocks.hold, - getStructuredAgentSessionLaunchSelection: () => null, - getStructuredAgentSessionLaunchOptionToken: () => mocks.hold + getStructuredAgentSessionLaunchSelection: () => null })) import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' @@ -423,11 +422,7 @@ describe('useStructuredAgentSessionOptions', () => { it('remembers a pick the launch applied as the next launch default', async () => { answer({ modelCatalog: () => Promise.resolve(SAVED_LIST) }) - mocks.hold.mockResolvedValue({ - kind: 'accepted', - fence: 1, - options: { model: 'gpt-5.6-luna' } - }) + mocks.hold.mockResolvedValue({ kind: 'accepted', options: { model: 'gpt-5.6-luna' } }) const { result, unmount } = renderOptions( { ...PROVISIONAL, launchSeedOptions: SEED }, mutateWith(async () => null).mutate @@ -503,31 +498,27 @@ describe('useStructuredAgentSessionOptions', () => { describe('before the provider starts', () => { it('keeps a picked effort visible after a model pick during startup', async () => { - let reads = 0 answer({ - options: async () => { - if (reads++ > 0) { - return new Promise(() => {}) - } - return { - models: ['opus', 'sonnet'].map((id) => ({ - id, - label: id, - efforts: ['low', 'high'].map((value) => ({ value, label: value })) - })), - current: { model: 'opus' } - } - } + options: async () => ({ + models: ['opus', 'sonnet'].map((id) => ({ + id, + label: id, + efforts: ['low', 'high'].map((value) => ({ value, label: value })) + })), + current: { model: 'opus' } + }) }) - const { mutate } = mutateWith(async (_description, _method, fields) => fields) const { result, unmount } = renderOptions( { ...REOPENED, agent: 'claude', providerStarting: true }, - mutate + mutateWith(async () => new Promise(() => {})).mutate ) await waitFor(() => expect(currentValue(result.current.optionSnapshot, 'model')).toBe('opus')) - await act(async () => { - await result.current.setStructuredOption('effort', 'low') - await result.current.setStructuredOption('model', 'sonnet') + 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') @@ -589,7 +580,7 @@ describe('useStructuredAgentSessionOptions', () => { describe("a paired server's new chat", () => { it("shows the server's seed once its list answers and remembers a pick on the server under its model", async () => { answer({ modelCatalog: () => Promise.resolve(SAVED_LIST) }) - mocks.hold.mockResolvedValue({ kind: 'accepted', fence: 1, options: { effort: 'high' } }) + mocks.hold.mockResolvedValue({ kind: 'accepted', options: { effort: 'high' } }) const { result, unmount } = renderOptions( { ...PROVISIONAL, paired: true, launchSeedOptions: SEED }, mutateWith(async () => null).mutate @@ -615,7 +606,7 @@ describe('useStructuredAgentSessionOptions', () => { // A server whose user never saved a model starts the CLI default, as a local chat does. it('shows the CLI default and takes picks when the server saved no model', async () => { answer({ modelCatalog: () => Promise.resolve(HOST_CATALOG) }) - mocks.hold.mockResolvedValue({ kind: 'accepted', fence: 1, options: { model: 'gpt-hosted' } }) + mocks.hold.mockResolvedValue({ kind: 'accepted', options: { model: 'gpt-hosted' } }) const { result, unmount } = renderOptions( { ...PROVISIONAL, paired: true }, mutateWith(async () => null).mutate 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 9c2f87e1dd4d..143ad8184cf1 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 @@ -1,10 +1,15 @@ import { toast } from 'sonner' import { useCallback, useMemo } from 'react' -import type { AgentSessionOptionResult } from '../../../../shared/agent-session-wire' +import type { + AgentSessionOptionResult, + AgentSessionOptionsResult +} from '../../../../shared/agent-session-wire' import type { AgentType } from '../../../../shared/agent-status-types' import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' +import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode' import { + applyStructuredAgentSessionOptions, canSetStructuredAgentSessionOption, commitStructuredAgentSessionOptionValues, lockedStructuredAgentSessionOptionSnapshot, @@ -12,19 +17,17 @@ import { structuredAgentSessionOptionSnapshot, type StructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' -import { acceptStructuredAgentSessionOptionValues } from '../../../../shared/structured-agent-session-option-view' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' -import { structuredAgentSessionHostKey } from '@/runtime/structured-agent-session-host-capability' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-settings-write' import { encodeStructuredAgentSessionOptionValue } from '../../../../shared/structured-agent-session-option-codec' import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' import { useHostModelCatalogUpgrade } from './use-host-model-catalog-upgrade' import { useStructuredAgentSessionOptionState } from './use-structured-agent-session-option-state' -import { useStructuredAgentSessionOptionSurface } from './use-structured-agent-session-option-surface' import { agentSessionWriteFailureText } from './agent-session-write-notice-text' import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch' import { - getStructuredAgentSessionLaunchOptionToken, + getStructuredAgentSessionLaunchSelection, holdStructuredAgentSessionLaunchOption, type StructuredLaunchOptionOutcome } from '@/lib/structured-agent-session-launch-options' @@ -41,8 +44,6 @@ export function useStructuredAgentSessionOptions(args: { providerStarting?: boolean providerRunning?: boolean hostOptionStamp?: string - optionSelection?: Readonly> - hostPermissionMode?: string fence: number | null turnId: string | null unloadedTurnRevisions: number | undefined @@ -60,20 +61,19 @@ 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. const acceptsPicks = !transportEnabled || fence !== null const optionCatalog = useMemo(() => structuredAgentSessionSeedCatalog(agent), [agent]) - const identity = `${agent}:${sessionId}:${structuredAgentSessionHostKey(target)}` - const selectionFromStatus = args.optionSelection !== undefined const { - beginLaunchPick, + optionView, takesSendOptions, optionState, - optionView, optionStateRef, activeOptionRecordRef, + pendingOptionsRef, optionMutationGeneration, updateOptionState, rereadOptions, @@ -81,7 +81,7 @@ export function useStructuredAgentSessionOptions(args: { } = useStructuredAgentSessionOptionState({ agent, optionCatalog, - identity, + identity: `${agent}:${sessionId}`, fence, sessionId, target, @@ -89,16 +89,11 @@ 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. readsBeforeStart: launch?.kind !== 'new', - hostSelection: args.optionSelection, - hostPermissionMode: args.hostPermissionMode, - launchSeedOptions, - held, - acceptedOptions: launch?.acceptedOptions, turnId, unloadedTurnRevisions: args.unloadedTurnRevisions }) @@ -135,52 +130,72 @@ export function useStructuredAgentSessionOptions(args: { [agent, target] ) const sendStructuredOption = useCallback( - async (id: string, encoded: string): Promise> | null> => { - const targetRecord = optionStateRef.current.record - let mutationGeneration = ++optionMutationGeneration.current + async (id: string, encoded: string): Promise => { + const currentState = optionStateRef.current + const targetRecord = currentState.record + const mutationGeneration = ++optionMutationGeneration.current + const sameRecord = (): boolean => activeOptionRecordRef.current === targetRecord const isCurrent = (): boolean => - activeOptionRecordRef.current === targetRecord && - optionMutationGeneration.current === mutationGeneration - const shownAtOnce = id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID - if (shownAtOnce && !selectionFromStatus) { + sameRecord() && optionMutationGeneration.current === mutationGeneration + const currentPick = (): boolean => isCurrent() || pendingOptionsRef.current[id] === encoded + // Record-only: shown at once, so the picker stays usable while the write waits behind a start. + const shownAtOnce = providerStarting || id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID + if (shownAtOnce) { updateOptionState((current) => commitStructuredAgentSessionOptionValues(current, { [id]: encoded }) ) + } else { + pendingOptionsRef.current[id] = encoded + updateOptionState((current) => ({ ...current, pendingId: id })) } - updateOptionState((current) => ({ ...current, pendingPick: { id, value: encoded } })) let result: AgentSessionOptionResult | null = null - let committed: Readonly> | null = null try { result = await mutate( 'agentSession.setOption', 'agentSession.setOption', { key: id, value: encoded } ) - if (result && isCurrent()) { - mutationGeneration = ++optionMutationGeneration.current - const acceptedValues = result.options ?? { [id]: encoded } - committed = acceptedValues + if (result && sameRecord() && currentPick()) { + const returned = result.options ?? { [id]: encoded } + const committed = isCurrent() ? returned : { [id]: returned[id] ?? encoded } updateOptionState((current) => current.record === targetRecord - ? acceptStructuredAgentSessionOptionValues( - current, - acceptedValues, - args.optionSelection - ) + ? commitStructuredAgentSessionOptionValues(current, committed) : current ) - rememberOptionPicks(optionView, acceptedValues) - rereadOptions() + // The launch seed names the model an effort-only pick was made under. + rememberOptionPicks( + optionView(currentState, launchSeedOptions, NO_HELD_OPTIONS), + committed + ) } - return committed + return Boolean(result) } finally { - if (shownAtOnce && !result && !selectionFromStatus && isCurrent()) { - rereadOptions() + if (sameRecord() && pendingOptionsRef.current[id] === encoded) { + delete pendingOptionsRef.current[id] + } + // A settled write, or a shown one that failed, reads back what the host now holds. + if (isCurrent() && (result || shownAtOnce)) { + void callStructuredAgentSession( + target, + 'agentSession.options', + { sessionId } + ) + .then((refreshed) => { + if (isCurrent()) { + updateOptionState((latest) => + latest.record === targetRecord && optionCatalog + ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) + : latest + ) + } + }) + .catch(() => {}) } if (isCurrent()) { updateOptionState((current) => - current.record === targetRecord && current.pendingPick?.id === id - ? { ...current, pendingPick: null } + current.record === targetRecord && current.pendingId === id + ? { ...current, pendingId: null } : current ) } @@ -188,14 +203,17 @@ export function useStructuredAgentSessionOptions(args: { }, [ activeOptionRecordRef, - args.optionSelection, + launchSeedOptions, mutate, + optionCatalog, optionMutationGeneration, optionStateRef, - rememberOptionPicks, - rereadOptions, optionView, - selectionFromStatus, + pendingOptionsRef, + providerStarting, + rememberOptionPicks, + sessionId, + target, updateOptionState ] ) @@ -204,73 +222,80 @@ export function useStructuredAgentSessionOptions(args: { if (outcome.kind === 'refused') { toast.error(agentSessionWriteFailureText(outcome.failure, 'option')) } else if (outcome.kind === 'accepted') { - rememberOptionPicks(optionView, outcome.options) + rememberOptionPicks( + optionView(optionStateRef.current, launchSeedOptions, NO_HELD_OPTIONS), + outcome.options + ) } }, - [optionView, rememberOptionPicks] + [launchSeedOptions, optionStateRef, optionView, rememberOptionPicks] ) const optionSnapshot = useMemo(() => { - const snapshot = structuredAgentSessionOptionSnapshot(optionView) + const snapshot = structuredAgentSessionOptionSnapshot( + optionView(optionState, launchSeedOptions, held) + ) return acceptsPicks ? snapshot : lockedStructuredAgentSessionOptionSnapshot(snapshot) - }, [acceptsPicks, optionView]) - const changeStructuredOption = useCallback( - async ( - id: string, - value: string | boolean - ): Promise> | null> => { + }, [acceptsPicks, held, launchSeedOptions, optionState, optionView]) + const setStructuredOption = useCallback( + async (id: string, value: string | boolean): Promise => { + const view = optionView(optionStateRef.current, launchSeedOptions, held) + const pickState = { + ...view, + pendingId: providerStarting || pendingOptionsRef.current[id] === undefined ? null : id + } const encoded = encodeStructuredAgentSessionOptionValue(id, value) if ( !optionCatalog || encoded === null || - !canSetStructuredAgentSessionOption( - { ...optionView, pendingPick: optionStateRef.current.pendingPick }, - id, - value - ) + !canSetStructuredAgentSessionOption(pickState, id, value) ) { - return null + return false } if (!transportEnabled || fence === null) { // No fence yet: the launch holds it and applies it before anything else is sent. const applied = holdStructuredAgentSessionLaunchOption(sessionId, id, encoded) - if (applied) { - const isCurrent = beginLaunchPick( - getStructuredAgentSessionLaunchOptionToken(sessionId, id) - ) - void applied.then((outcome) => { - if (isCurrent()) { - settleLaunchOptionPick(outcome) - } - }) - } - return applied === null ? null : { [id]: encoded } + void applied?.then(settleLaunchOptionPick) + return applied !== null } return sendStructuredOption(id, encoded) }, [ - beginLaunchPick, fence, + held, + launchSeedOptions, optionCatalog, - optionView, optionStateRef, + optionView, + pendingOptionsRef, + providerStarting, sendStructuredOption, sessionId, settleLaunchOptionPick, transportEnabled ] ) - const setStructuredOption = useCallback( - async (id: string, value: string | boolean): Promise => - (await changeStructuredOption(id, value)) !== null, - [changeStructuredOption] + const setOption = useCallback( + async (id: string, value: string | boolean) => { + await setStructuredOption(id, value) + // Read, not rendered: a pick the launch just took is not in this render's props yet. + const currentHeld = getStructuredAgentSessionLaunchSelection(sessionId)?.held + return { + snapshot: structuredAgentSessionOptionSnapshot( + optionView(optionStateRef.current, launchSeedOptions, currentHeld ?? NO_HELD_OPTIONS) + ) + } + }, + [launchSeedOptions, optionStateRef, optionView, sessionId, setStructuredOption] + ) + const optionSurface = useMemo( + () => ({ + getSnapshot: () => optionSnapshot, + setOption, + invokeAction: async () => ({ snapshot: optionSnapshot }), + subscribe: () => () => {} + }), + [setOption, optionSnapshot] ) - const optionSurface = useStructuredAgentSessionOptionSurface({ - sessionId, - optionView, - optionSnapshot, - takesSendOptions, - changeOption: changeStructuredOption - }) const support = transportEnabled && conversationSupport?.sessionId === sessionId ? conversationSupport : null @@ -289,7 +314,7 @@ export function useStructuredAgentSessionOptions(args: { optionSnapshot, optionSurface, setStructuredOption, - takesSendOptions, + sendSource: { optionStateRef, pendingOptionsRef, optionView, takesSendOptions }, unavailable: hostCatalog.unavailable } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.test.tsx new file mode 100644 index 000000000000..25939e1ec825 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/structured-agent-session-launch-options', () => ({ + getStructuredAgentSessionLaunchSelection: () => null +})) + +import { CLAUDE_SESSION_OPTION_CATALOG } from '../../../../shared/agent-session-option-catalog-claude-codex' +import { + applyStructuredAgentSessionModelCatalog, + createStructuredAgentSessionOptionState +} from '../../../../shared/structured-agent-session-options' +import { structuredAgentSessionOptionView } from '../../../../shared/structured-agent-session-option-view' +import { useStructuredAgentSessionSendOptions } from './use-structured-agent-session-send-options' + +const levels = (...values: string[]) => values.map((value) => ({ value, label: value })) + +function sendOptionsFor( + takesSendOptions: boolean, + pending: Record, + seedOptions: Record | undefined = { model: 'claude-gone', effort: 'max' } +) { + const optionStateRef = { + current: applyStructuredAgentSessionModelCatalog( + createStructuredAgentSessionOptionState('claude', CLAUDE_SESSION_OPTION_CATALOG), + CLAUDE_SESSION_OPTION_CATALOG, + { + origin: 'probe', + fetchedAt: 1, + models: [ + { id: 'opus[1m]', label: 'Opus', isDefault: false, efforts: levels('low', 'xhigh') }, + { id: 'sonnet', label: 'Sonnet', isDefault: true, efforts: levels('low', 'high') } + ] + }, + { newLaunch: true } + ) + } + const { result } = renderHook(() => + useStructuredAgentSessionSendOptions( + 'session-1', + { kind: 'new', seedOptions, heldOptions: {} }, + { + optionStateRef, + pendingOptionsRef: { current: pending }, + optionView: (state, seed, held) => + structuredAgentSessionOptionView(state, seed, held, takesSendOptions), + takesSendOptions + } + ) + ) + return result.current +} + +describe('what each composer send carries', () => { + it('carries the shown model and effort to a host that takes them, on every send', () => { + const sendOptions = sendOptionsFor(true, {}) + expect(sendOptions()).toEqual({ model: 'sonnet', effort: 'high' }) + expect(sendOptions()).toEqual({ model: 'sonnet', effort: 'high' }) + }) + + it('carries the default the composer shows when nothing names a model', () => { + expect(sendOptionsFor(true, {}, undefined)()).toEqual({ model: 'sonnet', effort: 'high' }) + }) + + it('carries a pick still on its way, resolved as the picker shows it', () => { + expect(sendOptionsFor(true, { model: 'opus[1m]', effort: 'low' })()).toMatchObject({ + model: 'opus[1m]', + effort: 'low' + }) + // A gone model the pick names moves to the listed default, as the picker shows. + expect(sendOptionsFor(true, { model: 'claude-retired' })()).toMatchObject({ + model: 'sonnet' + }) + }) + + it('carries nothing to an older host', () => { + expect(sendOptionsFor(false, {})()).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts new file mode 100644 index 000000000000..1211e2c44f56 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-send-options.ts @@ -0,0 +1,34 @@ +import { useCallback } from 'react' +import { structuredAgentSessionOptionSnapshot } from '../../../../shared/structured-agent-session-options' +import { structuredAgentSessionSendSelection } from '../../../../shared/structured-agent-session-option-view' +import { getStructuredAgentSessionLaunchSelection } from '@/lib/structured-agent-session-launch-options' +import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisional-launch' +import type { useStructuredAgentSessionOptionState } from './use-structured-agent-session-option-state' + +/** What each send carries to a host that takes it: the model and effort the composer shows now. */ +export function useStructuredAgentSessionSendOptions( + sessionId: string, + launch: StructuredAgentSessionLaunchView | undefined, + source: Pick< + ReturnType, + 'optionStateRef' | 'pendingOptionsRef' | 'optionView' | 'takesSendOptions' + > +): () => Readonly> | undefined { + const { optionStateRef, optionView, pendingOptionsRef, takesSendOptions } = source + return useCallback(() => { + if (!takesSendOptions) { + return undefined + } + // Read, not rendered: a pick the launch just took is not in this render's props yet. + const selection = getStructuredAgentSessionLaunchSelection(sessionId) + // A pick on its way resolves as the picker shows it, so a gone model it names is replaced too. + const view = optionView(optionStateRef.current, selection?.seed ?? launch?.seedOptions, { + ...(selection?.held ?? launch?.heldOptions), + ...pendingOptionsRef.current + }) + return structuredAgentSessionSendSelection( + view.record.agent, + structuredAgentSessionOptionSnapshot(view) + ) + }, [launch, optionStateRef, optionView, pendingOptionsRef, sessionId, takesSendOptions]) +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.test.tsx index 4083682b3387..4cf3de1f9082 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.test.tsx @@ -16,9 +16,44 @@ const QUEUEING = { capability: 'supported', enabled: true } as const afterEach(() => { resetStructuredAgentSessionSendsForTests() - mocks.call.mockReset() }) +// The composer moves the reader only for a message the transcript draws as a bubble. +it.each([ + ['a text follow-up while the agent works', QUEUEING, true, [], 'queued'], + ['the same follow-up when the agent is idle', QUEUEING, false, [], true], + ['with the queue setting off', { capability: 'supported', enabled: false }, true, [], true], + [ + 'an image follow-up, which queues like text', + QUEUEING, + true, + [{ path: '/a.png', previewUri: '/a.png' }], + 'queued' + ] +] as const)( + 'answers whether %s waits as a queued card', + (_case, queue, isWorking, images, admission) => { + mocks.call.mockImplementation(() => new Promise(() => {})) + const { result } = renderHook(() => + useStructuredAgentSessionSends({ + sessionId: 'session-1', + target: { kind: 'local' }, + fence: 1, + submissions: [], + queuedMessageIds: [], + queue, + historyLoaded: false, + isWorking + }) + ) + let sent: boolean | 'queued' = false + act(() => { + sent = result.current.send('follow up', images) + }) + expect(sent).toBe(admission) + } +) + it('takes no second send while the first is out', () => { mocks.call.mockImplementation(() => new Promise(() => {})) const { result } = renderHook(() => diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts index d63abe7b4a99..8108b426fe8a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-sends.ts @@ -4,9 +4,6 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { StructuredAgentSessionAttachment } from '../../../../shared/structured-agent-session-send-mutation' -import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' -import { agentSessionExecutionOptionsFromSnapshot } from '../../../../shared/agent-session-execution-option-snapshot' -import { agentSessionExecutionOptionsRecord } from '../../../../shared/agent-session-execution-options' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { structuredAgentSessionQueueRequest, @@ -42,8 +39,8 @@ export function useStructuredAgentSessionSends(args: { isWorking?: boolean /** The chat reads Stopping: a send made now is drawn after the turn being stopped. */ stopping?: boolean - /** What the picker shows, which each send carries; absent to a host that takes no send options. */ - executionOptionSnapshot?: readonly SessionOptionDescriptor[] + /** The composer's model and effort each send carries; undefined to a host that takes none. */ + sendOptions?: () => Readonly> | undefined }) { const { cursor, @@ -52,11 +49,11 @@ export function useStructuredAgentSessionSends(args: { isWorking = false, queue, queuedMessageIds, + sendOptions, sessionId, stopping, submissions, - target, - executionOptionSnapshot + target } = args const subscribe = useCallback( (listener: () => void) => subscribeToStructuredAgentSessionPendingSends(sessionId, listener), @@ -101,19 +98,15 @@ export function useStructuredAgentSessionSends(args: { if (!text.trim() && attachments.length === 0) { return false } - const delivery = structuredAgentSessionQueueRequest(queue, attachments) - const executionOptions = executionOptionSnapshot - ? agentSessionExecutionOptionsFromSnapshot(executionOptionSnapshot) - : undefined + const delivery = structuredAgentSessionQueueRequest(queue) + const options = sendOptions?.() const sent = sendStructuredAgentSessionMessage({ sessionId, target, text, attachments, ...(delivery ? { delivery } : {}), - ...(executionOptions - ? { options: agentSessionExecutionOptionsRecord(executionOptions) } - : {}), + ...(options ? { options } : {}), ...(stopping ? { sentWhileStopping: true as const } : {}) }) // Refused while the chat's send is out: the text stays in the box. @@ -129,7 +122,7 @@ export function useStructuredAgentSessionSends(args: { ? 'queued' : true }, - [executionOptionSnapshot, isWorking, queue, queuedMessageIds, sessionId, stopping, target] + [isWorking, queue, queuedMessageIds, sendOptions, sessionId, stopping, target] ) return { 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 71cbf80bdf8a..f95cb15cbe7c 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 @@ -1,4 +1,5 @@ import { useMemo, useRef } from 'react' +import { useStructuredAgentSessionSendOptions } from './use-structured-agent-session-send-options' import { useStructuredAgentSessionSends } from './use-structured-agent-session-sends' import { useStructuredAgentSessionCommandWrite } from './use-structured-agent-session-command-write' import { structuredAgentSessionNewSendsQueue } from './structured-agent-session-queue-request' @@ -55,9 +56,6 @@ export function useStructuredAgentSession(args: { providerStarting?: boolean /** The host runs the session's provider and its start is proven. */ providerRunning?: boolean - /** The current selection in a capable host's status, including an empty selection. */ - optionSelection?: Readonly> - hostPermissionMode?: string /** This view started the session; only then does the stored selection name what it runs. */ launch?: StructuredAgentSessionLaunchView /** The composer Edit copies a card's text into. */ @@ -108,7 +106,7 @@ export function useStructuredAgentSession(args: { optionSnapshot, optionSurface, setStructuredOption, - takesSendOptions, + sendSource, unavailable, threadGoal: threadGoalSupport, reportedThreadGoal, @@ -123,8 +121,6 @@ export function useStructuredAgentSession(args: { isVisible, providerVisible, providerStarting, - optionSelection: args.optionSelection, - hostPermissionMode: args.hostPermissionMode, ...(args.providerRunning ? { providerRunning: true } : {}), ...(args.hostOptionStamp ? { hostOptionStamp: args.hostOptionStamp } : {}), fence: state.fence, @@ -133,6 +129,7 @@ export function useStructuredAgentSession(args: { mutate, ...(launch ? { launch } : {}) }) + const sendOptions = useStructuredAgentSessionSendOptions(sessionId, launch, sendSource) // Only a capable host may see `delivery`; a card any host publishes shows, with its actions. const queueCapability = useStructuredAgentSessionHostQueuesMessagesState(target) const queuedMessageIds = useMemo( @@ -164,8 +161,7 @@ export function useStructuredAgentSession(args: { historyLoaded: transportEnabled && state.status === 'ready', isWorking: transportState.isWorking, stopping: stopControl.stopping, - // Every send carries what the picker shows, to a host that keeps it with the message. - executionOptionSnapshot: takesSendOptions ? optionSnapshot : undefined + sendOptions }) const contextUsage = useStructuredAgentSessionContextUsage( diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts index 45e359785337..74d4a7b663fd 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts @@ -406,7 +406,7 @@ describe('the first message owned by create', () => { 'agentSession.create', 'agentSession.setOption' ]) - option.resolve({ ok: true, fence: 1, value: { selected: 'later-pick' } }) + option.resolve({ ok: true, value: { selected: 'later-pick' } }) }) it('recovers a reload by replaying the frozen create when the old row is outside history', async () => { diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts index efbc9d2804b2..3535c9ceb0d0 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts @@ -11,7 +11,7 @@ import { structuredAgentSessionSendSelection } from '../../../shared/structured-agent-session-option-view' import { structuredAgentSessionOptionSnapshot } from '../../../shared/structured-agent-session-options' -import { firstFrameOptionState } from '@/components/native-chat/structured-agent-session-first-frame-options' +import { firstFrameOptionState } from '@/components/native-chat/use-structured-agent-session-option-state' import { applyStructuredLaunchHeldOptions, settleStructuredLaunchCreateOptions @@ -83,7 +83,7 @@ export function publishStructuredLaunchCreateOptions( if (state.intent.createMessageSupport !== true) { return applyStructuredLaunchHeldOptions(state, receipt) } - settleStructuredLaunchCreateOptions(state, state.intent.params.options ?? {}, receipt.fence) + settleStructuredLaunchCreateOptions(state, state.intent.params.options ?? {}) void applyStructuredLaunchHeldOptions(state, receipt).catch((error: unknown) => { console.warn('[native-chat] applying a launch option failed', error) }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts index b991ad022a37..8d700ba9227f 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts @@ -69,7 +69,6 @@ import { startStructuredAgentLaunch } from './structured-agent-session-launch' import { - getStructuredAgentSessionLaunchOptionToken, getStructuredAgentSessionLaunchSelection, holdStructuredAgentSessionLaunchOption } from './structured-agent-session-launch-options' @@ -131,7 +130,7 @@ function deferred(): Deferred { return { promise, resolve } } -const ACCEPTED = { ok: true, fence: 1, value: { key: 'model', value: 'x' } } +const ACCEPTED = { ok: true, value: { key: 'model', value: 'x' } } function mutations(): { method: string; fence?: unknown; key?: unknown; value?: unknown }[] { return mocks.call.mock.calls @@ -207,17 +206,15 @@ describe('picks made while a chat launches', () => { ]) setOptionReplies[0]!.resolve({ ok: true, - fence: 7, value: { key: 'model', value: 'gpt-picked', options: { model: 'gpt-picked' } } }) await expect(modelPick).resolves.toEqual({ kind: 'accepted', - fence: 7, options: { model: 'gpt-picked' } }) await settle() expect(lifecycle()).toBe('pending') - setOptionReplies[1]!.resolve({ ...ACCEPTED, fence: 7 }) + setOptionReplies[1]!.resolve(ACCEPTED) await launch.promptDeliveryResult expect(mutations()).toEqual([ @@ -352,23 +349,17 @@ describe('picks made while a chat launches', () => { startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-7' }) holdStructuredAgentSessionLaunchOption(SESSION_ID, 'effort', 'high') await settle() - const token = getStructuredAgentSessionLaunchOptionToken(SESSION_ID, 'effort') const seen: unknown[] = [] subscribeStructuredAgentLaunchStatus(() => seen.push(getStructuredAgentSessionLaunchSelection(SESSION_ID)) ) setOptionReplies[0]!.resolve({ ok: true, - fence: 1, value: { key: 'effort', value: 'high', options: { model: 'gpt-seeded', effort: 'high' } } }) await settle() - expect(seen[0]).toEqual({ - seed: { model: 'gpt-seeded', effort: 'high' }, - held: {}, - accepted: { options: { model: 'gpt-seeded', effort: 'high' }, fence: 1, token } - }) + expect(seen[0]).toEqual({ seed: { model: 'gpt-seeded', effort: 'high' }, held: {} }) expect(lifecycle()).toBeNull() }) }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index 8306c87bc81e..0b18e0c253ce 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -35,20 +35,13 @@ export type StructuredLaunchSelection = { seed?: Readonly> /** Encoded picks made before publish; the launch applies them before it publishes. */ held: Readonly> - accepted?: StructuredLaunchAcceptedOptions } export type StructuredLaunchOptionOutcome = - | { kind: 'accepted'; options: Readonly>; fence: number } + | { kind: 'accepted'; options: Readonly> } | { kind: 'refused'; failure: AgentSessionWriteFailure } | { kind: 'superseded' } -export type StructuredLaunchAcceptedOptions = { - options: Readonly> - fence: number - token: object -} - type OptionReply = (outcome: StructuredLaunchOptionOutcome) => void const optionReplies = new WeakMap>() @@ -117,7 +110,7 @@ async function setLaunchOption( ...fields }) return result.ok - ? { kind: 'accepted', options: result.value.options ?? { [key]: value }, fence: result.fence } + ? { kind: 'accepted', options: result.value.options ?? { [key]: value } } : { kind: 'refused', failure: agentSessionRefusalFailure(result.refusal) } } catch (error) { return { @@ -142,17 +135,7 @@ function settleHeldOption( } = state.selection // A pick made while this one was in flight superseded it and stays held for the next pass. const settled = current === encoded - const token = settled ? repliesFor(state).get(id) : undefined - const accepted = - outcome.kind === 'accepted' && token - ? { - options: { ...state.selection.accepted?.options, ...outcome.options }, - fence: outcome.fence, - token - } - : state.selection.accepted state.selection = { - accepted, seed: outcome.kind === 'accepted' ? { ...seed, ...outcome.options } : seed, held: settled ? rest : state.selection.held } @@ -168,11 +151,10 @@ function settleHeldOption( /** Selections included in the durable create are already the host's option intent. */ export function settleStructuredLaunchCreateOptions( state: StructuredLaunchState, - options: Readonly>, - fence: number + options: Readonly> ): void { for (const [id, encoded] of Object.entries(options)) { - settleHeldOption(state, id, encoded, { kind: 'accepted', options, fence }) + settleHeldOption(state, id, encoded, { kind: 'accepted', options }) } } @@ -206,14 +188,6 @@ export async function applyStructuredLaunchHeldOptions( } } -export function getStructuredAgentSessionLaunchOptionToken( - sessionId: string, - id: string -): object | undefined { - const state = getStructuredLaunchStateBySessionId(sessionId) - return state ? optionReplies.get(state)?.get(id) : undefined -} - export function getStructuredAgentSessionLaunchSelection( sessionId: string ): StructuredLaunchSelection | null { diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts index 1f65f9da0334..de1617cdb1b6 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.test.ts @@ -131,13 +131,11 @@ describe('structured agent session status feed', () => { await vi.advanceTimersByTimeAsync(0) hostEmit()({ type: 'snapshot', sessions: [summary('one'), summary('two')] }) expect(feed.getSessionObservation('one')).toBe('live') - expect(feed.getSnapshotSample('one')).toBe(1) await vi.advanceTimersByTimeAsync(31 * 60_000) expect(feed.getSessionObservation('one')).toBe('live') expect(mocks.subscribeStatus).toHaveBeenCalledOnce() hostEmit()({ type: 'end' }) expect(feed.getSessionObservation('one')).toBe('unverifiable') - expect(feed.getSnapshotSample('one')).toBe(1) hostEmit()({ type: 'status', session: summary('one') }) expect(feed.getSessionObservation('one')).toBe('unverifiable') await vi.advanceTimersByTimeAsync(300) @@ -147,9 +145,6 @@ describe('structured agent session status feed', () => { expect(feed.getSnapshot().has('one')).toBe(true) hostEmit(1)({ type: 'status', session: summary('one') }) expect(feed.getSessionObservation('one')).toBe('live') - expect(feed.getSnapshotSample('one')).toBe(1) - hostEmit(1)({ type: 'snapshot', sessions: [summary('one')] }) - expect(feed.getSnapshotSample('one')).toBe(2) deactivate() expect(feed.getSessionObservation('one')).toBe('unverifiable') expect(vi.getTimerCount()).toBe(0) diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.ts index 5cd77c945054..82b741d2b59f 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.ts @@ -20,7 +20,6 @@ export type StructuredAgentSessionStatusSnapshot = AgentSessionStatusSnapshot export type StructuredAgentSessionStatusFeedOwner = { activate: () => () => void - getSnapshotSample: (sessionId: string) => number getSnapshot: () => StructuredAgentSessionStatusSnapshot getSessionObservation: (sessionId: string) => 'live' | 'unverifiable' subscribe: (listener: () => void) => () => void @@ -38,7 +37,6 @@ export function structuredAgentSessionStatusFeedKey(target: RuntimeClientTarget) } function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { - const snapshotSamples = new Map() let snapshot: StructuredAgentSessionStatusSnapshot = new Map() const confirmedSessions = new Set() const listeners = new Set<() => void>() @@ -61,7 +59,6 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { if (event.type === 'snapshot') { reconnectAttempt = 0 for (const session of event.sessions) { - snapshotSamples.set(session.sessionId, (snapshotSamples.get(session.sessionId) ?? 0) + 1) confirmedSessions.add(session.sessionId) } } else if (event.type === 'status') { @@ -200,7 +197,6 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { } } }, - getSnapshotSample: (sessionId) => snapshotSamples.get(sessionId) ?? 0, getSnapshot: () => snapshot, getSessionObservation: (sessionId) => confirmedSessions.has(sessionId) ? 'live' : 'unverifiable', diff --git a/src/shared/agent-session-execution-option-snapshot.test.ts b/src/shared/agent-session-execution-option-snapshot.test.ts deleted file mode 100644 index 3c16f5c41d2b..000000000000 --- a/src/shared/agent-session-execution-option-snapshot.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { agentSessionExecutionOptionsFromSnapshot } from './agent-session-execution-option-snapshot' -import { structuredAgentSessionSeedCatalog } from './structured-agent-session-seed-catalog' -import { - applyStructuredAgentSessionModelCatalog, - commitStructuredAgentSessionOptionValues, - createStructuredAgentSessionOptionState, - structuredAgentSessionOptionSnapshot -} from './structured-agent-session-options' -import { CLAUDE_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-claude-codex' -import { structuredAgentSessionOptionView } from './structured-agent-session-option-view' - -describe('message choices while the picker loads', () => { - it.each(['codex', 'claude', 'pi'])('omits unknown %s choices', (agent) => { - const seed = structuredAgentSessionSeedCatalog(agent) - const state = createStructuredAgentSessionOptionState(agent, seed) - expect( - agentSessionExecutionOptionsFromSnapshot(structuredAgentSessionOptionSnapshot(state)) - ).toBeUndefined() - }) - - it.each([ - ['pi', 'anthropic/claude-sonnet-4-5'], - ['codex', 'some-unlisted-model'] - ])('omits %s choices until its saved model is listed', (agent, model) => { - const seed = structuredAgentSessionSeedCatalog(agent) - const state = commitStructuredAgentSessionOptionValues( - createStructuredAgentSessionOptionState(agent, seed), - { model, effort: 'high' } - ) - expect( - agentSessionExecutionOptionsFromSnapshot(structuredAgentSessionOptionSnapshot(state)) - ).toBeUndefined() - }) -}) - -describe('what a composer send carries', () => { - const levels = (...values: string[]) => values.map((value) => ({ value, label: value })) - function carried( - pending: Record, - seed: Record | undefined = { model: 'claude-gone', effort: 'max' } - ) { - const listed = applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState('claude', CLAUDE_SESSION_OPTION_CATALOG), - CLAUDE_SESSION_OPTION_CATALOG, - { - origin: 'probe', - fetchedAt: 1, - models: [ - { id: 'opus[1m]', label: 'Opus', isDefault: false, efforts: levels('low', 'xhigh') }, - { id: 'sonnet', label: 'Sonnet', isDefault: true, efforts: levels('low', 'high') } - ] - }, - { newLaunch: true } - ) - // A pick on its way is committed into the view the picker shows, as the option state does. - const state = commitStructuredAgentSessionOptionValues(listed, pending) - return agentSessionExecutionOptionsFromSnapshot( - structuredAgentSessionOptionSnapshot(structuredAgentSessionOptionView(state, seed, {}, true)) - ) - } - - it('carries the shown model and effort, a gone Claude model moved to the listed default', () => { - expect(carried({})).toMatchObject({ model: 'sonnet', effort: 'high' }) - }) - - it('carries the default the composer shows when nothing names a model', () => { - expect(carried({}, undefined)).toMatchObject({ model: 'sonnet', effort: 'high' }) - }) - - it('carries a pick still on its way, resolved as the picker shows it', () => { - expect(carried({ model: 'opus[1m]', effort: 'low' })).toMatchObject({ - model: 'opus[1m]', - effort: 'low' - }) - // A gone model the pick names moves to the listed default, its effort to the nearest offered. - expect(carried({ model: 'claude-retired', effort: 'max' })).toMatchObject({ - model: 'sonnet', - effort: 'high' - }) - }) -}) diff --git a/src/shared/agent-session-execution-option-snapshot.ts b/src/shared/agent-session-execution-option-snapshot.ts deleted file mode 100644 index d0ffac39a05f..000000000000 --- a/src/shared/agent-session-execution-option-snapshot.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { SessionOptionDescriptor } from './native-chat-session-options' -import type { AgentSessionExecutionOptions } from './agent-session-execution-options' - -/** What the picker displayed at Send, excluding launch-only permission. */ -export function agentSessionExecutionOptionsFromSnapshot( - snapshot: readonly SessionOptionDescriptor[] -): AgentSessionExecutionOptions | undefined { - const choices = ['model', 'effort', 'personality', 'fastMode', 'serviceTier'] - const descriptors = snapshot.filter( - (descriptor) => descriptor.transport === 'agent-session' && choices.includes(descriptor.id) - ) - if ( - descriptors.length === 0 || - descriptors.some((descriptor) => descriptor.valueSource === 'unknown') - ) { - return undefined - } - const selected: Record = {} - for (const descriptor of descriptors) { - const value = descriptor.kind.currentValue - if (value !== undefined) { - selected[descriptor.id] = String(value) - } - } - return selected -} diff --git a/src/shared/agent-session-finished-model-listing.ts b/src/shared/agent-session-finished-model-listing.ts deleted file mode 100644 index 89bf8ac0d68b..000000000000 --- a/src/shared/agent-session-finished-model-listing.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { AgentSessionModelCatalogResult } from './agent-session-wire' - -function isListedModelRow(row: unknown): row is { id: string } { - return ( - typeof row === 'object' && - row !== null && - 'id' in row && - typeof row.id === 'string' && - 'efforts' in row && - Array.isArray(row.efforts) - ) -} - -/** The ids a finished listing names; undefined for one still running, failed, empty or malformed. */ -export function finishedModelListingIds( - catalog: AgentSessionModelCatalogResult -): string[] | undefined { - const rows: unknown = catalog.origin === 'unknown' ? null : catalog.models - return Array.isArray(rows) && - rows.length > 0 && - rows.every(isListedModelRow) && - !catalog.listingInProgress && - !catalog.unavailable - ? rows.map((row) => row.id) - : undefined -} diff --git a/src/shared/agent-session-options-result.ts b/src/shared/agent-session-options-result.ts index 68fd84d072a3..1064d95daf59 100644 --- a/src/shared/agent-session-options-result.ts +++ b/src/shared/agent-session-options-result.ts @@ -28,9 +28,9 @@ export type AgentSessionOptionsResult = { /** This session takes image attachments. Absent = unknown (an older host). */ acceptsImages?: boolean models: AgentSessionModelOption[] - /** False when no host catalog has loaded; models may contain built-in fallbacks. - * Absent keeps older hosts' authoritative options-list behavior. */ - modelsKnown?: boolean + /** The `models` ids the agent's own finished listing names, excluding host-added rows and + * unverified launch-only rows. Absent: nothing verified, as from an older host. */ + listedModelIds?: string[] /** Session/account/transport support. Absent means unknown, never unsupported. */ fastModeSupport?: AgentSessionFastModeSupport current: { diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 88df9d547c86..d84ec4e76003 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -243,8 +243,6 @@ export type AgentSessionStatusSummary = { model?: string /** The chat's saved permission mode; a client re-reads options when it or `model` moves. */ permissionMode?: string - /** The host's saved option selection, including an empty selection. Absent on older hosts. */ - optionSelection?: Readonly> /** The tool the running turn is inside, else the last one it used. Absent unless `status` * is 'working'. */ toolName?: string diff --git a/src/shared/structured-agent-session-option-view.test.ts b/src/shared/structured-agent-session-option-view.test.ts index bc96919a1c88..1e0eea292b54 100644 --- a/src/shared/structured-agent-session-option-view.test.ts +++ b/src/shared/structured-agent-session-option-view.test.ts @@ -4,7 +4,6 @@ import type { AgentSessionModelCatalogResult } from './agent-session-wire' import { applyStructuredAgentSessionModelCatalog, applyStructuredAgentSessionOptions, - canSetStructuredAgentSessionOption, createStructuredAgentSessionOptionState, structuredAgentSessionOptionSnapshot, type StructuredAgentSessionOptionState @@ -29,10 +28,12 @@ const withHostList = (state = fresh(), catalog: AgentSessionModelCatalogResult = applyStructuredAgentSessionModelCatalog(state, CLAUDE_SESSION_OPTION_CATALOG, catalog, { newLaunch: true }) -// A stopped chat's own answer lists the host's rows plus its own model, named by its id. -const stopped = (model: string, rows = [OPUS]) => +// A chat's own answer lists its listing's rows plus its own model, named by its id; `listed` are +// the ids its listing names (none from an older host). +const stopped = (model: string, rows = [OPUS], listed = rows.map((row) => row.id)) => applyStructuredAgentSessionOptions(fresh(), CLAUDE_SESSION_OPTION_CATALOG, { models: [...rows, { id: model, label: model, isDefault: false, efforts: [] }], + listedModelIds: listed, current: { model, effort: 'high' } }) @@ -81,8 +82,8 @@ describe('a Claude model the verified list no longer offers', () => { }) }) - it('moves a model only the session itself lists, as the listing does not name it', () => { - const live = withHostList(stopped('sonnet', [OPUS, SONNET])) + it('moves a model only the session itself lists, as its listing does not name it', () => { + const live = stopped('sonnet', [OPUS, SONNET], ['opus[1m]']) expect(structuredAgentSessionListedPicks(live, undefined, {})).toEqual({ model: 'opus[1m]', effort: 'xhigh' @@ -101,13 +102,6 @@ describe('a Claude model the verified list no longer offers', () => { ])('keeps the model while the list is %s', (_name, catalog) => { const state = withHostList(fresh(), catalog) expect(structuredAgentSessionListedPicks(state, remembered, {})).toBeNull() - expect( - structuredAgentSessionListedPicks( - withHostList(stopped('claude-gone'), catalog), - undefined, - {} - ) - ).toBeNull() }) it('takes no row the finished listing does not name, as from a built-in fallback list', () => { @@ -133,15 +127,17 @@ describe('a Claude model the verified list no longer offers', () => { ['a row with no efforts', [{ id: 'opus[1m]', label: 'Opus', isDefault: false }]], ['no rows', []] ])('keeps the model when the host answer has %s', (_name, models) => { - const live = stopped('claude-gone') // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a malformed host answer is the case under test. const answer = { origin: 'probe', fetchedAt: 1, models } as AgentSessionModelCatalogResult - expect(structuredAgentSessionListedPicks(withHostList(live, answer), undefined, {})).toBeNull() + const state = withHostList(stopped('claude-gone', [OPUS], []), answer) + expect(structuredAgentSessionListedPicks(state, undefined, {})).toBeNull() }) - it('keeps the model before any host answer', () => { + it('keeps the model before any host answer, and on an answer that verifies no row', () => { expect(structuredAgentSessionListedPicks(fresh(), remembered, {})).toBeNull() - expect(structuredAgentSessionListedPicks(stopped('claude-gone'), undefined, {})).toBeNull() + // An older host's answer names no listed ids. + const unverified = withHostList(stopped('claude-gone', [OPUS], [])) + expect(structuredAgentSessionListedPicks(unverified, undefined, {})).toBeNull() }) }) @@ -172,10 +168,26 @@ describe('what a send carries', () => { const named = { ...SONNET, isDefault: true } const listing = { ...LISTED, models: [OPUS, named], listingNamesConfiguredModel: true } expect(carried(withHostList(fresh(), listing))).toEqual({ model: 'sonnet' }) - const listing2: AgentSessionModelCatalogResult = { ...LISTED, listingInProgress: true } - expect(carried(withHostList(stopped('claude-gone'), listing2))).toEqual({ - model: 'claude-gone' - }) + expect(carried(stopped('claude-gone', [OPUS], []))).toEqual({ model: 'claude-gone' }) + }) + + it("keeps a model the chat's own listing names, whichever CLI version wrote the host's", () => { + // Claude Code 2.1.280 lists opus[1m] as its default and no plain opus; 2.1.302 the reverse. + const row = (id: string, isDefault = false) => ({ ...SONNET, id, label: id, isDefault }) + const cli280 = [row('opus[1m]', true), row('sonnet'), row('sonnet[1m]'), row('haiku')] + const cli302 = [row('opus', true), row('sonnet'), row('haiku')] + const sent = (child: typeof cli280, model: string, host: typeof cli280) => + carried(withHostList(stopped(model, child), { ...LISTED, models: host }))?.model + expect(sent(cli280, 'opus[1m]', cli302)).toBe('opus[1m]') + expect(sent(cli302, 'opus', cli280)).toBe('opus') + // A model the chat's own CLI no longer lists still moves to that CLI's default. + expect(sent(cli302, 'opus[1m]', cli280)).toBe('opus') + // An older host names no listed ids: only the chat's own default, where the host lists it. + const older = (child: typeof cli280, model: string, host: typeof cli280) => + carried(withHostList(stopped(model, child, []), { ...LISTED, models: host }))?.model + expect(older(cli280, 'opus[1m]', cli302)).toBe('opus[1m]') + expect(older(cli302, 'opus', cli280)).toBe('opus') + expect(older(cli302, 'opus[1m]', cli302)).toBe('opus') }) it('resolves a pick on its way as the picker shows it, a gone model it names included', () => { @@ -218,36 +230,3 @@ describe('nearestAgentEffort', () => { expect(nearestAgentEffort('turbo', ['low', 'high'])).toBeUndefined() }) }) - -it('offers, validates and snapshots the status model without carrying another model’s observation', () => { - const catalog = CLAUDE_SESSION_OPTION_CATALOG - const state = applyStructuredAgentSessionOptions( - fresh(), - catalog, - { - models: [SONNET, OPUS], - current: { model: 'sonnet', effort: 'high', permissionMode: 'ask' } - }, - true - ) - const view = structuredAgentSessionOptionView(state, undefined, {}, true, { - selection: { model: 'opus[1m]' } - }) - expect(shown(state, undefined, {}, true, { selection: { model: 'opus[1m]' } })).toEqual({ - model: 'opus[1m]', - effort: undefined - }) - expect(canSetStructuredAgentSessionOption(view, 'effort', 'xhigh')).toBe(true) - const picked = structuredAgentSessionOptionView( - { ...state, pendingPick: { id: 'effort', value: 'xhigh' } }, - undefined, - {}, - true, - { selection: { model: 'opus[1m]' } } - ) - expect( - structuredAgentSessionSendSelection('claude', structuredAgentSessionOptionSnapshot(picked)) - ).toEqual({ model: 'opus[1m]', effort: 'xhigh' }) - expect(state.record.model).toEqual({ value: 'sonnet', source: 'reported' }) - expect(state.record.valuesByModel['opus[1m]']).toBeUndefined() -}) diff --git a/src/shared/structured-agent-session-option-view.ts b/src/shared/structured-agent-session-option-view.ts index 82d566ba191f..c049a1c1deb5 100644 --- a/src/shared/structured-agent-session-option-view.ts +++ b/src/shared/structured-agent-session-option-view.ts @@ -7,22 +7,6 @@ import type { SessionOptionDescriptor } from './native-chat-session-options' import { cloneNativeChatSessionOptionRecord } from './native-chat-session-option-state' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from './agent-chat-permission-mode' -export function acceptStructuredAgentSessionOptionValues( - state: StructuredAgentSessionOptionState, - values: Readonly>, - predecessor?: Readonly> -): StructuredAgentSessionOptionState { - return { - ...commitStructuredAgentSessionOptionValues(state, { - ...(state.reconciliation ? {} : predecessor), - ...values - }), - reconciliation: { - permission: state.reconciliation?.permission === true || values.permissionMode !== undefined - } - } -} - function catalogListsSeedModel( state: StructuredAgentSessionOptionState, seed: Readonly> @@ -45,28 +29,8 @@ export function structuredAgentSessionOptionView( state: StructuredAgentSessionOptionState, seed: Readonly> | undefined, held: Readonly>, - takesSendOptions: boolean, - host?: { selection?: Readonly>; permissionMode?: string } + takesSendOptions: boolean ): StructuredAgentSessionOptionState { - const selection = state.reconciliation || state.executionUnknown ? undefined : host?.selection - const pending = state.pendingPick - if (selection !== undefined || pending) { - state = { ...state, record: cloneNativeChatSessionOptionRecord(state.record) } - if (selection !== undefined) { - state = commitStructuredAgentSessionOptionValues(state, selection) - } - if (pending) { - state = commitStructuredAgentSessionOptionValues(state, { [pending.id]: pending.value }) - } - state = { ...state, pendingPick: pending } - } - if ( - !state.reconciliation?.permission && - host?.permissionMode !== undefined && - pending?.id !== AGENT_CHAT_PERMISSION_MODE_OPTION_ID - ) { - state = { ...state, permissionMode: host.permissionMode } - } const { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: heldPermission, ...heldSelection } = held const replaced = takesSendOptions ? structuredAgentSessionListedPicks(state, seed, heldSelection) @@ -118,7 +82,7 @@ function modelOptionView( if (seeded) { view = commitStructuredAgentSessionOptionValues(view, seed) } - return { ...commitStructuredAgentSessionOptionValues(view, held), pendingPick: state.pendingPick } + return { ...commitStructuredAgentSessionOptionValues(view, held), pendingId: state.pendingId } } const EFFORT_RANK = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] @@ -141,19 +105,24 @@ export function nearestAgentEffort( .sort((a, b) => distance(a) - distance(b) || rank(b) - rank(a))[0] } -/** What a send moves a Claude chat to when the host's finished listing does not name its model - * (held, else recorded, else seeded): the listed row marked default, else the first, with the +/** What a send moves a Claude chat to when the finished listing the picker shows does not name its + * model (held, else recorded, else seeded): that listing's default row, else its first, with the * effort moved to the nearest level offered. Null keeps the model. */ export function structuredAgentSessionListedPicks( state: StructuredAgentSessionOptionState, seed: Readonly> | undefined, held: Readonly> ): Record | null { - const { catalog, catalogSource, record, verifiedModelIds: verified = [] } = state + const { catalog, catalogSource, record, verifiedModelIds: own } = state const tracked = record.model?.value const model = held.model ?? (typeof tracked === 'string' ? tracked : seed?.model) - // Only the listing proves a row: the session's own unlisted model is shown but never listed. - const rows = catalog?.models.filter((row) => verified.includes(row.id)) ?? [] + // Only the listing proves a row: the session's own unlisted model is shown but never listed. An + // older host's answer proves none, so only its own default may stand in, where the host lists it. + const verified = own ?? state.hostListedModelIds ?? [] + const rows = + catalog?.models.filter( + (row) => verified.includes(row.id) && (own !== undefined || row.isDefault) + ) ?? [] if ( record.agent !== 'claude' || (catalogSource !== 'host' && catalogSource !== 'live') || diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 8d246f957372..943f6d9ad904 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -1,6 +1,5 @@ import type { AgentSessionOptionCatalog, CatalogModel } from './agent-session-option-catalog' import { discoveredModel } from './structured-agent-session-discovered-model' -import { finishedModelListingIds } from './agent-session-finished-model-listing' import { buildNativeChatSessionOptionSnapshot, resolveEffectiveNativeChatModelId, @@ -53,16 +52,16 @@ export type StructuredAgentSessionOptionState = { * built-in list before the host answered (shown as the quiet placeholder); `builtin` is the * same list once the host said it has none, usable but naming nothing the host may replace. */ catalogSource: 'seed' | 'builtin' | 'host' | 'live' | null - /** The models the host's latest answer names when it is a finished, well-formed listing: the - * only rows that can stand in for a model it lacks, whatever list the picker shows. */ + /** The rows of `catalog` its own finished listing names: the only ones that can stand in for a + * model it lacks. Never another listing's, which may come from another CLI version. */ verifiedModelIds?: readonly string[] + /** What the host's finished account list names, for a live answer from an older host that + * names no listed ids of its own. */ + hostListedModelIds?: readonly string[] record: NativeChatSessionOptionRecord /** The permission mode the host or launch last stated; null shows no picker. */ permissionMode: string | null - /** Accepted or read-back facts supersede the preceding cached status. */ - reconciliation?: { permission: boolean } - executionUnknown?: boolean - pendingPick: { id: string; value: string } | null + pendingId: string | null } /** With `seedCatalog`, the state holds the static seed from the first frame, but the picker shows @@ -76,7 +75,7 @@ export function createStructuredAgentSessionOptionState( catalogSource: seedCatalog ? 'seed' : null, record: createNativeChatSessionOptionRecord(agent), permissionMode: null, - pendingPick: null + pendingId: null } } @@ -85,24 +84,50 @@ export function createStructuredAgentSessionOptionState( export function settleStructuredAgentSessionBuiltinCatalog( state: StructuredAgentSessionOptionState ): StructuredAgentSessionOptionState { - const settled = withVerifiedModelIds(state, undefined) + const settled = withHostListedModelIds(state, undefined) return settled.catalogSource === 'seed' ? { ...settled, catalogSource: 'builtin' } : settled } -function withVerifiedModelIds( +/** The host list's ids, which verify the catalog only while the host's list is what it shows. */ +function withHostListedModelIds( state: StructuredAgentSessionOptionState, ids: readonly string[] | undefined ): StructuredAgentSessionOptionState { - return String(state.verifiedModelIds) === String(ids) + const verified = state.catalogSource === 'live' ? state.verifiedModelIds : ids + return String(state.hostListedModelIds) === String(ids) && + String(state.verifiedModelIds) === String(verified) ? state - : { ...state, verifiedModelIds: ids } + : { ...state, verifiedModelIds: verified, hostListedModelIds: ids } +} + +function isListedModelRow(row: unknown): row is { id: string } { + return ( + typeof row === 'object' && + row !== null && + 'id' in row && + typeof row.id === 'string' && + 'efforts' in row && + Array.isArray(row.efforts) + ) +} + +/** The ids a finished listing names; undefined for one still running, failed, empty or malformed. */ +export function finishedListingIds(catalog: AgentSessionModelCatalogResult): string[] | undefined { + const rows: unknown = catalog.origin === 'unknown' ? null : catalog.models + return Array.isArray(rows) && + rows.length > 0 && + rows.every(isListedModelRow) && + !catalog.listingInProgress && + !catalog.unavailable + ? rows.map((row) => row.id) + : undefined } /** * Applies the host's stored model catalog: models only, no current selection * and no record writes, so nothing here reads as a committed value — the pick - * stays provisional until a live options result confirms it. A live catalog - * is never downgraded by this. + * stays provisional until a live options result confirms it. A live catalog, + * and the ids its own answer verified, are never replaced by this. */ export function applyStructuredAgentSessionModelCatalog( state: StructuredAgentSessionOptionState, @@ -110,9 +135,9 @@ export function applyStructuredAgentSessionModelCatalog( catalog: AgentSessionModelCatalogResult, options: { newLaunch: boolean } ): StructuredAgentSessionOptionState { - const verifiedModelIds = finishedModelListingIds(catalog) + const hostListedModelIds = finishedListingIds(catalog) if (state.catalogSource === 'live') { - return withVerifiedModelIds(state, verifiedModelIds) + return withHostListedModelIds(state, hostListedModelIds) } const models = catalog.origin === 'unknown' @@ -138,15 +163,15 @@ export function applyStructuredAgentSessionModelCatalog( ...(namesDefault ? { defaultModelIsCliDefault: true } : {}) }, catalogSource: 'host', - verifiedModelIds + verifiedModelIds: hostListedModelIds, + hostListedModelIds } } export function applyStructuredAgentSessionOptions( state: StructuredAgentSessionOptionState, seed: AgentSessionOptionCatalog, - result: AgentSessionOptionsResult, - observed = false + result: AgentSessionOptionsResult ): StructuredAgentSessionOptionState { if (result.current.fastMode === undefined) { clearTrackedSessionOption(state.record, result.current.model ?? null, 'fastMode') @@ -162,22 +187,14 @@ export function applyStructuredAgentSessionOptions( ...(result.current.fastMode !== undefined ? { fastMode: result.current.fastMode } : {}), ...(result.current.serviceTier ? { serviceTier: result.current.serviceTier } : {}) }, - observed ? undefined : (result.current.confirmed ?? []) + result.current.confirmed ?? [] ) return { ...state, - catalog: - result.modelsKnown === false - ? state.catalog - : structuredAgentSessionOptionCatalog(seed, result), - catalogSource: result.modelsKnown === false ? state.catalogSource : 'live', - reconciliation: state.reconciliation - ? { - permission: - result.current.permissionMode !== undefined || state.reconciliation?.permission === true - } - : undefined, - permissionMode: result.current.permissionMode ?? state.permissionMode + catalog: structuredAgentSessionOptionCatalog(seed, result), + catalogSource: 'live', + verifiedModelIds: result.listedModelIds?.length ? result.listedModelIds : undefined, + permissionMode: result.current.permissionMode ?? null } } @@ -206,11 +223,8 @@ function modelOptionSnapshot(state: StructuredAgentSessionOptionState): SessionO if (!state.catalog) { return [] } - // A built-in list can name a saved model it recognizes, never a guessed default. - if ( - state.catalogSource === 'seed' && - !state.catalog.models.some((model) => model.id === state.record.model?.value) - ) { + // Until the host answers, the built-in list would paint a label its list may replace. + if (state.catalogSource === 'seed') { return [PROVIDER_DEFAULT_MODEL_PLACEHOLDER] } const snapshot = buildNativeChatSessionOptionSnapshot({ @@ -246,8 +260,7 @@ export function canSetStructuredAgentSessionOption( const descriptor = structuredAgentSessionOptionSnapshot(state).find((entry) => entry.id === id) return Boolean( state.catalog && - (state.pendingPick === null || - (id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID && state.pendingPick.id === id)) && + state.pendingId !== id && ((typeof value === 'string' && descriptor?.kind.type === 'select' && descriptor.kind.choices.some((choice) => choice.value === value)) || @@ -261,7 +274,7 @@ export function commitStructuredAgentSessionOption( value: string ): StructuredAgentSessionOptionState { if (id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID) { - return { ...state, permissionMode: value, pendingPick: null } + return { ...state, permissionMode: value, pendingId: null } } if (!state.catalog) { return state @@ -273,16 +286,16 @@ export function commitStructuredAgentSessionOption( ) const decoded = decodeStructuredAgentSessionOptionValue(id, value) if (decoded === null) { - return { ...state, pendingPick: 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?.source === 'dispatched') { + if (effort) { setTrackedSessionOption(state.record, 'effort', effort.value, 'dispatched') } - return { ...state, pendingPick: null } + return { ...state, pendingId: null } } export function commitStructuredAgentSessionOptionValues( From 7b7e7535a9bb2ee0bfe6e4f6b1e4bafd634fbc64 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:18:23 -0700 Subject: [PATCH 116/127] Preserve provider output observation and typed startup fixtures --- config/scripts/vitest-sqlite-runtime-files.mjs | 1 + .../acp-structured-host-start-aborts.test.ts | 2 +- .../claude-structured-message-options.test.ts | 2 +- ...ctured-agent-session-attach-orchestration.ts | 2 +- .../managed-provider-process.ts | 17 +++++++++++++++++ 5 files changed, 21 insertions(+), 3 deletions(-) diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 1ab8e23b44af..18fabc46168c 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -26,6 +26,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/acp/acp-timeline-turn-failures.test.ts', 'src/main/acp/acp-structured-session-adapter.test.ts', 'src/main/acp/acp-structured-session-adapter-resume.test.ts', + 'src/main/acp/acp-structured-host-start-aborts.test.ts', 'src/main/acp/acp-conversation-copy.test.ts', 'src/main/pi/rpc-turn-races.test.ts', 'src/main/acp/acp-structured-child-session-end.test.ts', 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 a9f846455a10..d92f99d2dd60 100644 --- a/src/main/acp/acp-structured-host-start-aborts.test.ts +++ b/src/main/acp/acp-structured-host-start-aborts.test.ts @@ -35,7 +35,7 @@ function beforeAttachEnds(host: StructuredAgentSessionHost, atEnd: () => void): aborts.begin = (sessionId) => { const started = begin(sessionId) return { - signal: started.signal, + ...started, end: () => { if (!fired) { fired = true diff --git a/src/main/claude/claude-structured-message-options.test.ts b/src/main/claude/claude-structured-message-options.test.ts index 11d0918d2117..9a043e66da75 100644 --- a/src/main/claude/claude-structured-message-options.test.ts +++ b/src/main/claude/claude-structured-message-options.test.ts @@ -99,7 +99,7 @@ describe('Claude accepted message choices', () => { }) it('restores A after an applied unanswered B and an uncorrelated old init', async () => { - let actual = 'sonnet' + let actual: string | undefined = 'sonnet' const claude = fakeClaude({ initModels: MODELS, routes: { list_models: () => MODELS } }) const adapter = await acquired(claude) const connection = claude.connections[0]! 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 3b2b9e1ec4fb..4805f3c0ab94 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 @@ -117,7 +117,7 @@ export function attachStructuredAgentSession( result: await runAttach(context, callerKey, params, { ...options, recordPhase }) } }) - if ('result' in selected) { + if (selected.result !== undefined) { return selected.result } await selected.flight.outcome diff --git a/src/main/provider-process/managed-provider-process.ts b/src/main/provider-process/managed-provider-process.ts index 98e669d8e8dc..f77f4936e30a 100644 --- a/src/main/provider-process/managed-provider-process.ts +++ b/src/main/provider-process/managed-provider-process.ts @@ -215,3 +215,20 @@ export function spawnManagedProviderProcess( } } } + +/** Watches stdout only once its reader subscribes: a listener of our own would start the stream + * flowing and drop whatever arrived before a reader that subscribes late. */ +function observeReaderOutput( + stdout: Pick, + onOutput: () => void +): void { + const onSubscribe = (event: string | symbol): void => { + if (event !== 'data' && event !== 'readable') { + return + } + stdout.removeListener('newListener', onSubscribe) + // After the reader's own listener lands; no chunk can arrive before a microtask runs. + queueMicrotask(() => stdout.on('data', onOutput)) + } + stdout.on('newListener', onSubscribe) +} From 74c946e6224672f217a56799acc2062fba50a7e0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:55:42 -0700 Subject: [PATCH 117/127] fix: preserve initial Claude launch choices and effort readback --- .../claude-structured-session-options.ts | 20 ++++++++++++++++--- .../claude-structured-session-publication.ts | 5 ++++- .../claude-structured-session-startup.test.ts | 2 +- .../claude-structured-session-startup.ts | 4 ++-- ...ssion-claude-stop-exit-ends-record.test.ts | 4 +++- ...ctured-agent-session-forget-status.test.ts | 1 + .../structured-agent-session-options-read.ts | 8 +------- ...startup-unanswered-control-request.test.ts | 15 +++++++++++--- 8 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 70c6c448fa9a..1ca91be20d83 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -53,6 +53,22 @@ export function observeClaudeSettingsApplied(session: ClaudeSession, settings: u } } +/** Applied effort describes execution; effective effort describes the underlying settings. */ +export function observeClaudeSettingsExecutionEffort( + session: ClaudeSession, + settings: unknown +): void { + const applied = readClaudeSettingsApplied(settings) + const effort = applied ? applied.effort : readClaudeSettingsEffort(settings) + if (session.reusableOptions.get('effort') !== 'max') { + if (effort) { + session.reusableOptions.set('effort', effort) + } else if (applied) { + session.reusableOptions.delete('effort') + } + } +} + export function readClaudeSettingsFastMode(settings: unknown): boolean | null { const value = record(record(settings)?.effective)?.fastMode return typeof value === 'boolean' ? value : null @@ -236,10 +252,8 @@ function observeClaudeSettingsReadback( const fastMode = readClaudeSettingsFastMode(settings) const perSessionOptIn = readClaudeSettingsFastModePerSessionOptIn(settings) observeClaudeSettingsApplied(session, settings) + observeClaudeSettingsExecutionEffort(session, settings) if (effort) { - if (session.reusableOptions.get('effort') !== 'max') { - session.reusableOptions.set('effort', effort) - } session.reportedOptions.effort = effort } if (fastMode !== null) { diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index e7f8c3b877bd..17b6dfa62c91 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -63,7 +63,10 @@ export function createClaudeSessionPublication(input: { reportedOptions: {}, reportedModelMutation: 0, confirmedOptions: new Set(), - reusableOptions: new Map(), + // Only choices carried in this child's launch can skip their first write. + reusableOptions: new Map( + [...(input.options ?? [])].filter(([key]) => key === 'model' || key === 'effort') + ), launchedModel: null, fastModeAtStart: false, translator: input.translator, diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index d7b099eb0153..4dbb69d407f2 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -85,7 +85,7 @@ describe('Claude host-awaited open', () => { ) const connection = claude.connections[0]! expect(connection.calls.filter((call) => call.subtype === 'apply_flag_settings')).toEqual([]) - connection.getSettings = async () => ({ effective: { fastMode } }) + connection.getSettings = async () => ({ effective: { effortLevel: 'high', fastMode } }) await expect( adapter.dispatch({ sessionId: 'session-1', diff --git a/src/main/claude/claude-structured-session-startup.ts b/src/main/claude/claude-structured-session-startup.ts index a2937cab916a..87a35be71a75 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -21,6 +21,7 @@ import { claudeResolvedCatalogListing, claudeStructuredSessionOptionsFrom, observeClaudeSettingsApplied, + observeClaudeSettingsExecutionEffort, readClaudeSettingsApplied, readClaudeFastModeFacts, readClaudeSettingsEffort @@ -164,11 +165,11 @@ function applyClaudeSettingsFacts( session.events?.publish() return } + observeClaudeSettingsExecutionEffort(session, settings) // The readback vouches for a value the child was launched with only when it reports that value. const agrees = (key: string, reported: string): boolean => !session.options.has(key) || session.options.get(key) === reported if (effort) { - session.reusableOptions.set('effort', effort) session.reportedOptions.effort = effort if (agrees('effort', effort)) { session.confirmedOptions.add('effort') @@ -177,7 +178,6 @@ function applyClaudeSettingsFacts( // A launch `--effort` shows only in `applied`, never in `effective` (measured on 2.1.280). const launchedEffort = session.options.get('effort') if (launchedEffort !== undefined && session.appliedOptions?.effort === launchedEffort) { - session.reusableOptions.set('effort', launchedEffort) session.confirmedOptions.add('effort') } if (published.fastMode !== null) { 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 14adfb0f2b6b..454bcf0bf89e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-exit-ends-record.test.ts @@ -453,13 +453,15 @@ it('rejects a message whose start meets a close still unverifiable, and starts n it('saves an option change while the close stays unverifiable without writing to the old child', async () => { const connection = await stopWithUnprovenClose(2) + const calls = [...connection.calls] await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) expect(connection.closeCount).toBe(1) - expect(connection.calls.some((call) => call.subtype === 'set_model')).toBe(false) + expect(connection.calls).toEqual(calls) // The pick remains saved for a later start while the old child is still unverifiable. await expect(setModel('claude-opus-5')).resolves.toMatchObject({ ok: true }) + expect(connection.calls).toEqual(calls) expect(child()?.close).toBeDefined() expect(store.getRecord(SESSION)?.options).toMatchObject({ model: 'claude-opus-5' }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index 6fad454e993f..cf93b60c98f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -191,6 +191,7 @@ function attachContext( adoptEventSink: () => undefined, probeOwner: async () => ({ outcome: 'pid-absent' }), discardEventSink: () => undefined, + startFlights: new Map(), acquireAborts: new StructuredAgentSessionAcquireAborts() }, sessions, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index a0e4ab772624..2b6b86b8af5f 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 @@ -267,13 +267,7 @@ export async function readStructuredAgentSessionOptions( ...selected.current, ...options.current, ...(model !== undefined ? { model } : {}), - effort: - saved.effort ?? - (model === options.current.model - ? options.current.effort - : model === selected.current.model - ? selected.current.effort - : undefined), + effort: saved.effort ?? options.current.effort, fastMode: saved.fastMode !== undefined ? selected.current.fastMode 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 2aee258ac5ee..b16861c6b037 100644 --- a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts +++ b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts @@ -128,6 +128,7 @@ describe('a Claude start whose CLI answers initialize but not a control request' }) expect(claude.child(SESSION).launch.options).toMatchObject({ model: 'sonnet', + effort: 'high', permissionMode: 'acceptEdits' }) // The record keeps the saved choices through the start. @@ -139,8 +140,15 @@ describe('a Claude start whose CLI answers initialize but not a control request' expect(claude.child(SESSION).calls).not.toContain('set_model') expect(claude.child(SESSION).calls).not.toContain('set_permission_mode') + claude.child(SESSION).connection.getSettings = async () => ({ + applied: { effort: 'high' }, + effective: { effortLevel: 'low' } + }) + await host.readOptions(SESSION) await send(host, 'hello') await vi.waitFor(() => expect(claude.child(SESSION).calls).toContain('send')) + expect(claude.child(SESSION).calls).not.toContain('set_model') + expect(claude.child(SESSION).calls).not.toContain('apply_flag_settings') }) it('keeps the launched saved choice when the user later changes a different option', async () => { @@ -175,14 +183,14 @@ describe('a Claude start whose CLI answers initialize but not a control request' 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: undefined, confirmed: undefined }) + expect(await picker(host)).toEqual({ model: 'sonnet', effort: 'high', confirmed: ['effort'] }) // 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: 'sonnet', - effort: undefined, - confirmed: undefined + effort: 'high', + confirmed: ['effort'] }) expect(record(host)?.options).toEqual({ model: 'sonnet' }) @@ -224,6 +232,7 @@ describe('a Claude start whose CLI answers initialize but not a control request' expect(claude.child(SESSION).launch.options).toMatchObject({ sessionId: freshProviderSessionId, model: 'sonnet', + effort: 'high', permissionMode: 'acceptEdits' }) expect(claude.child(SESSION).launch.options.resume).toBeUndefined() From f9153ba48a2ebf0c6cca00a9503e98006970415c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:06:52 -0700 Subject: [PATCH 118/127] test: complete Claude catalog session fixture --- src/main/claude/claude-stream-json-connection.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 5466c5403556..4ab8d8550e52 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -558,6 +558,7 @@ describe('Claude stream-json connection', () => { const session = { connection, options: new Map(), + reusableOptions: new Map(), reportedOptions: {}, startup: { state: 'proven' } } as unknown as ClaudeSession From 556aca34d44090cb2cc8e59c5f1b9c119159a21b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:18:05 -0700 Subject: [PATCH 119/127] Preserve incoming main files after merge formatting --- .../ai-vault/ai-vault-local-scan-progress.ts | 4 +- .../ai-vault-scan-coordinator.test.ts | 5 +- .../ai-vault-scan-progress-flow.test.ts | 5 +- .../ai-vault/session-scanner-discovery.ts | 5 +- .../session-scanner-scope-walk.test.ts | 5 +- .../ai-vault/session-scanner-zcode-sources.ts | 7 +- src/main/ai-vault/session-scanner.ts | 6 +- src/main/ai-vault/session-scope-cwd.ts | 7 +- .../antigravity-acp-runtime-probe.ts | 4 +- .../structured-agent-session-handover.ts | 1 + ...tructured-agent-session-unfinished-work.ts | 16 +- .../structured-agent-cli-source.ts | 14 +- ...red-agent-session-source-admission.test.ts | 291 +++++------------- ...e-structured-idle-releases-unknown.test.ts | 4 +- .../structured-agent-launch-parity.test.ts | 11 +- .../AiVaultScanProgressStrip.test.tsx | 8 +- .../AiVaultScanProgressStrip.tsx | 7 +- .../right-sidebar/ai-vault-session-refresh.ts | 5 +- 18 files changed, 113 insertions(+), 292 deletions(-) diff --git a/src/main/ai-vault/ai-vault-local-scan-progress.ts b/src/main/ai-vault/ai-vault-local-scan-progress.ts index a84a9cc164a6..034a16303db3 100644 --- a/src/main/ai-vault/ai-vault-local-scan-progress.ts +++ b/src/main/ai-vault/ai-vault-local-scan-progress.ts @@ -59,7 +59,9 @@ export function startLocalAiVaultScanProgress( ): LocalAiVaultScanProgressHandle { const previous = latest.get(scanKey) const fallback = - previous?.stage === 'finished' ? previous : (currentScans.get(scanKey)?.fallback ?? null) + previous?.stage === 'finished' + ? previous + : (currentScans.get(scanKey)?.fallback ?? null) let current: AiVaultScanProgress = { scanId, stage: 'finding', diff --git a/src/main/ai-vault/ai-vault-scan-coordinator.test.ts b/src/main/ai-vault/ai-vault-scan-coordinator.test.ts index 61392a5e700d..209b4055f2c6 100644 --- a/src/main/ai-vault/ai-vault-scan-coordinator.test.ts +++ b/src/main/ai-vault/ai-vault-scan-coordinator.test.ts @@ -44,10 +44,7 @@ describe('AiVaultScanCoordinator', () => { }) }) await Promise.resolve() - expect(localAiVaultScanProgress('scope')).toMatchObject({ - stage: 'finding', - startedAt: 2_000 - }) + expect(localAiVaultScanProgress('scope')).toMatchObject({ stage: 'finding', startedAt: 2_000 }) expect(stages).not.toContain(null) finishPreparation?.(EMPTY_RESULT) await expect(Promise.all([first, replacement])).resolves.toEqual([EMPTY_RESULT, EMPTY_RESULT]) diff --git a/src/main/ai-vault/ai-vault-scan-progress-flow.test.ts b/src/main/ai-vault/ai-vault-scan-progress-flow.test.ts index 8f40cb10b6f3..729bdc1717eb 100644 --- a/src/main/ai-vault/ai-vault-scan-progress-flow.test.ts +++ b/src/main/ai-vault/ai-vault-scan-progress-flow.test.ts @@ -139,10 +139,7 @@ describe('Session History scan progress, scanner child to window', () => { // A failed refresh puts back the last finished scan rather than leaving the key empty. replacement.fail() - expect(localAiVaultScanProgress('scope')).toMatchObject({ - scanId: 'earlier', - stage: 'finished' - }) + expect(localAiVaultScanProgress('scope')).toMatchObject({ scanId: 'earlier', stage: 'finished' }) }) it('settles unfinished work when an interactive timeout retires the shared scanner', async () => { diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index 954a235ff339..799f208224fa 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -306,10 +306,7 @@ export async function forEachSessionFile( options.signal?.throwIfAborted() // Why: a gate refusal means the scan could not run, not that the tree is // empty — swallowing it would misreport a stalled distro as "no transcript". - if ( - error instanceof WslTranscriptFsError || - (options.strictErrors && !isMissingSidecarError(error)) - ) { + if (error instanceof WslTranscriptFsError || (options.strictErrors && !isMissingSidecarError(error))) { throw error } return diff --git a/src/main/ai-vault/session-scanner-scope-walk.test.ts b/src/main/ai-vault/session-scanner-scope-walk.test.ts index 81e3f730a978..90852c4b9243 100644 --- a/src/main/ai-vault/session-scanner-scope-walk.test.ts +++ b/src/main/ai-vault/session-scanner-scope-walk.test.ts @@ -220,10 +220,7 @@ it('uses a cold parsed latest Codex cwd after its unlisted row is evicted', asyn const scan = () => scanAiVaultSessions({ ...roots, platform: 'linux', limit: 1, scopePaths: ['/repo'] }) expect((await scan()).sessions.some((row) => row.filePath === path)).toBe(false) - const parsed = await parseAgentSessionFileCached( - { agent: 'codex', file, codexHome: null }, - 'linux' - ) + const parsed = await parseAgentSessionFileCached({ agent: 'codex', file, codexHome: null }, 'linux') expect(rows.get(path)?.session?.cwd).toBe('/repo') for (let index = 0; index <= MAX_UNLISTED_ENTRIES; index++) { storeSessionParseCacheEntry(`search-${index}`, { diff --git a/src/main/ai-vault/session-scanner-zcode-sources.ts b/src/main/ai-vault/session-scanner-zcode-sources.ts index b39f99229ae6..a980aa5aab46 100644 --- a/src/main/ai-vault/session-scanner-zcode-sources.ts +++ b/src/main/ai-vault/session-scanner-zcode-sources.ts @@ -34,12 +34,7 @@ export function zcodeDiscoveries( list(), scopeWalksAgentFiles('zcode', options.scopePaths) ? list(options.scopePaths) : undefined ]) - return { - agent: 'zcode' as const, - rootDir: dbPath, - files, - ...(scopeFiles ? { scopeFiles } : {}) - } + return { agent: 'zcode' as const, rootDir: dbPath, files, ...(scopeFiles ? { scopeFiles } : {}) } }) } diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index f6441d33f774..af415f5eddac 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -1,8 +1,4 @@ -import type { - AiVaultListResult, - AiVaultScanIssue, - AiVaultSession -} from '../../shared/ai-vault-types' +import type { AiVaultListResult, AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { withSpan } from '../observability/tracer' import { sessionSortTime } from './session-scanner-accumulator' diff --git a/src/main/ai-vault/session-scope-cwd.ts b/src/main/ai-vault/session-scope-cwd.ts index fad3287dc145..b8ae68b4dd7e 100644 --- a/src/main/ai-vault/session-scope-cwd.ts +++ b/src/main/ai-vault/session-scope-cwd.ts @@ -40,7 +40,8 @@ export const AI_VAULT_SCOPE_CWD_SOURCES: Record (record.type === 'session_meta' ? asRecord(record.payload)?.cwd : undefined), + cwdOf: (record) => + record.type === 'session_meta' ? asRecord(record.payload)?.cwd : undefined, // Its first record carries the base instructions (about 20 KB): one read, not two. firstReadBytes: 64 * 1024 }, @@ -101,9 +102,7 @@ export async function readTranscriptHeadCwd( const { cwdOf } = source let cwd: string | null = null const first = source.firstReadBytes ?? HEAD_READ_BYTES - for (const length of first < HEAD_WIDENED_READ_BYTES - ? [first, HEAD_WIDENED_READ_BYTES] - : [first]) { + for (const length of first < HEAD_WIDENED_READ_BYTES ? [first, HEAD_WIDENED_READ_BYTES] : [first]) { let head: Buffer try { head = await readTranscriptSlice(file.path, 0, length, 'scan', signal) diff --git a/src/main/antigravity/antigravity-acp-runtime-probe.ts b/src/main/antigravity/antigravity-acp-runtime-probe.ts index 1b913e9751da..f8baff94aa8f 100644 --- a/src/main/antigravity/antigravity-acp-runtime-probe.ts +++ b/src/main/antigravity/antigravity-acp-runtime-probe.ts @@ -6,7 +6,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { createAcpAgentConnection, type AcpAgentConnection } from '../acp/acp-agent-connection' import type { InitializeResponse } from '../acp/generated/acp-protocol.generated' -import { runAgentModelCatalogSession } from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner' +import { + runAgentModelCatalogSession +} from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner' import { ANTIGRAVITY_ACP_ENV, scrubAntigravityAcpEnvironment } from './antigravity-acp-environment' const PROBE_TIMEOUT_MS = 90_000 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts index 4f705d27397a..f9079f678dae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts @@ -74,6 +74,7 @@ export async function beginSubmissionHandover( const turnScope = contextStructuredAgentSessionCurrentWork(ctx).turnScope() const carried = carriedStoppedHistory(ctx, turnScope, submission, body) const release = ctx.deliveryOrder?.activateSend(ctx.sessionId, clientMessageId) + // The pending row precedes the adapter's own report of which sends it holds. const released = beginStructuredAgentSessionHandover( ctx.journal, clientMessageId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts index 9a90800184ac..f6e4705ee683 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unfinished-work.ts @@ -53,15 +53,13 @@ export function unfinishedStructuredAgentSessionWorkWasInterrupted( ): boolean { const currentSnapshot = journal.snapshot() if ( - journal - .submissions?.() - .some( - (send) => - heldSends?.has(send.clientMessageId) && - send.fence === exit?.ownerFence && - !isQueuedAgentJournalSubmission(send) && - (send.dispatchState === 'pending' || send.dispatchState === 'unknown') - ) || + journal.submissions?.().some( + (send) => + heldSends?.has(send.clientMessageId) && + send.fence === exit?.ownerFence && + !isQueuedAgentJournalSubmission(send) && + (send.dispatchState === 'pending' || send.dispatchState === 'unknown') + ) || currentSnapshot.items.some(isInProgressStructuredAgentSessionItem) ) { return true diff --git a/src/main/native-chat/structured-agent-cli-source.ts b/src/main/native-chat/structured-agent-cli-source.ts index 74842d0588a7..a0364d7c79d7 100644 --- a/src/main/native-chat/structured-agent-cli-source.ts +++ b/src/main/native-chat/structured-agent-cli-source.ts @@ -21,14 +21,12 @@ export function captureStructuredAgentCliSource( try { return Object.freeze({ rows: Object.freeze( - readRows().map((row) => - Object.freeze({ - ...row, - ...(row.providerSession - ? { providerSession: Object.freeze({ ...row.providerSession }) } - : {}) - }) - ) + readRows().map((row) => Object.freeze({ + ...row, + ...(row.providerSession + ? { providerSession: Object.freeze({ ...row.providerSession }) } + : {}) + })) ) }) } catch (error) { diff --git a/src/main/native-chat/structured-agent-session-source-admission.test.ts b/src/main/native-chat/structured-agent-session-source-admission.test.ts index 5b6880abd992..04fd387af202 100644 --- a/src/main/native-chat/structured-agent-session-source-admission.test.ts +++ b/src/main/native-chat/structured-agent-session-source-admission.test.ts @@ -31,74 +31,30 @@ async function file(path: string, records: unknown[]) { } function params(agent: StructuredAgentId, transcriptPath: string): AgentSessionAttachParams { return { - envelope: { - sessionId: 'new-chat', - clientOperationId: 'create', - expectedRuntimeFence: null, - payloadFingerprint: '' - }, - location: { - executionHostId: 'local', - workspaceId: 'folder', - workspaceKind: 'folder', - wslDistro: null - }, - agent, - provider: agent, - runtimeKind: 'native', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, - adopt: { - providerHandle: { agent, transport: 'terminal-transcript', nativeId: id }, - transcriptPath, - source: { providerSessionId: id, workspaceCwd: root } - } + envelope: { sessionId: 'new-chat', clientOperationId: 'create', expectedRuntimeFence: null, payloadFingerprint: '' }, + location: { executionHostId: 'local', workspaceId: 'folder', workspaceKind: 'folder', wslDistro: null }, + agent, provider: agent, runtimeKind: 'native', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, + adopt: { providerHandle: { agent, transport: 'terminal-transcript', nativeId: id }, transcriptPath, + source: { providerSessionId: id, workspaceCwd: root } } } } it('creates from the host hook path when Claude filename differs, then checks actual recorded cwd', async () => { const transcriptPath = await file(join(root, 'projects', 'folder', 'different-uuid.jsonl'), [ - { - type: 'user', - uuid: 'first', - sessionId: id, - cwd: root, - message: { role: 'user', content: 'hello' } - }, - { - type: 'assistant', - uuid: 'reply', - sessionId: id, - cwd: '/later-drift', - message: { role: 'assistant', content: 'reply' } - } + { type: 'user', uuid: 'first', sessionId: id, cwd: root, message: { role: 'user', content: 'hello' } }, + { type: 'assistant', uuid: 'reply', sessionId: id, cwd: '/later-drift', message: { role: 'assistant', content: 'reply' } } ]) - const sourcePath = () => - structuredAgentCliSourcePath({ - agent: 'claude', - workspaceId: 'folder', - resumeFrom: { - providerSessionId: id, - cliSource: { workspaceId: 'folder', paneKey: 'tab:leaf' } - }, - rows: [ - { - paneKey: 'tab:leaf', - worktreeId: 'folder', - agentType: 'claude', - state: 'waiting', - prompt: '', - connectionId: null, - receivedAt: 1, - stateStartedAt: 1, - providerSession: { key: 'session_id', id, transcriptPath } - } - ] - }) + const sourcePath = () => structuredAgentCliSourcePath({ + agent: 'claude', workspaceId: 'folder', resumeFrom: { providerSessionId: id, + cliSource: { workspaceId: 'folder', paneKey: 'tab:leaf' } }, + rows: [{ paneKey: 'tab:leaf', worktreeId: 'folder', agentType: 'claude', state: 'waiting', prompt: '', + connectionId: null, receivedAt: 1, stateStartedAt: 1, + providerSession: { key: 'session_id', id, transcriptPath } }] + }) const importer = CLAUDE_CLI_CONVERSATIONS.transcriptImport! const selectedAccountHome = { variable: 'CLAUDE_CONFIG_DIR', path: root } vi.spyOn(importer, 'accountHomeCandidates').mockReturnValue([ - selectedAccountHome, - { ...selectedAccountHome, path: join(root, 'locked-account') } + selectedAccountHome, { ...selectedAccountHome, path: join(root, 'locked-account') } ]) const admit = importer.admitTranscriptPath.bind(importer) const admission = vi.spyOn(importer, 'admitTranscriptPath').mockImplementation((args) => { @@ -109,62 +65,36 @@ it('creates from the host hook path when Claude filename differs, then checks ac }) const find = vi.spyOn(importer, 'findTranscript') const adoption = await resolveStructuredAgentSessionAdoptionForCreate({ - settings: {}, - agent: 'claude', - transcriptImport: importer, - providerSessionId: id, + settings: {}, agent: 'claude', transcriptImport: importer, providerSessionId: id, selectedAccountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, - launchEnv: {}, - workspaceCwd: root, - resolveTranscriptPath: sourcePath + launchEnv: {}, workspaceCwd: root, resolveTranscriptPath: sourcePath }) expect(adoption.transcriptPath).toBe(transcriptPath) expect(admission).toHaveBeenCalledOnce() expect(find).not.toHaveBeenCalled() - expect(await prepareAdoptedTranscript(params('claude', adoption.transcriptPath))).toMatchObject({ - ok: true - }) + expect(await prepareAdoptedTranscript(params('claude', adoption.transcriptPath))).toMatchObject({ ok: true }) const wrongDirectory = params('claude', adoption.transcriptPath) if (wrongDirectory.adopt?.source) { wrongDirectory.adopt.source.workspaceCwd = '/another-folder' } - expect(await prepareAdoptedTranscript(wrongDirectory)).toMatchObject({ - ok: false, - refusal: { details: { reason: 'transcriptUnreadable' } } - }) + expect(await prepareAdoptedTranscript(wrongDirectory)).toMatchObject({ ok: false, + refusal: { details: { reason: 'transcriptUnreadable' } } }) const foreign = await file(join(root, 'foreign.jsonl'), []) const escaped = process.platform === 'win32' ? foreign : join(root, 'projects', 'escaped.jsonl') if (process.platform !== 'win32') { await symlink(foreign, escaped) } - await expect( - resolveStructuredAgentSessionAdoptionForCreate({ - settings: {}, - agent: 'claude', - transcriptImport: importer, - providerSessionId: id, - selectedAccountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, - launchEnv: {}, - workspaceCwd: root, - resolveTranscriptPath: () => escaped - }) - ).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptUnreadable' } } }) - admission.mockRejectedValueOnce( - Object.assign(new Error('Selected account inaccessible'), { code: 'EACCES' }) - ) - await expect( - resolveStructuredAgentSessionAdoptionForCreate({ - settings: {}, - agent: 'claude', - transcriptImport: importer, - providerSessionId: id, - selectedAccountHome, - launchEnv: {}, - workspaceCwd: root, - resolveTranscriptPath: sourcePath - }) - ).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptUnreadable' } } }) + await expect(resolveStructuredAgentSessionAdoptionForCreate({ + settings: {}, agent: 'claude', transcriptImport: importer, providerSessionId: id, + selectedAccountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, + launchEnv: {}, workspaceCwd: root, resolveTranscriptPath: () => escaped + })).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptUnreadable' } } }) + admission.mockRejectedValueOnce(Object.assign(new Error('Selected account inaccessible'), { code: 'EACCES' })) + await expect(resolveStructuredAgentSessionAdoptionForCreate({ + settings: {}, agent: 'claude', transcriptImport: importer, providerSessionId: id, + selectedAccountHome, launchEnv: {}, workspaceCwd: root, resolveTranscriptPath: sourcePath + })).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptUnreadable' } } }) expect(find).not.toHaveBeenCalled() }) @@ -177,13 +107,8 @@ it('judges the lookup and its re-admission against one store resolved once per a const find = vi.spyOn(importer, 'findTranscript') const admission = vi.spyOn(importer, 'admitTranscriptPath') const adoption = await resolveStructuredAgentSessionAdoptionForCreate({ - settings: {}, - agent: 'claude', - transcriptImport: importer, - providerSessionId: id, - selectedAccountHome, - launchEnv: {}, - workspaceCwd: root + settings: {}, agent: 'claude', transcriptImport: importer, providerSessionId: id, + selectedAccountHome, launchEnv: {}, workspaceCwd: root }) expect(adoption.transcriptPath).toBe(transcriptPath) expect(store).toHaveBeenCalledOnce() @@ -191,45 +116,29 @@ it('judges the lookup and its re-admission against one store resolved once per a expect(admission.mock.calls[0]?.[0].store).toBe(join(root, 'projects')) }) -it.skipIf(process.platform === 'win32')( - 'reports a not-yet-saved conversation under a linked account folder as unsaved', - async () => { - const linked = join(root, 'linked-account') - await mkdir(join(root, 'projects'), { recursive: true }) - await symlink(root, linked) - await expect( - resolveStructuredAgentSessionAdoptionForCreate({ - settings: {}, - agent: 'claude', - transcriptImport: CLAUDE_CLI_CONVERSATIONS.transcriptImport!, - providerSessionId: id, - selectedAccountHome: { variable: 'CLAUDE_CONFIG_DIR', path: linked }, - launchEnv: {}, - workspaceCwd: root, - resolveTranscriptPath: () => join(linked, 'projects', 'folder', `${id}.jsonl`) - }) - ).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptNotFound' } } }) - } -) +it.skipIf(process.platform === 'win32')('reports a not-yet-saved conversation under a linked account folder as unsaved', async () => { + const linked = join(root, 'linked-account') + await mkdir(join(root, 'projects'), { recursive: true }) + await symlink(root, linked) + await expect(resolveStructuredAgentSessionAdoptionForCreate({ + settings: {}, agent: 'claude', transcriptImport: CLAUDE_CLI_CONVERSATIONS.transcriptImport!, + providerSessionId: id, selectedAccountHome: { variable: 'CLAUDE_CONFIG_DIR', path: linked }, + launchEnv: {}, workspaceCwd: root, + resolveTranscriptPath: () => join(linked, 'projects', 'folder', `${id}.jsonl`) + })).rejects.toMatchObject({ refusal: { details: { reason: 'transcriptNotFound' } } }) +}) -it.each(['claude', 'codex', 'grok', 'omp', 'pi'] as const)( - 'retains empty versus unreadable source facts for %s', - async (agent) => { - const transcript = join(root, `${id}.jsonl`) - await writeFile(transcript, '') - expect(await prepareAdoptedTranscript(params(agent, transcript))).toMatchObject({ - ok: false, - refusal: { details: { reason: 'transcriptNotFound' } } - }) - for (const content of ['{broken json}\n', '{"type":"future_record"}\n']) { - await writeFile(transcript, content) - expect(await prepareAdoptedTranscript(params(agent, transcript))).toMatchObject({ - ok: false, - refusal: { details: { reason: 'transcriptUnreadable' } } - }) - } +it.each(['claude', 'codex', 'grok', 'omp', 'pi'] as const)('retains empty versus unreadable source facts for %s', async (agent) => { + const transcript = join(root, `${id}.jsonl`) + await writeFile(transcript, '') + expect(await prepareAdoptedTranscript(params(agent, transcript))).toMatchObject({ ok: false, + refusal: { details: { reason: 'transcriptNotFound' } } }) + for (const content of ['{broken json}\n', '{"type":"future_record"}\n']) { + await writeFile(transcript, content) + expect(await prepareAdoptedTranscript(params(agent, transcript))).toMatchObject({ ok: false, + refusal: { details: { reason: 'transcriptUnreadable' } } }) } -) +}) it('checks canonical provider metadata during the existing import, including Codex copied parent metadata', async () => { const codex = await file(join(root, `${id}.jsonl`), [ @@ -241,38 +150,23 @@ it('checks canonical provider metadata during the existing import, including Cod for (const agent of ['omp', 'pi'] as const) { const transcript = await file(join(root, `${id}.jsonl`), [ { type: 'session', id, cwd: root }, - { - type: 'message', - id: 'turn', - message: { role: 'user', content: [{ type: 'text', text: 'hello' }] } - } + { type: 'message', id: 'turn', message: { role: 'user', content: [{ type: 'text', text: 'hello' }] } } ]) expect(await prepareAdoptedTranscript(params(agent, transcript))).toMatchObject({ ok: true }) await file(transcript, [{ type: 'session', id: 'foreign', cwd: root }]) expect(await prepareAdoptedTranscript(params(agent, transcript))).toMatchObject({ ok: false }) } - const grok = await file(join(root, id, 'chat_history.jsonl'), [ - { type: 'user', content: 'hello' } - ]) + const grok = await file(join(root, id, 'chat_history.jsonl'), [{ type: 'user', content: 'hello' }]) await file(join(root, id, 'summary.json'), [{ info: { id, cwd: root } }]) expect(await prepareAdoptedTranscript(params('grok', grok))).toMatchObject({ ok: true }) await rm(join(root, id, 'summary.json')) - expect(await prepareAdoptedTranscript(params('grok', grok))).toMatchObject({ - ok: false, - refusal: { details: { reason: 'transcriptUnreadable' } } - }) + expect(await prepareAdoptedTranscript(params('grok', grok))).toMatchObject({ ok: false, + refusal: { details: { reason: 'transcriptUnreadable' } } }) await file(codex, [{ type: 'session_meta', payload: { id } }]) - expect( - await prepareLegacyTranscriptImport({ - agent: 'codex', - sessionId: id, - options: { filePath: codex } - }) - ).toMatchObject({ ok: true, items: [], sourceEmpty: false }) - expect(await prepareAdoptedTranscript(params('codex', codex))).toMatchObject({ - ok: false, - refusal: { details: { reason: 'transcriptUnreadable' } } - }) + expect(await prepareLegacyTranscriptImport({ agent: 'codex', sessionId: id, + options: { filePath: codex } })).toMatchObject({ ok: true, items: [], sourceEmpty: false }) + expect(await prepareAdoptedTranscript(params('codex', codex))).toMatchObject({ ok: false, + refusal: { details: { reason: 'transcriptUnreadable' } } }) }) it('materializes legacy Codex into the system home and pins a verified selected account alias', async () => { @@ -281,9 +175,7 @@ it('materializes legacy Codex into the system home and pins a verified selected const historyOnly = join(root, 'history-only') const selected = join(root, 'codex-accounts', 'selected', 'home') const suffix = join('sessions', '2026', '10', '15', `rollout-stamp-${id}.jsonl`) - const transcriptPath = await file(join(legacy, suffix), [ - { type: 'session_meta', payload: { id } } - ]) + const transcriptPath = await file(join(legacy, suffix), [{ type: 'session_meta', payload: { id } }]) vi.spyOn(codexHomes, 'getSystemCodexHomePath').mockReturnValue(system) const source = { accountHome: { variable: 'CODEX_HOME' as const, path: legacy }, transcriptPath } const prepare = codexAiVaultSessionResumePreparation(() => ({ @@ -292,57 +184,20 @@ it('materializes legacy Codex into the system home and pins a verified selected resolveSelectedHostAccountCodexHomePathForResume: () => null, getHostCodexHomePathsForSessionDiscovery: () => [legacy] }, - systemCodexHomePath: historyOnly, - preparePinnedLaunchHome: async () => {} + systemCodexHomePath: historyOnly, preparePinnedLaunchHome: async () => {} })) const migrated = await prepareCodexAdoption(source, id, prepare, [source.accountHome]) - expect(migrated).toEqual({ - accountHome: { variable: 'CODEX_HOME', path: system }, - transcriptPath: join(system, suffix) - }) - await expect( - prepareLegacyTranscriptImport({ - agent: 'codex', - sessionId: id, - options: { filePath: join(historyOnly, suffix) } - }) - ).resolves.toMatchObject({ ok: false }) - await prepare({ - agent: 'codex', - sessionId: id, - filePath: transcriptPath, - codexHome: legacy, - executionHostId: 'local' - }) - expect( - await prepareLegacyTranscriptImport({ - agent: 'codex', - sessionId: id, - options: { filePath: join(historyOnly, suffix) } - }) - ).toMatchObject({ ok: true }) + expect(migrated).toEqual({ accountHome: { variable: 'CODEX_HOME', path: system }, transcriptPath: join(system, suffix) }) + await expect(prepareLegacyTranscriptImport({ agent: 'codex', sessionId: id, + options: { filePath: join(historyOnly, suffix) } })).resolves.toMatchObject({ ok: false }) + await prepare({ agent: 'codex', sessionId: id, filePath: transcriptPath, codexHome: legacy, executionHostId: 'local' }) + expect(await prepareLegacyTranscriptImport({ agent: 'codex', sessionId: id, + options: { filePath: join(historyOnly, suffix) } })).toMatchObject({ ok: true }) await file(join(selected, suffix), [{ type: 'session_meta', payload: { id } }]) - expect( - await prepareCodexAdoption( - migrated, - id, - async () => ({ useRealCodexHome: false, substituteCodexHome: selected }), - [{ variable: 'CODEX_HOME', path: selected }] - ) - ).toEqual({ - accountHome: { variable: 'CODEX_HOME', path: selected }, - transcriptPath: join(selected, suffix) - }) - await expect( - prepareCodexAdoption( - source, - id, - async () => { - throw new Error('EACCES selected account') - }, - [source.accountHome] - ) - ).rejects.toThrow('EACCES selected account') + expect(await prepareCodexAdoption(migrated, id, async () => ({ useRealCodexHome: false, substituteCodexHome: selected }), + [{ variable: 'CODEX_HOME', path: selected }])).toEqual({ accountHome: { variable: 'CODEX_HOME', path: selected }, transcriptPath: join(selected, suffix) }) + await expect(prepareCodexAdoption(source, id, async () => { throw new Error('EACCES selected account') }, + [source.accountHome])).rejects.toThrow('EACCES selected account') }) // A 2.x terminal started as `opencode` reports `opencode`; either OpenCode chat opens its conversation. diff --git a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts index 2541f004e00a..9f66a0509327 100644 --- a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -121,9 +121,7 @@ describe('a live Claude chat whose send could not be written', () => { expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') ) const snapshot = await host.journalSnapshot(SESSION) - expect(snapshot.items.some((item) => item.body.kind === 'status' && item.body.failure)).toBe( - false - ) + expect(snapshot.items.some((item) => item.body.kind === 'status' && item.body.failure)).toBe(false) expect( snapshot.items.some((item) => item.body.kind === 'turn' && item.body.state === 'completed') ).toBe(true) diff --git a/src/main/runtime/structured-agent-launch-parity.test.ts b/src/main/runtime/structured-agent-launch-parity.test.ts index 5bd5ceac3404..b128562d1d6f 100644 --- a/src/main/runtime/structured-agent-launch-parity.test.ts +++ b/src/main/runtime/structured-agent-launch-parity.test.ts @@ -155,16 +155,7 @@ import { } from './structured-worker-identity' // A new agent goes last: its cases then shift only the configured ones' session ids. -const AGENTS = [ - 'claude', - 'codex', - 'grok', - 'opencode', - 'omp', - 'pi', - 'antigravity', - 'opencode2' -] as const +const AGENTS = ['claude', 'codex', 'grok', 'opencode', 'omp', 'pi', 'antigravity', 'opencode2'] as const type Agent = (typeof AGENTS)[number] /** What each fake binary prints for `--version`: a release every launch gate accepts. */ diff --git a/src/renderer/src/components/right-sidebar/AiVaultScanProgressStrip.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultScanProgressStrip.test.tsx index cfa8122cef52..ca426082a446 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultScanProgressStrip.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultScanProgressStrip.test.tsx @@ -61,7 +61,9 @@ it('keeps a narrower cached panel off the broader key while a late matching pane }) const local = renderHook(() => useAiVaultScanProgress('local', broader)) const narrowerPanel = renderHook(() => useAiVaultScanProgress('local', narrower)) - const remote = renderHook(() => useAiVaultScanProgress('runtime:server-1', broader)) + const remote = renderHook(() => + useAiVaultScanProgress('runtime:server-1', broader) + ) const allHosts = renderHook(() => useAiVaultScanProgress('all', broader)) await waitFor(() => expect(local.result.current?.progress).toBe(cachedProgress)) act(() => push({ scanKey: broader, progress: cachedProgress })) @@ -158,6 +160,8 @@ it('keeps real rows through partial rescans, detached keys and failed retries', expect(view.queryByText('Scanned', { exact: false })).toBeNull() expect(panel.container.querySelector('.animate-spin, .lucide-check')).toBeNull() } - act(() => push({ scanKey: 'worktree-c', progress: { ...next, agents: [FINISHED.agents[0]!] } })) + act(() => + push({ scanKey: 'worktree-c', progress: { ...next, agents: [FINISHED.agents[0]!] } }) + ) expect(rowCount()).toBe(1) }) diff --git a/src/renderer/src/components/right-sidebar/AiVaultScanProgressStrip.tsx b/src/renderer/src/components/right-sidebar/AiVaultScanProgressStrip.tsx index 9438b8221e11..29b7bc3e99c4 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultScanProgressStrip.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultScanProgressStrip.tsx @@ -83,8 +83,7 @@ function StripText({ progress, active }: StripActivity): React.JSX.Element { active && progress.stage === 'reading' ? `${formatCount(progress.read.done)} / ${formatCount(progress.read.total)}` : fileCountLabel(progress.agents.reduce((sum, agent) => sum + foundCount(agent), 0)) - const sentence = - active || progress.stage === 'finished' ? stripSentence(progress) : EMPHASIS_TOKEN + const sentence = active || progress.stage === 'finished' ? stripSentence(progress) : EMPHASIS_TOKEN const [before, after] = sentence.split(EMPHASIS_TOKEN) return ( @@ -182,9 +181,7 @@ export function AiVaultScanProgressStrip({ ))} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index 280164f55855..087619b327c7 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -246,10 +246,7 @@ export function useAiVaultSessionRefresh( ) { setTerminalScopeKey(scanKey) if (!isAiVaultScanCancellation(err)) { - setFailure({ - scopeKey: scanKey, - cause: err instanceof Error ? err.message : String(err) - }) + setFailure({ scopeKey: scanKey, cause: err instanceof Error ? err.message : String(err) }) } } } finally { From 8b5a05a4ec8c7e7028705fcd2c37238b5e836f5b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:26:19 -0700 Subject: [PATCH 120/127] Preserve compact handover through startup merge --- config/path-literal-baseline.txt | 4 --- config/scripts/vitest-node-runtime-files.mjs | 2 -- .../vitest-sqlite-database-runtime-files.mjs | 1 - .../scripts/vitest-sqlite-runtime-files.mjs | 27 ------------------- .../structured-agent-session-child-exit.ts | 2 +- .../structured-agent-session-handover.ts | 12 +++++++-- ...structured-conversation-compaction.test.ts | 3 ++- 7 files changed, 13 insertions(+), 38 deletions(-) diff --git a/config/path-literal-baseline.txt b/config/path-literal-baseline.txt index b76b7dbb4d26..be1593d8bc11 100644 --- a/config/path-literal-baseline.txt +++ b/config/path-literal-baseline.txt @@ -7,10 +7,6 @@ config/scripts/check-layout-writer-ratchets.mjs src/renderer/src/runtime/remote- config/scripts/pr-layout-oracle-route.mjs src/main/runtime/old-session-tabs-stream/ config/scripts/pr-layout-oracle-route.mjs src/renderer/src/runtime/remote-workspace-frozen/ config/scripts/run-ssh-staged-upload-reliability.mjs src/main/ssh/ssh-relay-install-namespace.test.ts -config/scripts/vitest-sqlite-runtime-files.mjs src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts -config/scripts/vitest-sqlite-runtime-files.mjs src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts -config/scripts/vitest-sqlite-runtime-files.mjs src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts -config/scripts/vitest-sqlite-runtime-files.mjs src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts config/tsconfig.node.json src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts config/tsconfig.node.json src/renderer/src/components/native-chat/native-chat-resolution-receipt.ts config/tsconfig.node.json src/renderer/src/components/native-chat/structured-agent-question-projection.ts diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 4387f9358039..cb961e4823b6 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -87,8 +87,6 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts', - 'src/main/native-chat/structured-chat-naming-command-first.test.ts', - 'src/main/native-chat/structured-chat-naming-status-hook.test.ts', 'src/main/runtime/agent-session-conversation-name-store.test.ts', 'src/main/runtime/structured-session-mail-redrive-wiring.test.ts', 'src/main/runtime/rpc/ws-transport.test.ts', diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index 1cc0e2c026b7..e802cbed3bcc 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -188,7 +188,6 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-codex-held-sends.test.ts', 'src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts', 'src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts', - 'src/main/runtime/structured-agent-session-failure-logging.test.ts', 'src/main/runtime/structured-agent-session-held-signal.test.ts', 'src/main/runtime/structured-agent-session-host-refusal.test.ts', 'src/main/runtime/structured-agent-session-install-failure.test.ts', diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 60a67ae8ac70..3eed6fb8e540 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -15,7 +15,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/repo-backfill-wsl-move.test.ts', 'src/main/acp/acp-structured-host-live-listing.test.ts', 'src/main/acp/acp-structured-host-configured-default.test.ts', - 'src/main/pi/rpc-configured-default.test.ts', 'src/main/acp/acp-structured-omp-recordings.test.ts', 'src/main/acp/acp-text-stream-identity.test.ts', 'src/main/acp/acp-text-message-ownership.test.ts', @@ -67,8 +66,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/codex/codex-structured-journal-subagent-readers.test.ts', 'src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts', 'src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts', - 'src/main/codex/codex-structured-question-order.test.ts', - 'src/main/ipc/pty/ipc/spawn-commit-ssh-lease-cardinality.test.ts', 'src/main/ipc/repos/folder-workspace-attachment-handlers.test.ts', 'src/main/ipc/ssh/ssh-host-server-on-connect-wiring.test.ts', 'src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts', @@ -81,8 +78,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-acquire-isolation.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts', @@ -93,7 +88,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-opening-send-hold.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts', @@ -106,7 +100,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts', @@ -114,17 +107,12 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-held-child-lease-renewal.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts', @@ -135,20 +123,13 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-live-turn-window.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-message-choices.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-options.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts', @@ -163,8 +144,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts', 'src/main/native-chat/native-chat-visuals-sweep.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts', @@ -177,7 +156,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-provider-output.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-recovery-output.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts', @@ -186,12 +164,9 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-signed-out-child.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts', @@ -238,7 +213,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/agent-session-launch-directory.test.ts', 'src/main/runtime/agent-session-launch-env-admission.test.ts', 'src/main/runtime/agent-session-lease-renewal.test.ts', - 'src/main/runtime/agent-session-record-options.test.ts', 'src/main/runtime/agent-session-record-store-tab-table.test.ts', 'src/main/runtime/agent-session-record-store.test.ts', 'src/main/runtime/agent-session-permission-backfill.test.ts', @@ -269,7 +243,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-close-before-create.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts', - 'src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts', 'src/main/runtime/rpc/methods/structured-agent-session-unsubscribe.test.ts', 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 bfe9a3ea88da..1dd7ff27ea3a 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 @@ -153,7 +153,7 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< const observedAt = observation?.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-handover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts index f9079f678dae..eefe8711a197 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts @@ -26,7 +26,10 @@ import { import type { StructuredAgentSessionDeliveryOrder } from './structured-agent-session-delivery-order' import { carriedStoppedHistory } from './structured-agent-session-stopped-history-selection' import { withStoppedHistory } from '../../../shared/agent-session-stopped-history' -import { beginStructuredAgentSessionHandover } from './structured-agent-session-handing-over' +import { + beginStructuredAgentSessionHandover, + whileStructuredAgentSessionHandingOver +} from './structured-agent-session-handing-over' export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverContext & { acquireAborts?: Pick @@ -65,7 +68,12 @@ export async function beginSubmissionHandover( return null } if (body.command) { - await handOverStructuredAgentSessionCommand(ctx, submission, body) + await whileStructuredAgentSessionHandingOver( + ctx.journal, + clientMessageId, + async () => handOverStructuredAgentSessionCommand(ctx, submission, body), + ctx.workChanged + ) return null } // The message joins the turn the child it goes to is running, a steer, or opens its own; never a diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index da96b9acc405..1f8c2799b5ff 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -150,7 +150,8 @@ async function commandTurn(clientMessageId: string) { return (await journal()).items.find((item) => item.itemId === itemId) } -it('answers at handover, then journals its own entry, turn and result (B1, B16)', async () => { +it('answers at handover before the provider reports holding the command, then journals its result', async () => { + Object.assign(state.host.deps.adapter, { heldSends: () => new Set() }) await attach() const events = await subscribe() const params = compactParams() From d9a694915e0922bf902b2d11511b64e1475f75cd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:32:49 -0700 Subject: [PATCH 121/127] Type instant startup fixtures before their open is supplied --- .../structured-agent-session-instant-start.test-support.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 b7c6834e1274..ab7b4713f294 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 @@ -2,7 +2,10 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -export function startsWhenPublished( +type InstantStartAdapter = Omit & + Partial> + +export function startsWhenPublished( adapter: A, _host: () => unknown ): A & StructuredAgentSessionAdapter { From 281ae33770d8a6f292c0b1fa90c3c9df8b3687ea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:47:13 -0700 Subject: [PATCH 122/127] Keep browser sign-in under its existing expiry owner --- config/scripts/vitest-node-runtime-files.mjs | 1 + config/scripts/vitest-runtime-boundary.test.ts | 3 +-- src/main/acp/acp-peer-limits.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index cb961e4823b6..79178c4ccf6b 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -85,6 +85,7 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts', + 'src/main/native-chat/structured-chat-naming.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts', 'src/main/runtime/agent-session-conversation-name-store.test.ts', diff --git a/config/scripts/vitest-runtime-boundary.test.ts b/config/scripts/vitest-runtime-boundary.test.ts index 6ff715a9255f..737bd193c37a 100644 --- a/config/scripts/vitest-runtime-boundary.test.ts +++ b/config/scripts/vitest-runtime-boundary.test.ts @@ -27,8 +27,7 @@ it('routes native-chat journal and record-store SQLite contracts through Node', expect.arrayContaining([ 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.test.ts', - 'src/main/native-chat/structured-chat-naming-command-first.test.ts', - 'src/main/native-chat/structured-chat-naming-status-hook.test.ts', + 'src/main/native-chat/structured-chat-naming.test.ts', 'src/main/runtime/agent-session-conversation-name-store.test.ts', 'src/main/runtime/structured-session-mail-redrive-wiring.test.ts' ]) diff --git a/src/main/acp/acp-peer-limits.ts b/src/main/acp/acp-peer-limits.ts index a816531cb977..6a8f29a33dd9 100644 --- a/src/main/acp/acp-peer-limits.ts +++ b/src/main/acp/acp-peer-limits.ts @@ -44,7 +44,6 @@ export function requestTimeout(value: number | null | undefined): number | null /** Each start step's own bound; turns are unbounded and option writes carry their caller's. */ const ACP_START_STEP_TIMEOUT_MS: Readonly>> = { initialize: 60_000, - authenticate: 120_000, 'session/new': 300_000, 'session/load': 300_000, 'session/resume': 300_000, From 9aa0e77cb4ced196621d0bac65865ed9158a08e8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:14:58 -0700 Subject: [PATCH 123/127] Finish the main merge: retired-model display, start rewind cut, new tests A model the Claude CLI retired is reported again, so the picker shows what the child runs and the next send moves the chat off it. The start reads a rewind in doubt against the handle it answered with, the record its commit then applies to. Main's tests follow the start's open: the admission fixture holds a real turn, and a sign-in close during setup fails the open. --- src/main/acp/acp-structured-sign-in.test.ts | 6 +++-- .../claude/claude-structured-retired-model.ts | 3 ++- .../claude-structured-session-adapter.ts | 4 +++ .../claude/claude-structured-session-state.ts | 2 ++ ...structured-agent-session-adapter-router.ts | 3 +++ .../structured-agent-session-adapter.ts | 2 ++ .../structured-agent-session-options-read.ts | 14 ++++++----- ...ructured-agent-session-provider-started.ts | 25 ++++++++++++++++--- ...ured-agent-session-start-admission.test.ts | 19 ++++++++++---- .../structured-rewind-recovery.ts | 15 +++++------ ...uctured-agent-session-options-read.test.ts | 1 + 11 files changed, 70 insertions(+), 24 deletions(-) diff --git a/src/main/acp/acp-structured-sign-in.test.ts b/src/main/acp/acp-structured-sign-in.test.ts index 28ba9bf81388..e8425302577d 100644 --- a/src/main/acp/acp-structured-sign-in.test.ts +++ b/src/main/acp/acp-structured-sign-in.test.ts @@ -99,11 +99,13 @@ it('ends a running turn signed out when the connection closes for sign-in', asyn it('starts signed out without Orca words when the connection closes for sign-in', async () => { const rig = await openAcpAdapterRig({ script: (agent) => agent.on('session/new', () => {}) }) - const acquired = rig.acquire().catch((failure: unknown) => failure) + // Session setup runs in the start's open, which the rig awaits after the acquire. + const starting = rig.acquire() await rig.frame('session/new') const internal = 'The agent asked to sign in in a browser' AcpSessionRuntime.prototype.close.call(rig.child(), new AcpSignInPromptError(internal)) - const error = await acquired + await starting + const error = rig.openFailure() // Session setup re-types sign-in answers; this close must stay Orca's, words and all. expect(error).toMatchObject({ reason: 'notSignedIn' }) expect(providerDiagnosticOf(error)).toBeUndefined() diff --git a/src/main/claude/claude-structured-retired-model.ts b/src/main/claude/claude-structured-retired-model.ts index ce574cb680fe..3bbe76dc06ed 100644 --- a/src/main/claude/claude-structured-retired-model.ts +++ b/src/main/claude/claude-structured-retired-model.ts @@ -10,7 +10,7 @@ import type { /** The launched model, once, when a root reply says it does not exist and the chat still has it * picked: the live session stops holding it. Saved picks remain the user's. */ export function retireClaudeLaunchedModel( - session: Pick, + session: Pick, message: Record ): string | null { const launched = session.launchedModel @@ -24,6 +24,7 @@ export function retireClaudeLaunchedModel( return null } session.options.delete('model') + session.retiredModel = launched return launched } diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index f9188ba25dcc..29d7d94b95fa 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -261,6 +261,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } readOptions = (input: { sessionId: string; fence: number }) => readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) + readOptionRestoreFailures = (sessionId: string): readonly string[] => { + const session = this.sessions.get(sessionId) + return session?.retiredModel !== undefined && !session.options.has('model') ? ['model'] : [] + } releaseAcquisition = (input: { sessionId: string }): Promise => this.afterClose(input.sessionId, () => this.releaseProviderSession(input.sessionId)) diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 4d863a770dfa..bdf45d8b4236 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -180,6 +180,8 @@ export type ClaudeSession = { reusableOptions: Map /** The model the child was launched with, as `--model`; null when it runs the CLI's own. */ launchedModel: string | null + /** A launched model the CLI answered `model_not_found` for; reported until a new pick. */ + retiredModel?: string /** Fast was omitted from launch settings and needs confirmation at message input. */ fastModeAtStart: boolean /** CLI-advertised protocol capabilities from init; gates interrupt-receipt handling. */ 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 5aeec815ae62..b75c53fcdb0c 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 @@ -175,6 +175,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return reader(input) } + readOptionRestoreFailures = (sessionId: string): readonly string[] => + this.owner(sessionId).readOptionRestoreFailures?.(sessionId) ?? [] + providerHistoryWindow = (input: { identity: AgentSessionJournalIdentity accountHome: AgentSessionAccountHome diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 16c3d45703ef..04f4a9ca6bc5 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 @@ -375,6 +375,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & sessionId: string fence: number }): Promise + /** Saved option keys the running child could not take (a model its provider retired). */ + readOptionRestoreFailures?(sessionId: string): readonly string[] /** Provider history for restart reconciliation, bounded to what the provider * recorded after the journal's last committed item. Only the adapter can say * whether the read has a proven start and whether a turn is still running, so diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 95881b04104d..add321f39ee1 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 @@ -268,17 +268,19 @@ export async function readStructuredAgentSessionOptions( ? await structuredAgentSessionLaunchPermission(context.deps, record) : null const selected = live ? await atRest() : options - // The saved pick shows, except one the running child could not take (a model its provider says + // The saved pick shows, except a model the running child could not take (its provider says it // does not exist): that shows what the child runs instead. - const refused = live ? (adapter.readOptionRestoreFailures?.(sessionId) ?? []) : [] + const refusedModel = live && !!adapter.readOptionRestoreFailures?.(sessionId).includes('model') const saved = record?.options ?? {} - const shownSaved = (key: 'model' | 'effort') => (refused.includes(key) ? undefined : saved[key]) - const model = shownSaved('model') ?? (options.current.model || selected.current.model) + const { model: _refused, ...selectedWithoutModel } = selected.current + const model = refusedModel + ? options.current.model + : (saved.model ?? (options.current.model || selected.current.model)) const current = { - ...selected.current, + ...(refusedModel ? selectedWithoutModel : selected.current), ...options.current, ...(model !== undefined ? { model } : {}), - effort: shownSaved('effort') ?? options.current.effort, + effort: saved.effort ?? options.current.effort, fastMode: saved.fastMode !== undefined ? selected.current.fastMode 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 999789f1b88c..7487b8b5b9b4 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 @@ -19,6 +19,7 @@ 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 type { AgentSessionRecord } from '../../../shared/agent-session-record' import { applyStructuredRewindRecovery, readStructuredRewindRecovery, @@ -54,7 +55,7 @@ export async function settleStructuredAgentSessionProviderStarted( signal?: AbortSignal ): Promise { // The provider read runs before the commit, so no send waits behind it. - const rewind = await readRewindAtStart(context, event.sessionId, signal) + const rewind = await readRewindAtStart(context, event, signal) // Serialized behind the attach that published this child, so the lease it proved is committed. await context.serialize(event.sessionId, async () => { signal?.throwIfAborted() @@ -101,9 +102,10 @@ export async function settleStructuredAgentSessionProviderStarted( * rewind would move. One that stays in doubt fails the start, as it would refuse every send. */ async function readRewindAtStart( context: StructuredAgentSessionProviderStartedContext, - sessionId: string, + event: OpenedProvider, signal?: AbortSignal ): Promise { + const { sessionId } = event const journal = context.sessions.get(sessionId)?.journal if (!journal) { return null @@ -115,13 +117,30 @@ async function readRewindAtStart( journal, structuredAgentSessionConversationFence(context.deps.store, sessionId), context.deps.adapter, - signal + signal, + recordWithStartedHandle(context, event) ) } catch (error) { return rewindRecoveryFailed(context, sessionId, error, signal) } } +/** The record as the commit leaves it once it records the handle this start answered with, which + * is what the rewind's apply cuts against; a record that refuses it is read as it stands. */ +function recordWithStartedHandle( + context: StructuredAgentSessionProviderStartedContext, + { sessionId, fence, link }: OpenedProvider +): AgentSessionRecord | undefined { + const record = context.deps.store.getRecord(sessionId) + try { + return record && link + ? recordAgentSessionProviderHandle({ record, fence, link, now: context.now() }) + : record + } catch { + return record + } +} + async function applyRewindAtStart( context: StructuredAgentSessionProviderStartedContext, sessionId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts index e662b3633e24..9a9f2a003893 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts @@ -10,12 +10,15 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -68,6 +71,7 @@ let closeSession: Mock> let probeOwner: Mock<(record: unknown) => Promise> let delivered: string[] +let events: StructuredAgentSessionEventSink | undefined let initialAttach: ReturnType let idleSweep: { intervalMs: number; idleMs: number } @@ -163,15 +167,19 @@ const SETUP: Record Promise> = { drain: () => restart(), open: () => restart(), recovery: async () => { + // A rewind in doubt over a turn the chat holds, so the start asks the provider about it. + const turn = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 0 } + events!.appendItem(turn, hostTestMessage('rewound'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + await host.flushStreamedEvents(SESSION) + const expectedEpoch = (await host.journalSnapshot(SESSION)).cursor.epoch await restart() await persistRewindRecord(store, SESSION, store.getRecord(SESSION)!.lease.runtimeFence, { operationId: 'rewind-op', callerKey: 'client-1', - itemId: 'orca:rewound', - providerItemId: `codex:${THREAD}:turn-1:0`, - expectedEpoch: 'epoch-before', - phase: 'prepared', - retained: [] + itemId: agentJournalItemKey(turn), + providerItemId: agentJournalItemKey(turn), + expectedEpoch, + phase: 'prepared' }) }, 'pending-row': async () => { @@ -221,6 +229,7 @@ beforeEach(async () => { delivered = [] idleSweep = { intervalMs: 60 * 60_000, idleMs: 60 * 60_000 } acquire = vi.fn(async (input) => { + events = input.events await at('spawn') return { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: input.spawnToken }, 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 14f0eda9cc67..7835e1bf674a 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 @@ -4,6 +4,7 @@ import { agentSessionProviderHandleChainHead } from '../../../shared/agent-sessi import { isDeepStrictEqual } from 'node:util' import { structuredRewindCut, type StructuredRewindCut } from './structured-rewind-cut' import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' @@ -77,13 +78,11 @@ export type StructuredRewindRecoveryEvidence = { // Only this rewind moves a chat to a new epoch once it has history (imports run at creation), so // a moved epoch is its own replacement, committed before the crash. function rewindJournalCut( - store: AgentSessionRecordStore, - sessionId: string, + record: AgentSessionRecord | undefined, journal: AgentSessionJournal, rewind: AgentSessionRewindRecord ): { alreadyReplaced: boolean; cut: StructuredRewindCut | null } { const alreadyReplaced = journal.cursor().epoch !== rewind.expectedEpoch - const record = store.getRecord(sessionId) const head = record && agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle const cut = alreadyReplaced || !record || !head @@ -122,21 +121,23 @@ export async function recoverStructuredRewind( await applyStructuredRewindRecovery(deps, sessionId, journal, fence, evidence, receipt) } +/** `record` is the one the apply will see, when a caller's commit before it changes the record. */ export async function readStructuredRewindRecovery( { store }: RewindRecoveryDeps, sessionId: string, journal: AgentSessionJournal, fence: number, adapter?: StructuredAgentSessionAdapter, - signal?: AbortSignal + signal?: AbortSignal, + record = store.getRecord(sessionId) ): Promise { signal?.throwIfAborted() - const rewind = store.getRecord(sessionId)?.rewind + const rewind = record?.rewind if (rewind?.phase !== 'provider-succeeded' && rewind?.phase !== 'prepared') { return null } const target = parseAgentJournalItemKey(rewind.providerItemId ?? rewind.itemId) - const { cut } = rewindJournalCut(store, sessionId, journal, rewind) + const { cut } = rewindJournalCut(record, journal, rewind) if (target?.provider === 'claude' || rewind.phase !== 'prepared' || !cut) { return { rewind } } @@ -180,7 +181,7 @@ export async function applyStructuredRewindRecovery( await settleUnsupportedClaudeRewind(deps, sessionId, fence, rewind) return } - const { alreadyReplaced, cut } = rewindJournalCut(store, sessionId, journal, rewind) + const { alreadyReplaced, cut } = rewindJournalCut(store.getRecord(sessionId), journal, rewind) if (!alreadyReplaced && !cut) { throw new Error('agent_session_rewind:invalid-target') } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts index 18123c3dd7f8..17f4abd05382 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts @@ -4,6 +4,7 @@ import '../unused-default-rpc-methods.test-fixture' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { AgentSessionRecord } from '../../../../shared/agent-session-record' +import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' import { agentSessionRecordFixture } from '../../../../shared/agent-session-record.test-fixture' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { From 9727ddcfb1e04533a0c08105964c7133188c5a0f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:19:51 -0700 Subject: [PATCH 124/127] Type the start's rewind record as the store returns it --- .../structured-agent-session-provider-started.ts | 2 +- .../agent-session-wire/structured-rewind-recovery.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 7487b8b5b9b4..00eafb4e3393 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 @@ -130,7 +130,7 @@ async function readRewindAtStart( function recordWithStartedHandle( context: StructuredAgentSessionProviderStartedContext, { sessionId, fence, link }: OpenedProvider -): AgentSessionRecord | undefined { +): AgentSessionRecord | null { const record = context.deps.store.getRecord(sessionId) try { return record && link 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 7835e1bf674a..d87318ef302d 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 @@ -78,7 +78,7 @@ export type StructuredRewindRecoveryEvidence = { // Only this rewind moves a chat to a new epoch once it has history (imports run at creation), so // a moved epoch is its own replacement, committed before the crash. function rewindJournalCut( - record: AgentSessionRecord | undefined, + record: AgentSessionRecord | null, journal: AgentSessionJournal, rewind: AgentSessionRewindRecord ): { alreadyReplaced: boolean; cut: StructuredRewindCut | null } { From 9c4b6b51baa515bad48f6c5ec298669dc8b0f5ca Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:24:46 -0700 Subject: [PATCH 125/127] Type the Sleep test's held open as the provider open it replaces --- ...structured-agent-session-workspace-sleep-lifetime.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts index c176931488a3..286da63dc50a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts @@ -25,6 +25,7 @@ import { replaceHostTestState, attachParams } from './structured-agent-session-host-test-harness' +import type { StructuredAgentSessionProviderOpen } from './structured-agent-session-provider-open-contract' it('omits an unverified stop superseded by a newer send while retaining a sibling receipt', async () => { const { host, store, acquire } = hostTestState() @@ -238,9 +239,9 @@ it('stops an agent that is still starting', async () => { host.deps.adapter.closeSession = closeSession expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) let opening = false - host.deps.adapter.open = vi.fn(async ({ signal }) => { + host.deps.adapter.open = vi.fn(async ({ signal }) => { opening = true - await new Promise((_, reject) => + return await new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason)) ) }) From 77f8d5f2065f706b1277ce6c9a4f5eff9b8df090 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:30:01 -0700 Subject: [PATCH 126/127] Sleep counts a chat stopped once the start it aborted leaves it at rest A start runs outside the chat's lane, so Sleep's step can run before the start it aborted has settled; it then re-reads the chat once that start is done, instead of reporting it unverified. --- ...red-agent-session-conversation-lifetime.ts | 14 ++++-- ...t-session-workspace-sleep-lifetime.test.ts | 23 ++++++++++ ...ured-agent-session-workspace-sleep-stop.ts | 44 +++++++++++++++---- 3 files changed, 69 insertions(+), 12 deletions(-) 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 532902628123..25322b10986e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -29,7 +29,10 @@ import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation- import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger' import { sameProviderChild } from './structured-agent-session-provider-child' import type { ExecutionHostId } from '../../../shared/execution-host' -import { stopWorkspaceSleepChat } from './structured-agent-session-workspace-sleep-stop' +import { + settleWorkspaceSleepAbortedStarts, + stopWorkspaceSleepChat +} from './structured-agent-session-workspace-sleep-stop' import type { StructuredAgentSessionExpiredStartup } from './structured-agent-session-startup-attempt' import { providerStartupFailureFact, @@ -176,10 +179,13 @@ export function createStructuredAgentSessionConversationLifetime(host: { // final identity check against a later restart. return { fence: deps().store.getRecord(sessionId)?.lease.runtimeFence ?? fence, stopped } }) - const result = await closed // Settled outside the lane its last commits need, so only a newer start reads as later work. - await Promise.allSettled(abortedStarts) - return result + return settleWorkspaceSleepAbortedStarts( + { deps: deps(), sessions, sessionId }, + await closed, + abortedStarts, + () => host.context().runtimeState.startFlights.has(sessionId) + ) } const idleSweep = new StructuredAgentSessionIdleSweep({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts index 286da63dc50a..0592ed283daa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts @@ -259,6 +259,29 @@ it('stops an agent that is still starting', async () => { expect(host['sessions'].get(SESSION)?.child ?? null).toBeNull() }) +// Before the adapter owns a process, the aborted start leaves the chat at rest: that is stopped. +it('stops an agent whose process is still being acquired', async () => { + const { host, acquire } = hostTestState() + await attach() + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + let acquiring = false + acquire.mockImplementationOnce(async ({ signal }) => { + acquiring = true + return await new Promise((_, reject) => + signal?.addEventListener('abort', () => reject(signal.reason)) + ) + }) + const body = hostTestMessage('send while the agent is acquired') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(acquiring).toBe(true)) + + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + expect(host['runtimeState'].startFlights.has(SESSION)).toBe(false) + expect(host['sessions'].get(SESSION)?.child ?? null).toBeNull() +}) + it('sleeps only proven ended chats after idle eviction or restart', async () => { const rig = await createRestTestRig() try { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts index cb7b9a43f54d..fa3f4aa6ce35 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts @@ -69,15 +69,43 @@ export async function stopWorkspaceSleepChat(input: { !sessions.get(sessionId)?.child && ended?.rootGone === true && sameProviderChild(ended, child) - // A released reservation may still have an unproven root after restart; only - // exit evidence or a never-started record proves an unheld chat is stopped. const alreadyStopped = - !!after && - after.lease.runtimeFence === input.fence && - agentSessionLeaseIsReleased(after.lease) && - (agentSessionLeaseOwnerVerdict(after.lease) === 'exited' || - structuredSessionNeverStarted(after)) && + after?.lease.runtimeFence === input.fence && workspaceSleepChatAtRest(deps, sessions, sessionId) + return childStopped || ownedStopped || alreadyStopped +} + +/** A released reservation may still have an unproven root after restart; only exit evidence or a + * never-started record proves an unheld chat is stopped. */ +function workspaceSleepChatAtRest( + deps: Pick, + sessions: StructuredAgentSessionConversations, + sessionId: string +): boolean { + const record = deps.store.getRecord(sessionId) + return ( + !!record && + agentSessionLeaseIsReleased(record.lease) && + (agentSessionLeaseOwnerVerdict(record.lease) === 'exited' || + structuredSessionNeverStarted(record)) && !sessions.get(sessionId)?.child && deps.adapter.hasOwnedAcquisition?.(sessionId) !== true - return childStopped || ownedStopped || alreadyStopped + ) +} + +/** A start the stop aborted settles after the stop's step; once it has, and no newer start runs, + * the chat it left at rest counts as stopped. */ +export async function settleWorkspaceSleepAbortedStarts( + input: Pick[0], 'deps' | 'sessions' | 'sessionId'>, + result: { fence: number; stopped: boolean } | null, + abortedStarts: readonly Promise[], + startRunning: () => boolean +): Promise<{ fence: number; stopped: boolean } | null> { + await Promise.allSettled(abortedStarts) + if (!result || result.stopped || abortedStarts.length === 0 || startRunning()) { + return result + } + const { deps, sessions, sessionId } = input + return workspaceSleepChatAtRest(deps, sessions, sessionId) + ? { fence: deps.store.getRecord(sessionId)?.lease.runtimeFence ?? result.fence, stopped: true } + : result } From 0223f64c6f6cbc72abc1a0b1657091241607c359 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:33:08 -0700 Subject: [PATCH 127/127] Keep Sleep's start-abort bookkeeping beside the rest of Sleep's stop --- ...red-agent-session-conversation-lifetime.ts | 27 ++++------------ ...ured-agent-session-workspace-sleep-stop.ts | 31 ++++++++++++++++++- 2 files changed, 36 insertions(+), 22 deletions(-) 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 25322b10986e..d9bab20d37ca 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,8 +30,8 @@ import { deferredStructuredAgentSessionLogger } from './structured-agent-session import { sameProviderChild } from './structured-agent-session-provider-child' import type { ExecutionHostId } from '../../../shared/execution-host' import { - settleWorkspaceSleepAbortedStarts, - stopWorkspaceSleepChat + stopWorkspaceSleepChat, + workspaceSleepStartAborts } from './structured-agent-session-workspace-sleep-stop' import type { StructuredAgentSessionExpiredStartup } from './structured-agent-session-startup-attempt' import { @@ -127,26 +127,17 @@ export function createStructuredAgentSessionConversationLifetime(host: { const location = deps().store.getRecord(sessionId)?.location return location?.workspaceId === workspaceId && location.executionHostId === executionHostId } - const abortedStarts: Promise[] = [] - // The same abort a close gives a registered start, remembering which start it was. - const abortStart = (): void => { - const { runtimeState } = host.context() - const flight = runtimeState.startFlights.get(sessionId) - if (flight) { - abortedStarts.push(flight.outcome) - } - runtimeState.acquireAborts.abort(sessionId, 'closed while starting') - } + const starts = workspaceSleepStartAborts(() => host.context().runtimeState, sessionId) // Pending creates do not yet have a record; their queued close waits for founding to finish. if (matches()) { - abortStart() + starts.abort() } const closed = serialize(sessionId, async () => { if (!matches()) { return null } readRefusals.forget(sessionId) - abortStart() + starts.abort() const record = deps().store.getRecord(sessionId) if (!record) { return null @@ -179,13 +170,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { // final identity check against a later restart. return { fence: deps().store.getRecord(sessionId)?.lease.runtimeFence ?? fence, stopped } }) - // Settled outside the lane its last commits need, so only a newer start reads as later work. - return settleWorkspaceSleepAbortedStarts( - { deps: deps(), sessions, sessionId }, - await closed, - abortedStarts, - () => host.context().runtimeState.startFlights.has(sessionId) - ) + return starts.settle({ deps: deps(), sessions, sessionId }, await closed) } const idleSweep = new StructuredAgentSessionIdleSweep({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts index fa3f4aa6ce35..b5c65dee63ea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts @@ -11,6 +11,7 @@ import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import { sameProviderChild } from './structured-agent-session-provider-child' +import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' /** Stops one chat captured by a workspace Sleep and reports whether the stop is proven. */ @@ -92,9 +93,37 @@ function workspaceSleepChatAtRest( ) } +/** The same abort a close gives a registered start, remembering which starts it aborted so the + * result is read once they settle, outside the lane their last commits need. */ +export function workspaceSleepStartAborts( + runtimeState: () => Pick< + StructuredAgentSessionHostRuntimeState, + 'startFlights' | 'acquireAborts' + >, + sessionId: string +) { + const aborted: Promise[] = [] + return { + abort: (): void => { + const flight = runtimeState().startFlights.get(sessionId) + if (flight) { + aborted.push(flight.outcome) + } + runtimeState().acquireAborts.abort(sessionId, 'closed while starting') + }, + settle: ( + input: Pick[0], 'deps' | 'sessions' | 'sessionId'>, + result: { fence: number; stopped: boolean } | null + ) => + settleWorkspaceSleepAbortedStarts(input, result, aborted, () => + runtimeState().startFlights.has(sessionId) + ) + } +} + /** A start the stop aborted settles after the stop's step; once it has, and no newer start runs, * the chat it left at rest counts as stopped. */ -export async function settleWorkspaceSleepAbortedStarts( +async function settleWorkspaceSleepAbortedStarts( input: Pick[0], 'deps' | 'sessions' | 'sessionId'>, result: { fence: number; stopped: boolean } | null, abortedStarts: readonly Promise[],