diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 94b97db94e65..588ac80eadc1 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 = [ + 'tests/e2e/native-chat-startup-option-pickers.unit.test.ts', ...SQLITE_DATABASE_RUNTIME_INCLUDE, 'src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts', 'src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts', @@ -141,6 +142,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', @@ -185,6 +188,8 @@ 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-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/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-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..4b9718ae45fe 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,19 @@ 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, + 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 cc8d06d59302..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,14 +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) { - // Refused before the write, so the agent never saw it. - return { - state: 'rejected', - ...claudeDispatchRejection({ ...startupFailure, neverReachedAgent: true }) - } - } // 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-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 2843d4f7ddd7..64ada44671d7 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,19 @@ 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, + 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 7d2b564716b0..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 9465feba5d3d..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-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 c8afd235bc92..749bc8258497 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -19,12 +19,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, @@ -254,6 +249,12 @@ export async function acquireClaudeSession({ process, acquisitionGeneration: mintClaudeAcquisitionGeneration(deps), options: launch.savedOptions.options, + initialization: { + proof: initProof, + account: launch.account, + resumesTranscript: launch.resumesTranscript, + onLateRefusal: (error) => callbacks.handleExit(sessionId, attempt, error) + }, ...(deps.mintLinkId ? { linkId: deps.mintLinkId() } : {}), observedAt: deps.now?.() ?? Date.now() }) @@ -268,37 +269,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) { @@ -306,7 +276,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 40d3f71982e9..87c04b453ec7 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,9 +163,8 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { subtype: 'apply_flag_settings', params: { settings: { fastMode: true } } }) - expect(events.find((event) => event.type === 'started')).toMatchObject({ - reportedOptions: { fastMode: true }, - restoreSkippedOptions: [] + expect(await claudeOpened(adapter, 'session-1')).toMatchObject({ + reportedOptions: { fastMode: true } }) }) @@ -194,19 +194,13 @@ 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({ - reportedOptions: { fastMode: true }, - restoreSkippedOptions: [] + expect(await claudeOpened(adapter, 'session-1')).toMatchObject({ + reportedOptions: { fastMode: true } }) 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) @@ -435,13 +429,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 +479,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 +510,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 8ac3e3c16a45..84b4804cd696 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, @@ -13,8 +14,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 +45,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 { @@ -233,13 +233,27 @@ 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 = 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/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index f6b4b0a8c9ef..316d90b0a509 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -147,8 +147,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..f1870a7600c5 --- /dev/null +++ b/src/main/claude/claude-structured-session-open.ts @@ -0,0 +1,62 @@ +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 { + 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 + request.signal.throwIfAborted() + if ( + session.fence !== request.fence || + session.acquisitionGeneration !== request.acquisitionGeneration + ) { + throw new Error('claude session was replaced before open') + } + const { proof, account, resumesTranscript, 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 }), + onLateRefusal, + report: (event) => + emit(session, { + ...event, + sessionId: request.sessionId, + fence: session.fence, + acquisitionGeneration: session.acquisitionGeneration + }) + }) + 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/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..42cc302773cd 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -1,444 +1,98 @@ 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) }) - 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) - 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) - 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' }) + 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('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) + 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) - 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) - 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) + await expect(claudeOpened(adapter, 'session-1')).rejects.toThrow('not signed in') 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) + 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) - - 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) - 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..f924756e6221 100644 --- a/src/main/claude/claude-structured-session-startup.ts +++ b/src/main/claude/claude-structured-session-startup.ts @@ -1,14 +1,8 @@ -// 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 { 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 { @@ -23,7 +17,6 @@ import { claudeStructuredSessionPublicationOptions, prepareClaudeStructuredSessionAcquisitionOptions } from './claude-structured-session-acquisition-options' -import { claudeRetiredOptions } from './claude-structured-retired-model' import { claudeResolvedCatalogListing, claudeStructuredSessionOptionsFrom, @@ -31,10 +24,6 @@ import { readClaudeFastModeFacts, readClaudeSettingsEffort } from './claude-structured-session-options' -import { - failClaudeStartup, - type ClaudeSessionStartup -} from './claude-structured-session-startup-state' import type { ClaudeAuthDiagnostic, ClaudeSession, @@ -45,21 +34,15 @@ import { applyClaudeStartFastMode } from './claude-structured-start-fast-mode' -export type StructuredAgentSessionStartedOptions = Pick< - StructuredAgentSessionStartedEvent, - | 'reportedOptions' - | 'restoreSkippedOptions' - | 'retiredOptions' - | 'optionRevision' - | 'catalogListing' +export type ClaudeOpenFacts = Pick< + StructuredAgentSessionProviderOpenResult, + 'reportedOptions' | 'catalogListing' > /** A lifecycle event the start reports, before the session's identity is stamped on it. */ -export type ClaudeStartupReport = - | ({ - type: 'started' | 'options-reported' - } & StructuredAgentSessionStartedOptions) - | Pick +export type ClaudeStartupReport = { + type: 'options-reported' +} & ClaudeOpenFacts /** What the initialize answer proved: enough to start. */ export type ClaudeInitializeFacts = { @@ -82,7 +65,6 @@ export async function readClaudeStartupFacts(input: { initProof: ClaudeInitProof sessionId: string providerSessionId: string - startup: Pick resumesTranscript: boolean account?: AgentSessionAccountKind requestTimeoutMs: number | undefined @@ -90,14 +72,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 +123,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 +170,7 @@ function claudeStartedReportedOptions( session: ClaudeSession, catalog: unknown[], readMutationSequence = session.optionMutationSequence -): Pick { +): Pick { const { current, catalogListing } = claudeStructuredSessionOptionsFrom( session, catalog, @@ -196,83 +184,44 @@ 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 + ) } - 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`. - const optionRevision = input.optionRevision() const settings = await input.readSettings() if (!input.isCurrent()) { return @@ -299,20 +248,13 @@ 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. - ...claudeResolvedCatalogListing(session, readClaudeModels(facts.initialization)), - restoreSkippedOptions: [...session.restoreSkippedOptions], - ...claudeRetiredOptions(session), - optionRevision + ...claudeResolvedCatalogListing(session, readClaudeModels(facts.initialization)) }) 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 e540b3de2dae..44895a4b0272 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -4,11 +4,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, - StructuredAgentSessionStartedEvent -} 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 @@ -25,7 +21,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 @@ -66,10 +63,7 @@ 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 | { type: 'ended' sessionId: string @@ -81,10 +75,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 ClaudeStartedSend = { clientMessageId: string; requestedAt?: number } @@ -216,8 +206,14 @@ 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 + 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..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 @@ -43,14 +43,13 @@ export function admitClaudeStartFastMode( return fastMode } -/** Applies a saved Fast the launch left out after `started`, so nothing waits on it. A refusal - * drops it as main's refused restore did, from the record too; silence keeps it wanted and +/** Applies a saved Fast the launch left out after open, so nothing waits on it. A refusal + * drops the child's value; silence keeps it wanted and * unconfirmed. A write the user made meanwhile owns the option. */ export async function applyClaudeStartFastMode( session: ClaudeSession, facts: ClaudeStartupFacts, - fastMode: string, - 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/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) } diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 87f87d583bef..6ed9144c6e9b 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -225,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(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..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 @@ -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,53 @@ 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, + deathEvidence: { + kind: 'pid-absent', + detail: 'reservation failed before spawn', + observedAt: now, + ownerFence: 6 + } + }) + ), + 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, 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..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,12 @@ export function createAgentModelCatalogService( ...(unavailable ? { unavailable } : {}) } } - if (params.savedOnly) { + // 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 || + (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-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 a77f72f1e9d8..841e99688866 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, @@ -51,6 +62,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 @@ -86,6 +98,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 88574cd829d3..6e3a091c0d30 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' @@ -302,7 +302,7 @@ export function buildJournalSubmissionRow(input: { handoverRecorded?: true queuedMessageId?: string origin?: 'client' | 'host' - source?: Pick + source?: AgentJournalSubmission['source'] options?: Readonly> }): JournalSubmissionRow { return { 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 1a86e39589ac..32ff3bb32337 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 @@ -75,6 +75,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.spec.ts b/src/main/native-chat/agent-session-journal/journal-store.spec.ts index f773e8cf8491..b31509da54fe 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.spec.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.spec.ts @@ -496,6 +496,12 @@ export function registerJournalStoreTests(): void { 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-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 29a033a4e7b9..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 @@ -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,16 @@ 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. + // The child exits before the host can finish its protocol open. 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' }) @@ -618,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() @@ -629,10 +640,7 @@ describe('a child that exits before its message is handed over', () => { describe('a start that fails while messages wait on it', () => { it('keeps one row in the words its rejected messages carry', async () => { // Both are held for a starting child that never proves its start. - acquire.mockImplementation(async (input) => ({ - ...(await spawnChild(input)), - providerChildPhase: 'starting' as const - })) + acquire.mockImplementation(spawnStartingChild) await host.close(SESSION, 'evict') await startHost() @@ -730,10 +738,7 @@ describe('Stop withdraws what is queued', () => { adapterExtras = { closeSession } await host.close(SESSION, 'evict') await startHost() - acquire.mockImplementationOnce(async (input) => ({ - ...(await spawnChild(input)), - providerChildPhase: 'starting' as const - })) + acquire.mockImplementationOnce(spawnStartingChild) // Held for the starting child, which never proves its start. const id = await accept('hello') await eventually(() => expect(childPhase()).toBe('starting')) @@ -799,7 +804,7 @@ describe('an eviction between acceptance and handover', () => { }) it('does not stop an idle child while a message is still queued for it (W24)', async () => { - idleMs = 0 + idleMs = 1_000 const started = deferred() await host.close(SESSION, 'evict') await startHost() @@ -809,9 +814,12 @@ describe('an eviction between acceptance and handover', () => { }) const id = await accept('hello') await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) - // The idle sweep ticks every few milliseconds meanwhile. + // The idle sweep ticks every few milliseconds meanwhile, past the idle window. + clock = NOW + idleMs await new Promise((resolve) => setTimeout(resolve, 20)) + // Fresh again before publish: a start quiet for the whole window is stopped by design. + clock = NOW started.resolve() // Handed to the child it was queued for; the eviction may follow once nothing is owed. await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(1)) @@ -842,10 +850,7 @@ describe('a close that stops the child and then a wind-down step fails', () => { await startHost() acquire.mockImplementationOnce(async (input) => { await started.promise - return { - ...(await spawnChild(input)), - ...(starting ? { providerChildPhase: 'starting' as const } : {}) - } + return (starting ? spawnStartingChild : spawnChild)(input) }) const id = await accept('hello') await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-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-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 a8abf52f02bb..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({ @@ -129,7 +128,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-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index cb842d04c846..bf319c5fffa3 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) @@ -163,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 cc563e75258c..b80908ab08cd 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 @@ -57,6 +57,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 @@ -110,7 +111,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 @@ -215,6 +216,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 @@ -222,40 +225,21 @@ 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 * 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 = | 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` @@ -286,6 +270,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. */ @@ -316,6 +302,7 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & recoverRewind?(input: { sessionId: string fence: number + signal?: AbortSignal beforeTurnId: string }): Promise< | { ok: true; items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] } @@ -407,8 +394,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-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index dd52d0f525d5..e8286aa43ec8 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 @@ -161,7 +161,6 @@ async function attach( probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - optionRevision: () => 0, params, now: () => NOW, onAttached 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-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index 96681b63f3b2..6c82fe5891a5 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 generationEnded?: StructuredAgentSessionLifetimeContext['generationEnded'] /** Joining a stop's close ends the child's record through the one exit handler. */ endExitedChild: StructuredAgentSessionLifetimeContext['endExitedChild'] 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 317b15ae9fe6..c8bd949b5614 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 @@ -56,6 +56,8 @@ 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 @@ -74,21 +76,18 @@ 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 /** 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. `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 or kept a live one. */ onAttached: ( attached: AttachedJournal, acquisitionGeneration: string | null, - acquiredOwner: boolean, + owner: AttachedOwner, providerChildPhase: StructuredAgentSessionProviderChildPhase ) => Promise | void /** Host-owned provider sink, bound to the journal inside `onAttached`. */ @@ -138,7 +137,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 @@ -210,7 +209,7 @@ export async function performAttach( record = acquired.record acquisitionGeneration = acquired.acquisitionGeneration providerChildPhase = acquired.providerChildPhase - acquiredOwner = true + owner = 'acquired' } } catch (error) { const wording = { @@ -271,7 +270,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 2fdc842b335f..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,14 +205,11 @@ 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 } : {}), 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') } @@ -221,19 +218,19 @@ 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 mode that child was launched with. - const permissionMode = acquiredOwner - ? attempt.startup?.permissionMode - : current?.permissionMode + 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 = 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, @@ -253,20 +250,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'), + // An acquire reports its phase; a re-attach keeps what its child already proved. + phase: owner === 'acquired' ? providerChildPhase : (current?.phase ?? 'ready'), ...(startedFor === undefined ? {} : { startedFor }), ...(permissionMode === undefined ? {} : { permissionMode }) } } - 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 { @@ -282,9 +283,10 @@ 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) + } 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 c6bcc3f887b9..0fa4b7c77099 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 @@ -74,6 +74,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 @@ -134,12 +135,15 @@ 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 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 @@ -167,7 +171,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-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-hung-start-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts index 347de6cf6643..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 @@ -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 } @@ -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-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-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts index 88e495b5dbdf..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' @@ -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,8 +31,11 @@ let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let acquire: Mock let activeModel: string -let optionWritable: Promise +let activeEffort: string | null +let dispatch: Mock +let order: string[] let setOption: Mock +let releaseOpen: () => void function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { return { @@ -47,14 +51,25 @@ 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 + } else if (key === 'effort') { + activeEffort = value + } + return { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) } + }) + dispatch = vi.fn(async () => { + order.push('dispatch') + return { state: 'admitted' as const } }) acquire = vi.fn(async ({ fence, spawnToken, options }) => { activeModel = options?.model ?? DEFAULT_MODEL return { + providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4200, processStartTimeMs: NOW, spawnToken }, + acquisitionGeneration: 'generation-1', link: { linkId: `claude-native-${fence}`, handle: claudeProviderHandle(CLAUDE_SESSION, 'native-leaf'), @@ -65,12 +80,18 @@ function adapter(): StructuredAgentSessionAdapter { } }) return { + open: () => + new Promise((resolve) => { + releaseOpen = () => + resolve({ + reportedOptions: { model: DEFAULT_MODEL } + }) + }), 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 +104,12 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-claude-options-')) resetHostTestOperationIds() activeModel = DEFAULT_MODEL - optionWritable = Promise.resolve() + activeEffort = null + order = [] + releaseOpen = () => {} store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter: adapter(), @@ -114,22 +137,51 @@ afterEach(async () => { await rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }) }) -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)) - expect(setOption).not.toHaveBeenCalled() +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']) +}) - land() - expect(await picked).toMatchObject({ ok: true, value: { options: { model: PICKED_MODEL } } }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL }) +it('saves a waiting startup pick when Stop ends the child before it is ready', async () => { + const fields = { key: 'model', value: PICKED_MODEL } + const picked = host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', fields), + ...fields }) + await host.collaboratorsForTests().serialize(SESSION, async () => {}) + try { + expect( + await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) + ).toMatchObject({ ok: true }) + expect(await picked).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.options?.model).toBe(PICKED_MODEL) + expect(setOption).not.toHaveBeenCalled() + } finally { + releaseOpen() + } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index cf7127966fe5..e49076f4882b 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-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index 726396adc21d..7b00385e7734 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/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..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 @@ -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) }) @@ -452,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-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 2e1ac52b45fb..7b8e12e67524 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-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 6ab7eb4c841f..ae9ebfaf86ea 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,12 +21,21 @@ 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' 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 @@ -71,9 +80,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { sessionId, atRest ) - if (closed) { - host.context().runtimeState.optionRevisions.forget(sessionId) - } return closed } @@ -111,35 +117,74 @@ 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) - ) { + 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, + 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 } - 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 - }) - ) - }, - /** Runs `task` in the chat's lane, so no start interleaves with it, unless quit has begun or - * the host holds a child, a delivery or a dispatch for the chat; null then. */ + 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) + } + }), + /** Runs `task` in the chat's lane only when execution is idle. */ runWhileExecutionIdle: (sessionId: string, task: () => Promise): Promise => serialize(sessionId, async () => disposed || 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 e295c44302a0..a4cd751a8778 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-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts index 2bee381140a5..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 @@ -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 }) @@ -214,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()) @@ -229,10 +228,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-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' } }) } 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 dcde8e5c9dba..375986fce14e 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,18 +31,21 @@ import type { } from './structured-agent-session-host-types' import { oldestQueuedSubmission, - recordStructuredAgentSessionStartFailure + 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' 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 type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' export type StructuredAgentSessionDeliveryLoopDeps = { + deliveryOrder: StructuredAgentSessionDeliveryOrder sessions: ReadonlyMap adapter: StructuredAgentSessionAdapter agents: StructuredAgentRegistry @@ -54,6 +58,10 @@ export type StructuredAgentSessionDeliveryLoopDeps = { sessionId: string, startedFor: string ) => Promise + readiness: ( + sessionId: string, + child: StructuredAgentSessionProviderChildIdentity + ) => StructuredAgentSessionStartReadiness | null /** A message's model and effort, applied before its agent starts or it is handed over. */ applyMessageOptions: ( sessionId: string, @@ -85,7 +93,12 @@ type RefusedStart = Extract type Prepared = | 'stop' | RefusedStart - | { ok: true; awaited: StructuredAgentSessionProviderChildIdentity | null } + | { + 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. */ type StartFailure = { startKey: string | null; cause: StructuredAgentSessionStartFailureCause } @@ -104,6 +117,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 @@ -113,6 +127,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() } @@ -146,8 +164,12 @@ export class StructuredAgentSessionDeliveryLoop { ) return } + if (prepared.readiness) { + await prepared.readiness.wait() + } + await prepared.before const handed = await this.deps.serialize(sessionId, () => - this.handOver(sessionId, prepared.awaited) + this.handOver(sessionId, prepared.awaited, prepared.readiness !== null) ) if (handed === 'stop') { return @@ -221,13 +243,16 @@ 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, + before: this.deps.deliveryOrder.beforeSend(sessionId, oldest.clientMessageId) } } 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) { @@ -259,9 +284,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. @@ -269,6 +292,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 this.deps.applyMessageOptions(sessionId, next.options) await handOverSubmission( { @@ -288,24 +321,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'> { @@ -349,25 +366,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' } } - -/** 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..8347f64c5127 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-order.ts @@ -0,0 +1,84 @@ +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' + +export type StructuredAgentSessionDeliveryPlace = { + 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 = { + 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() + } + + /** 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) { + 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-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 a2b30d57c301..a5d755b5c573 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,12 +6,12 @@ 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 { settleStructuredAgentSessionOptionsReported, - settleStructuredAgentSessionOptionsSkipped, settleStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' import { @@ -22,6 +22,7 @@ import { type StructuredAgentSessionGenerationEnded } 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() @@ -38,9 +39,28 @@ export class StructuredAgentSessionEventRecovery { now: () => number runtimeState: StructuredAgentSessionHostRuntimeState wakeDelivery: (sessionId: string) => Promise + stopStartingChild: ( + 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 { @@ -61,7 +81,9 @@ export class StructuredAgentSessionEventRecovery { route: { runtimeState, acknowledgeRelease: (sessionId) => deps.adapter.acknowledgeSessionRelease?.(sessionId) - } + }, + abortOpen: (sessionId) => + runtimeState.acquireAborts.abort(sessionId, 'provider exited while opening') } } @@ -117,9 +139,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 new file mode 100644 index 000000000000..ebcd15553a78 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts @@ -0,0 +1,64 @@ +// 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'] + +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. */ + prove(child: { sessionId: string; fence: number; acquisitionGeneration: string }): Promise + clear(): void +} + +export function structuredAgentSessionHeldStarts( + _host: () => { handleAdapterEvent(event: StructuredAgentSessionLifecycleEvent): Promise } +): StructuredAgentSessionHeldStarts { + const heldGenerations = new Map>() + let generations = 0 + 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.set(acquisitionGeneration, Promise.withResolvers()) + 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}` + 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) + } + return adapter.open + ? adapter.open(input) + : { + reportedOptions: { model: '' } + } + } + return { ...adapter, acquire, open } + }, + prove, + 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-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index b59b15b9f45c..dc1baf9fa0f0 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 @@ -13,12 +13,15 @@ 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' +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' @@ -61,6 +64,11 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessionId: string, startedFor: string ) => Promise + deliveryOrder: StructuredAgentSessionDeliveryOrder + 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 /** Read lazily: the host's mutation context is still assembling when this is built. */ @@ -103,6 +111,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { }) return input.ensureProviderChild(sessionId, startedFor) }, + readiness: input.readiness, + deliveryOrder: input.deliveryOrder, applyMessageOptions: (sessionId, options) => applyStructuredAgentSessionMessageOptions(input.mutationContext(), sessionId, options), conversationFence: (sessionId) => @@ -143,6 +153,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { } const wakesQueued = new Set() const afterCommit = (sessionId: string, journal: AgentSessionJournal): void => { + input.deliveryOrder.reconcile(sessionId, journal.submissions()) const work = hostStructuredAgentSessionCurrentWork({ store: deps.store, sessions }, sessionId) if ( wakesQueued.has(sessionId) || 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 3c2d07866786..0d18d7f96974 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 @@ -158,6 +158,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-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index e84a2941829e..bfe82d6883bf 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,7 @@ // admitted without the writer lease; the delivery loop starts the provider child a send needs, and // an operation only the provider can perform starts it before admission. +import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../shared/agent-chat-permission-mode' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult, @@ -21,18 +22,18 @@ import type { AgentSessionThreadGoalResult } from '../../../shared/agent-session-thread-goal-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, + runStructuredAgentSessionMutation, type StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import { openForProviderWrite, - openWithAgent, + 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, @@ -44,6 +45,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 { + runWithReadyStructuredAgentSessionOperation, + openWithReadyStructuredAgentSessionChild, + runAfterRewindStart +} from './structured-agent-session-ready-operation' export type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { @@ -76,23 +82,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 runAfterRewindStart(context, caller.callerKey, params.envelope, plan, (selection) => + runStructuredAgentSessionMutation( + 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, selection) + ) ) } @@ -193,42 +201,67 @@ export async function setStructuredAgentSessionOption( caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope; key: string; value: string } ): Promise> { - // The permission mode is only ever the record's: the next start launches with it. const recordOnly = params.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID - if (!recordOnly) { - // 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 = () => recordOnly || !context.sessions.get(params.envelope.sessionId)?.child - return mutateStructuredAgentSession( + const atRest = () => { + const child = context.sessions.get(params.envelope.sessionId)?.child + return recordOnly || !child || child.phase === 'starting' || child.close !== undefined + } + 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: (ctx) => writeStructuredAgentSessionOption(context, ctx, params) + }, + (ledger) => + atRest() + ? openForWrite(context, params.envelope)() + : openWithReadyStructuredAgentSessionChild(context, params.envelope, selection)(ledger) + ), { - ...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. - get conversationWrite() { - return atRest() ? (true as const) : undefined + needsAgent: () => { + const child = context.sessions.get(params.envelope.sessionId)?.child + return !recordOnly && Boolean(child && !child.close) }, - run: (ctx) => writeStructuredAgentSessionOption(context, ctx, params) - }, - openForProviderWrite(context, params.envelope) + runAfterAgentEnds: true + } ) } -export function changeStructuredAgentSessionThreadGoal( +export async function changeStructuredAgentSessionThreadGoal( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope; change: AgentSessionThreadGoalChange } ): Promise> { - return mutateStructuredAgentSession( + const plan = threadGoalPlan(params) + return runWithReadyStructuredAgentSessionOperation( context, - caller, + caller.callerKey, params.envelope, - threadGoalPlan(params), - openWithAgent(context, 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-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts index d604b805eced..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 @@ -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-stop-event.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts index 308aa76035e6..70111754842c 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 @@ -11,6 +11,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 @@ -20,6 +21,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-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 f1174e4cea10..867c2661ccb0 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 @@ -68,8 +69,8 @@ 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. */ 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 2395ac67d83b..c33ce49dfdf4 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 @@ -24,7 +24,7 @@ import { type StructuredAgentSessionConversationLifetime } from './structured-agent-session-conversation-lifetime' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' -import * as sessionTabs from './structured-agent-session-host-tabs' +import { createStructuredAgentSessionTabSurface as createTabs } from './structured-agent-session-host-tabs' import { structuredAgentSessionMutationDelegates, type StructuredAgentSessionMutationContext @@ -123,6 +123,8 @@ export class StructuredAgentSessionHost { trackStart: (start) => this.tasks.trackAttach(start), ensureProviderChild: (sessionId, startedFor) => agentStart.ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), + deliveryOrder: this.runtimeState.deliveryOrder, + readiness: (id, child) => this.runtimeState.startupAttempts.readiness(id, child), // Put to rest like an idle agent: the stop writes nothing, and the next start is new. stopOutgrownAgent: (id) => this.lifetime.stopAgent(id, { cause: 'evict', resting: true }), mutationContext: () => this.mutationContext(), @@ -149,7 +151,10 @@ 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), + failStartingChild: (...args) => this.lifetime.failStartingChild(...args), + trackOpen: (open) => this.tasks.trackAttach(open) }) this.restartResume = createStructuredAgentSessionRestartResume(deps, this.sessions, { ...structuredAgentSessionRestartResumeSurfaces(this, this.now), @@ -200,6 +205,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 } } @@ -216,11 +222,7 @@ export class StructuredAgentSessionHost { knownAgentIds = (): readonly string[] => providerSupport.knownAgentIds(this.deps) - private readonly tabs = sessionTabs.createStructuredAgentSessionTabSurface( - this, - this.sessions, - (sessionId) => this.clientDelivery.forgetStatus(sessionId) - ) + private readonly tabs = createTabs(this, this.sessions, this.clientDelivery.forgetStatus) listSessionTabs = this.tabs.listSessionTabs getPersistedVisibleSessionTabIndex = this.tabs.getPersistedVisibleSessionTabIndex getSessionTabId = this.tabs.getSessionTabId @@ -255,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() @@ -283,13 +284,14 @@ export class StructuredAgentSessionHost { openConversation: this.conversationDelivery.open, 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 new file mode 100644 index 000000000000..a1a99ce71836 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts @@ -0,0 +1,27 @@ +// Fake children answer the host-awaited protocol open immediately. + +import type { + StructuredAgentSessionAdapter, + StructuredAgentSessionLifecycleEvent +} from './structured-agent-session-adapter' + +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}` + return { ...acquired, acquisitionGeneration, providerChildPhase: 'starting' as const } + } + return { + ...adapter, + acquire, + open: + adapter.open ?? + (async () => ({ + reportedOptions: { model: '' } + })) + } +} 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-message-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-message-options.ts index 830ce2245dbd..a77502be02a8 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 @@ -27,7 +27,13 @@ export async function writeStructuredAgentSessionOption( .then(() => undefined), publish: () => session && context.publish(sessionId, session.journal) } - if (input.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID || !session?.child) { + const child = session?.child + if ( + input.key === AGENT_CHAT_PERMISSION_MODE_OPTION_ID || + !child || + child.phase === 'starting' || + child.close !== undefined + ) { return recordStructuredAgentSessionOptionIntent(context.deps, ctx, input) } // Held where a start is, so a close, a Stop admitted now or quit ends the wait from outside. @@ -36,8 +42,6 @@ export async function writeStructuredAgentSessionOption( return await performSetOption(ctx, input, wait.signal) } finally { wait.end() - // Whatever the write's outcome, the child may hold what it asked for now. - context.optionRevisions.advance(sessionId) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index d50ae450616e..dfc9be219537 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -260,8 +260,17 @@ async function answerRecordedOperation( } /** The recorded answer from the journal, or `rerun` when the plan says nothing durable landed. */ -function replayRecordedOperation( - { plan, envelope }: AgentSessionMutationRequest, +export function replayRecordedOperation( + { + plan, + envelope + }: { + plan: Pick< + MutationPlan, + 'replay' | 'rerunWhenReplayMissing' | 'recoverUnknownFromDurableState' + > + envelope: AgentSessionMutationEnvelope + }, context: AgentSessionTurnContext, row: AgentSessionOperationRow ): AgentSessionMutationResult | 'rerun' { 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 e43ca52c857c..d763018b8743 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,7 +15,8 @@ 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 { hostStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work' import type { StructuredAgentSessionCaller, @@ -39,6 +40,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 + 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 @@ -55,7 +58,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 } @@ -68,24 +70,35 @@ 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, - currentWork: () => - hostStructuredAgentSessionCurrentWork( - { store: context.deps.store, sessions: context.sessions }, - envelope.sessionId - ), - 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, + currentWork: () => + hostStructuredAgentSessionCurrentWork( + { store: context.deps.store, sessions: context.sessions }, + envelope.sessionId + ), + 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-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index ed578ec8025b..183e27db4f77 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 @@ -188,7 +188,7 @@ export function sendPlan( .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-mutation-turn-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts index 40ecfb34c6c1..e09cf4672566 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts @@ -6,7 +6,7 @@ import type { AgentSessionMutationRequest } from './structured-agent-session-mut import type { AgentSessionTurnContext } from './structured-agent-session-turns' export function mutationTurnContext( - request: AgentSessionMutationRequest, + request: Omit, 'plan'>, journal: AgentSessionJournal, record: AgentSessionRecord ): AgentSessionTurnContext { 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 67085dfbcf22..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 @@ -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 @@ -56,10 +54,21 @@ 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 } : {}) } + }) return { + providerChildPhase: 'starting' as const, + acquisitionGeneration, process: { hostId: 'local', pid: 4200 + acquire.mock.calls.length, @@ -149,7 +158,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, @@ -169,47 +178,11 @@ describe('structured session options and close', () => { expect(events).toEqual([ { type: 'snapshot', - sessions: [expect.objectContaining({ status: 'idle', model: DEFAULT_MODEL })] - } - ]) - 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 }) + sessions: [expect.objectContaining({ status: 'idle' })] } ]) - }) - - 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 @@ -239,3 +212,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-options-read.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.test.ts index 679240d8ebc8..b2a7f4157662 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,50 @@ function restingRecord(): AgentSessionRecord { } describe('options at rest', () => { + 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: { threadGoal: () => null, 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({ permissionMode: 'ask' }) + expect(read).toHaveBeenCalledTimes(1) + const older = await readStructuredAgentSessionOptions(context, SESSION, { + readsWithoutModel: false + }) + expect(older.current).toEqual({ model: 'gpt-account', permissionMode: 'ask' }) + 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 +102,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' } @@ -76,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 @@ -98,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), @@ -114,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 092bd2e7fe14..7764e1a48a17 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 @@ -99,6 +99,7 @@ async function readStructuredAgentSessionOptionsAtRest( ?.read({ agent: record.provider, sessionId, + savedOnly: true, ...(inSessionWorkspace ? { inSessionWorkspace: true as const } : {}) }) .catch(() => null)) ?? { origin: 'unknown' } @@ -197,7 +198,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 }) @@ -246,9 +248,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/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..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' @@ -175,29 +174,26 @@ 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) 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')) @@ -212,16 +208,20 @@ 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 // 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' })]) @@ -240,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?.({ @@ -278,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-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 c7a9997117e3..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 @@ -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, @@ -222,9 +224,7 @@ function prove(): Promise { type: 'started', ...child, acquisitionGeneration, - reportedOptions: { model: 'default' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + reportedOptions: { model: 'default' } }) } @@ -530,14 +530,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, @@ -558,12 +559,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 } @@ -592,8 +593,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-open-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts new file mode 100644 index 000000000000..981636f2caad --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts @@ -0,0 +1,21 @@ +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'] + catalogListing?: AgentModelCatalogLiveListing +} + +export type StructuredAgentSessionProviderOpen = ( + input: StructuredAgentSessionProviderOpenInput +) => Promise 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..fbc8ba797193 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -0,0 +1,59 @@ +import { expect, it, vi } from 'vitest' +import type { + StructuredAgentSessionProviderOpen, + StructuredAgentSessionProviderOpenResult +} from './structured-agent-session-provider-open-contract' +import { + CALLER, + attach, + attachParams, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { hostTestMessage } from './structured-agent-session-host-test-data' + +const OPENED: StructuredAgentSessionProviderOpenResult = { + reportedOptions: { model: 'gpt-test' } +} + +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('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-provider-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts new file mode 100644 index 000000000000..9e9020fdb1fc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts @@ -0,0 +1,64 @@ +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 { + attempt.signal.throwIfAborted() + 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 ?? '' + }, + attempt.signal + ) + } 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-provider-started-catalog.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts index 7543580e4304..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 @@ -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,34 @@ 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' } + }) +} + +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.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index e742f23fd58e..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 @@ -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' @@ -135,17 +134,19 @@ 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. 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') + expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() expect(lastPhase()).toBe('ready') }) @@ -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,9 +228,11 @@ 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(store.getRecord(SESSION)?.options?.model).toBeUndefined() expect(savedListings.at(-1)).toEqual({ sessionId: SESSION, listing: expect.objectContaining({ @@ -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 553015985e69..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 @@ -1,34 +1,27 @@ -// 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`). -// -// 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. +// 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, StructuredAgentSessionStartedEvent } from './structured-agent-session-adapter' import type { StructuredAgentSessionHostDeps, - StructuredAgentSessionHostSession + StructuredAgentSessionHostSession, + 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 { recoverStructuredRewind } from './structured-rewind-recovery' export type StructuredAgentSessionProviderStartedContext = { deps: StructuredAgentSessionHostDeps @@ -37,47 +30,121 @@ export type StructuredAgentSessionProviderStartedContext = { now: () => number publishStatus?: (sessionId: string) => void runtimeState: { - startupAttempts: Pick - optionRevisions: Pick< - StructuredAgentSessionOptionRevisions, - 'admitReport' | 'advance' | 'isNewest' - > + startupAttempts: Pick + acquireAborts: Pick } /** 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 - 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. - 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 recoverRewindAtStart(context, event.sessionId, session, signal))) { + context.stopStartingChild(event.sessionId, child) + return null + } + // The limit can pass while 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) + void 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 } + return undefined }) - if (!started) { - return +} + +/** 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, + signal?: AbortSignal +): Promise { + try { + await recoverStructuredRewind( + context.deps, + sessionId, + session.journal, + structuredAgentSessionConversationFence(context.deps.store, sessionId), + context.deps.adapter, + 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, + 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 } - // Not awaited: the adapter's next event may be what the handover itself waits on. - void started.delivered.then(() => persistReportedOptions(context, event, admitted)) } /** A proven start shows the agent's program exists and may mean a sign-in was fixed, so the @@ -94,144 +161,22 @@ 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 admitted = admitReportedOptions(context, event) - if (event.catalogListing) { - void saveStartReadbackListing(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 -): 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) - } + 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 }) - .catch((error: unknown) => { - context.deps.logger.warn('recording what a started provider reported failed', { - scope: 'provider-started-options', - sessionId: event.sessionId, - error - }) - }) -} - -/** 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. */ -export function settleStructuredAgentSessionOptionsSkipped( - 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) - ) { + if (!readiness || (await readiness.wait()) !== 'ready') { 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) - } - }) + } + if (event.catalogListing) { + void saveStructuredAgentSessionStartReadbackListing(context, event, event.catalogListing) + } + return undefined } 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 00665c2fc3d3..83b98e806eec 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-compact.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-compact.test.ts index c75bd129d7b3..d27770589aaf 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 @@ -27,7 +27,8 @@ let rig: QueuedMessageTestRig beforeEach(async () => { vi.useFakeTimers() - 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(async () => { 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 f1671e1bc9ff..dc00adacc46f 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 @@ -67,7 +67,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({ @@ -283,9 +283,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-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index afd6f4cd9ed8..6276a780f4bb 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 @@ -46,7 +46,8 @@ let dispatch: QueuedMessageTestRig['dispatch'] beforeEach(async () => { currentRig = undefined - 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 }) currentRig = rig ;({ host, store, dispatch } = rig) }) 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..1539248f2df0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts @@ -0,0 +1,98 @@ +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('rechecks a goal retry settled during startup, and replays it later without a child', async () => { + const { host, store, acquire } = hostTestState() + const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) + host.deps.adapter.changeThreadGoal = changeThreadGoal + await attach() + const change = { kind: 'set' as const, objective: 'Ship the parser' } + let params = { envelope: envelope('agentSession.threadGoal', { change }), change } + changeThreadGoal.mockRejectedValueOnce(new Error('not applied')) + await expect(host.changeThreadGoal(CALLER, params)).rejects.toThrow('not applied') + expect(await host.changeThreadGoal(CALLER, params)).toMatchObject({ ok: true, replayed: false }) + params = { envelope: envelope('agentSession.threadGoal', { change }), change } + changeThreadGoal.mockRejectedValueOnce(new Error('not applied')) + await expect(host.changeThreadGoal(CALLER, params)).rejects.toThrow('not applied') + await host.close(SESSION, 'evict') + let ready!: () => void + const open = vi.fn( + () => + new Promise<{ reportedOptions: { model: string } }>((resolve) => { + ready = () => resolve({ reportedOptions: { model: 'default' } }) + }) + ) + host.deps.adapter.open = open + const retry = host.changeThreadGoal(CALLER, params) + await vi.waitFor(() => expect(open).toHaveBeenCalledOnce()) + expect(changeThreadGoal).toHaveBeenCalledTimes(3) + await store.recordOperationOutcome({ + callerKey: CALLER.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId: SESSION } + }) + ready() + expect(await retry).toMatchObject({ ok: true, replayed: true }) + expect(changeThreadGoal).toHaveBeenCalledTimes(3) + expect(acquire).toHaveBeenCalledTimes(2) + await host.close(SESSION, 'evict') + expect(await host.changeThreadGoal(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(acquire).toHaveBeenCalledTimes(2) + expect(changeThreadGoal).toHaveBeenCalledTimes(3) +}) 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..99ecf106e64c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation.ts @@ -0,0 +1,310 @@ +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 { + replayRecordedOperation, + type AgentSessionMutationSessionPreparation +} from './structured-agent-session-mutation-admission' +import type { MutationPlan } from './structured-agent-session-mutation-plans' +import { mutationTurnContext } from './structured-agent-session-mutation-turn-context' +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< + MutationPlan, + 'method' | 'fields' | 'operationIdScope' | 'commandReceipt' +> & + Partial< + Pick, 'replay' | 'rerunWhenReplayMissing' | 'recoverUnknownFromDurableState'> + > +type QueuedReadyOperation = + | { result: AgentSessionMutationResult } + | { + child: StructuredAgentSessionProviderChildIdentity + readiness: StructuredAgentSessionStartReadiness + 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, + 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 + runAfterAgentEnds?: true + } = {} +): 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.commandReceipt) { + 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?.decision.decision === 'replay') { + if (!plan.replay) { + return { result: await run({ ok: true, child: null, replay: true }) } + } + const opened = await openForWrite(context, envelope)() + const journal = context.sessions.get(envelope.sessionId)?.journal + if (!opened.ok || !journal) { + return { result: await run({ ok: true, child: null, replay: true }) } + } + const request = { + store: context.deps.store, + adapter: context.deps.adapter, + agents: context.deps.agents, + logger: context.deps.logger, + callerKey, + envelope, + journal: () => journal, + publish: () => undefined, + now: () => context.now() + } + const current = context.deps.store.evaluateMutationOperation({ + callerKey, + envelope, + hostFingerprint, + now: context.now(), + ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}) + }) + if (current?.decision.decision !== 'replay') { + return { result: await run({ ok: true, child: null, replay: true }) } + } + const turn = mutationTurnContext(request, journal, current.record) + const answer = replayRecordedOperation( + { envelope, plan: { ...plan, replay: plan.replay } }, + turn, + current.decision.row + ) + if (answer !== 'rerun') { + return { result: await run({ ok: true, child: null, replay: true }) } + } + } else if (!evaluated || evaluated.decision.decision !== 'admit') { + return { + result: await run({ + ok: true, + child: null + }) + } + } + } + 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 } + : 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', { + scope: 'operation-agent-start', + sessionId: envelope.sessionId, + error + }) + return operationDidNotRun() + }) + 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) + return { + child: child.child, + readiness: child.readiness, + place + } + } + ) + if ('result' in selected) { + return selected.result + } + try { + const ready = selected.readiness.wait() + const outcome = await waitForStructuredAgentSessionStart( + Promise.all([ready, selected.place.before]).then(([outcome]) => + outcome === 'ready' ? 'ready' : 'ended' + ), + deadline - Date.now() + ) + if (outcome !== 'ready' && !(outcome === 'ended' && options.runAfterAgentEnds)) { + return operationDidNotRun(outcome === 'timeout') + } + if (Date.now() >= deadline) { + return operationDidNotRun(true) + } + 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 }) + ) + } 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-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 01639485bd25..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 @@ -30,7 +30,8 @@ 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' const CALLER = { callerKey: 'client-1' } @@ -64,34 +65,38 @@ 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, + agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, adapter, 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 351e49fab1be..830fa9b812eb 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 @@ -10,7 +10,6 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journa import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { agentSessionRefusalOperationState } from '../../../shared/agent-session-refusal-retry' import type { AgentSessionThreadGoalChange } from '../../../shared/agent-session-thread-goal-wire' -import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' 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' @@ -27,6 +26,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' } @@ -47,30 +47,39 @@ 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, events }) => { - providerEvents = events - return { - 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 + 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 }, - 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(), @@ -99,23 +108,9 @@ beforeEach(async () => { }, now: () => NOW }) - // One live task a background Stop reaches. - host.deps.statusSink = { publish: () => {}, forget: () => {}, readChildWork: () => [TASK] } expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) }) -const TASK: AgentChildWorkView = { - 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 } -} - /** The provider's running turn, which a Stop naming it reaches. */ async function runningTurn(): Promise { providerEvents?.appendItem( @@ -165,9 +160,11 @@ describe('a press sent again', () => { expect(await pick('A', first)).toMatchObject({ ok: true, replayed: true }) expect(setOption.mock.calls.map(([input]) => input.value)).toEqual(['A', 'B']) + expect(store.getRecord(SESSION)?.options?.model).toBe('B') expect(await pick('A')).toMatchObject({ ok: true, replayed: false }) expect(setOption.mock.calls.map(([input]) => input.value)).toEqual(['A', 'B', 'A']) + expect(store.getRecord(SESSION)?.options?.model).toBe('A') }) it('leaves a cleared goal cleared under the first id, and sets it under a new one', async () => { 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 927d725a494a..9f1691f10f74 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) // A person's own message keeps the words Orca recorded, not the provider's copy. @@ -398,10 +413,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 }) @@ -415,10 +427,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') @@ -436,10 +445,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') @@ -721,10 +727,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( @@ -752,10 +755,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') @@ -785,12 +785,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) @@ -819,10 +817,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 4c42f005e617..90ad6fe0521e 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 @@ -14,19 +14,25 @@ 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' import { hostStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work' import { conversationCommandBlocked } from './structured-conversation-command-admission' -import { rewindRefusal } from './structured-rewind-refusal' +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' import { mergeRetainedHostLifecycleRows, rewindProviderRows } from './structured-rewind-retained-host-rows' +import { rewindInDoubt } from './structured-agent-session-send-preparation' export async function rewindStructuredAgentSession( context: StructuredAgentSessionMutationContext, @@ -35,8 +41,13 @@ export async function rewindStructuredAgentSession( params: AgentSessionRewindParams ): Promise> { const { sessionId, clientOperationId } = params.envelope - const store = context.deps.store - return context.serialize(sessionId, async () => { + const { + readChildWork, + deps: { store } + } = context + 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, @@ -46,28 +57,14 @@ 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: openWithReadyStructuredAgentSessionChild(context, params.envelope, selection), journal: () => context.sessions.get(sessionId)?.journal, currentWork: () => hostStructuredAgentSessionCurrentWork({ store, sessions: context.sessions }, sessionId), 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, @@ -83,17 +80,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) @@ -102,13 +93,10 @@ export async function rewindStructuredAgentSession( 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))) { + if (conversationCommandBlocked(ctx, record, readChildWork(sessionId), 'handover')) { return rewindRefusal('busy') } const snapshot = ctx.journal.snapshot() @@ -299,5 +287,15 @@ export async function rewindStructuredAgentSession( 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-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-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-options.test.ts index 357d80224d3c..742f53d84da2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-options.test.ts @@ -97,10 +97,7 @@ describe('an opening message created with a model and effort', () => { options: Record = OPENED ) { const start = rest.adapter.acquire.getMockImplementation()! - rest.adapter.acquire.mockImplementationOnce(async (input) => ({ - ...(await start(input)), - providerChildPhase: 'starting' - })) + rest.adapter.acquire.mockImplementationOnce(rest.heldStart(start)) const first = { clientMessageId: hostTestOperationId(), body: hostTestMessage('opening') } const params = createTestParams(first, { options }) expect( @@ -116,11 +113,7 @@ describe('an opening message created with a model and effort', () => { fence: rest.store.getRecord(SESSION)!.lease.runtimeFence, acquisitionGeneration: (await rest.adapter.acquire.mock.results[0]!.value) .acquisitionGeneration, - reportedOptions: options, - restoreSkippedOptions: [], - optionRevision: rest.host - .collaboratorsForTests() - .runtimeState.optionRevisions.current(SESSION) + reportedOptions: options }) return { first, proveStart } } @@ -140,10 +133,7 @@ describe('an opening message created with a model and effort', () => { fields }) } - expect(await rest.host.setOption(REST_TEST_CALLER, { envelope, ...fields })).toMatchObject({ - ok: true - }) - expect(rest.store.getRecord(SESSION)?.options?.model).toBe('sonnet') + const picked = rest.host.setOption(REST_TEST_CALLER, { envelope, ...fields }) const ranOn: (string | undefined)[] = [] rest.adapter.dispatch.mockImplementationOnce(async () => { ranOn.push(rest.store.getRecord(SESSION)?.options?.model) @@ -152,6 +142,8 @@ describe('an opening message created with a model and effort', () => { await proveStart() await vi.waitFor(() => expect(rest.adapter.dispatch).toHaveBeenCalledOnce()) expect(ranOn).toEqual(['opus']) + expect(await picked).toMatchObject({ ok: true }) + expect(rest.store.getRecord(SESSION)?.options?.model).toBe('sonnet') } finally { await rest.dispose() } @@ -203,20 +195,24 @@ describe('a send carrying its model and effort', () => { await eventually(() => expect(starting.starts).toHaveBeenCalled()) // Picked while the message waits for its agent to start. const fields = { key: 'model', value: 'gpt-b' } - await starting.host.setOption(CALLER, { + const picked = starting.host.setOption(CALLER, { envelope: starting.envelope(fields, 'agentSession.setOption', hostTestOperationId()), ...fields }) - expect(starting.store.getRecord(SESSION)?.options?.model).toBe('gpt-b') + const setOption = vi.mocked(starting.setOption) + starting.dispatch.mockImplementationOnce(async () => { + expect(setOption.mock.calls.at(-1)?.[0]).toMatchObject({ key: 'model', value: 'gpt-a' }) + expect(setOption.mock.invocationCallOrder.at(-1)).toBeLessThan( + starting.dispatch.mock.invocationCallOrder.at(-1) ?? 0 + ) + return acceptedDispatch() + }) await starting.proveStart() await eventually(async () => expect((await starting.submission(sent.id))?.handedOverAt).toBeDefined() ) - const setOption = vi.mocked(starting.setOption) - expect(setOption.mock.calls.at(-1)?.[0]).toMatchObject({ key: 'model', value: 'gpt-a' }) - expect(setOption.mock.invocationCallOrder.at(-1)).toBeLessThan( - starting.dispatch.mock.invocationCallOrder.at(-1) ?? 0 - ) + expect(await picked).toMatchObject({ ok: true }) + expect(starting.store.getRecord(SESSION)?.options?.model).toBe('gpt-b') } finally { await starting.dispose() } 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..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,12 +29,15 @@ import { import { agentSessionOperationOutcomeUnknown } from './structured-agent-session-replay-outcome' import { rewindRefusal } from './structured-rewind-refusal' import { recoverStructuredRewind } from './structured-rewind-recovery' -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' } @@ -96,28 +103,8 @@ export function openForProviderWrite( } } -/** For an operation only the provider can perform: the conversation, then its agent. */ -export function openWithAgent( - context: Pick, - envelope: AgentSessionMutationEnvelope, - beforeAgent?: () => AgentSessionMutationSessionPreparation -): (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?.() - return checked && !checked.ok ? checked : context.ensureAgent(envelope.sessionId) - } -} - -/** 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 +135,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 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, * 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 @@ -158,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) { @@ -177,21 +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 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 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 fb2f9beae728..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 @@ -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 } @@ -103,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' } }) } @@ -142,6 +138,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 +148,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) @@ -198,7 +194,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 +222,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 +258,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-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-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index 9206f95fde0b..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,10 +1,41 @@ +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' 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 & { @@ -43,24 +74,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 } } : {}) }) } @@ -78,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-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-start-readiness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-readiness.ts new file mode 100644 index 000000000000..da7f7fad76b3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-readiness.ts @@ -0,0 +1,22 @@ +export type StructuredAgentSessionStartReadiness = { + wait: (budgetMs?: number) => Promise<'ready' | 'ended' | 'timeout'> +} + +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) + } +} 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 cac22dd2ebfe..cd7b1dd37f83 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(async () => ({ origin: 'unknown' as const })), @@ -135,7 +131,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 }) @@ -182,41 +177,16 @@ async function submission(id: string): Promise { const id = await accept(text) @@ -380,7 +350,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') @@ -416,22 +386,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'), 50) - 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 @@ -468,149 +423,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 vi.advanceTimersByTimeAsync(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 vi.advanceTimersByTimeAsync(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 vi.advanceTimersByTimeAsync(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' }) }) }) @@ -632,9 +476,6 @@ describe('the attempt an acquire runs under', () => { 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 64c681c9f9ed..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 @@ -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,20 +164,12 @@ 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) + opened.resolve({ + reportedOptions: { model: 'default' } }) 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 ddf04b4b0e73..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 @@ -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 b704ec9eee30..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 @@ -11,6 +11,11 @@ // attempt ends nothing. import { randomUUID } from 'node:crypto' +import { + 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' @@ -30,7 +35,6 @@ export function mintStructuredAgentSessionStartupAttempt(input: { permissionMode?: AgentChatPermissionMode events?: StructuredAgentSessionEventSink signal?: AbortSignal - optionRevision: () => number }): StructuredAgentSessionStartupAttempt { const { record } = input return { @@ -46,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 } : {}) } } @@ -97,6 +100,7 @@ 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'> } /** Each session's open attempt and its startup clock. A session starts one child at a time, so a @@ -134,7 +138,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,12 +163,26 @@ export class StructuredAgentSessionStartupAttempts { } } - /** The acquire returned: a `starting` child stays on the clock until it proves its start, and + /** 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: (budgetMs) => waitForStructuredAgentSessionStart(tracked.readiness.promise, budgetMs) + } + } + + /** The acquire returned: its `starting` child stays on the clock until it proves its start, and * one published after the limit passed is expired now. */ published( sessionId: string, attemptId: string, - child: StructuredAgentSessionProviderChildIdentity & { phase: 'starting' | 'ready' } + child: StructuredAgentSessionProviderChildIdentity & { phase?: 'starting' | 'ready' } ): void { const tracked = this.open.get(sessionId) if (tracked?.attempt.attemptId !== attemptId) { @@ -184,6 +203,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) @@ -217,6 +248,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) } @@ -281,6 +313,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) ) @@ -313,6 +346,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-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 62bb50c17d22..3101059e85c7 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,6 +15,8 @@ import type { AgentSessionThreadGoalChange, AgentSessionThreadGoalResult } from '../../../shared/agent-session-thread-goal-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' import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' @@ -18,10 +24,24 @@ import { contextStructuredAgentSessionCurrentWork } from './structured-agent-ses 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}` } @@ -48,8 +68,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 0c0e50bda6da..2239a233ca6f 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 @@ -171,7 +171,7 @@ export async function performSend( if (existing) { return { ok: true, - value: { clientMessageId: input.clientMessageId, submission: existing } + value: { clientMessageId: input.clientMessageId, submission: { ...existing } } } } try { @@ -309,5 +309,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 329f4405597f..edc37fbd6535 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 @@ -176,7 +176,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 @@ -202,7 +202,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..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 @@ -105,7 +105,20 @@ 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: '' } + // Never admitted, so the empty report persists nothing. + }) 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 af2338f631a1..4d8a863d379f 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,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 { + runAfterRewindStart, + type ReadyOperationSelection +} from './structured-agent-session-ready-operation' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import { hostStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work' import { conversationCommandBlocked } from './structured-conversation-command-admission' @@ -50,6 +54,21 @@ export function runStructuredConversationCommand( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, params: ConversationCommandParams +): Promise> { + return runAfterRewindStart( + context, + caller.callerKey, + params.envelope, + { method: 'agentSession.conversationCommand', fields: { command: params.command } }, + (selection) => clearUnderSerialize(context, caller, params, selection) + ) +} + +async function clearUnderSerialize( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: ConversationCommandParams, + selection: ReadyOperationSelection ): Promise> { const { envelope, command } = params const { sessionId, clientOperationId } = envelope @@ -60,96 +79,94 @@ export function runStructuredConversationCommand( ? 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, - currentWork: () => - hostStructuredAgentSessionCurrentWork({ store, sessions: context.sessions }, sessionId), - 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, + currentWork: () => + hostStructuredAgentSessionCurrentWork({ store, sessions: context.sessions }, sessionId), + 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 519a22bd3b5b..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,6 +26,10 @@ import { structuredAgentSessionFailureWordsContext, structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' +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, @@ -146,7 +150,20 @@ function acceptStructuredConversationCommand( ...(params.delivery ? { delivery: params.delivery } : {}), priorRecord }) - return mutateStructuredAgentSession( + return runAfterRewindStart(context, caller.callerKey, params.envelope, plan, (selection) => + acceptUnderSerialize(context, caller, params, plan, arrival, selection) + ) +} + +function acceptUnderSerialize( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: ConversationCommandParams, + plan: ReturnType, + arrival: { clearInFlight?: boolean }, + selection: ReadyOperationSelection +): Promise> { + return runStructuredAgentSessionMutation( context, caller, params.envelope, @@ -193,6 +210,6 @@ function acceptStructuredConversationCommand( return plan.run(ctx) } }, - sendPreparation(context, params.envelope, arrival) + sendPreparation(context, params.envelope, arrival, selection) ) } 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..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,5 @@ +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import { mergeRetainedHostLifecycleRows, retainedRowReplacement @@ -5,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' @@ -78,8 +81,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 +96,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. @@ -176,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-refusal.ts b/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts index af309b13f405..aa37ca23fb4a 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-refusal.ts @@ -1,8 +1,10 @@ import { AGENT_SESSION_REWIND_REASONS, + type AgentSessionRewindParams, type AgentSessionRewindReason } from '../../../shared/agent-session-rewind' import { refuse, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' export function rewindRefusal(reason: AgentSessionRewindReason): { ok: false @@ -27,3 +29,20 @@ export function rewindRefusal(reason: AgentSessionRewindReason): { ) } } + +/** A rewind to a row a /clear left behind, or against an epoch since moved, is refused before any + * agent starts for it; null when the journal has no clear to check against. */ +export function rewindRefusalBehindClear( + journal: Pick | undefined, + target: Pick +): ReturnType | null { + const floor = journal?.context.floor() + if (!journal || !floor) { + return null + } + if (journal.cursor().epoch !== target.expectedEpoch) { + return rewindRefusal('stale-epoch') + } + const item = journal.snapshot().items.find((entry) => entry.itemId === target.itemId) + return !item || item.sequence <= floor.sequence ? rewindRefusal('invalid-target') : null +} diff --git a/src/main/native-chat/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/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 cbbdeca2eaf1..0bd72c8f2cc9 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -745,9 +745,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 585f003a01a1..6115678b5b7a 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -211,7 +211,7 @@ export class AgentSessionRecordStore { async proveOwner(args: { sessionId: string fence: number - link: AgentSessionProviderHandleLink + link?: AgentSessionProviderHandleLink now: number leaseTtlMs?: number options?: Readonly> @@ -220,7 +220,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-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-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/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') } 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 45baed003b13..0aad485b09b3 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -281,6 +281,7 @@ beforeEach(async () => { afterEach(async () => { vi.unstubAllEnvs() + claude.setSelfExit(null) await stopStructuredAgentSessionRuntime() await rm(root, { recursive: true, force: true }) }) @@ -402,7 +403,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) }) @@ -410,7 +413,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', @@ -419,18 +422,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, @@ -438,7 +436,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) @@ -447,7 +445,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. @@ -456,22 +454,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. @@ -479,15 +470,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)( @@ -797,14 +789,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/claude-structured-startup-unanswered-control-request.test.ts b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts index c68cdb887a3d..5de41a0a3a46 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 } @@ -109,7 +118,7 @@ describe('a Claude start whose CLI answers initialize but not a control request' claude.behave(SESSION, { ...LIVE_START, optionWritesHang: true, controlTimeoutMs: DEADLINE_MS }) vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() - const saved = { model: 'sonnet', permissionMode: 'accept-edits' } + const saved = { model: 'sonnet', effort: 'high', permissionMode: 'accept-edits' } await expect( host.attach(CALLER, claude.attachParams(SESSION, null, { options: saved })) ).resolves.toMatchObject({ ok: true }) @@ -122,10 +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({ model: 'sonnet', effort: 'high' }), - { timeout: DEADLINE_MS * 40 } - ) + await ready(host) + expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high' }) expect(record(host)?.permissionMode).toBe('accept-edits') expect(host.deps.adapter.readOptionRestoreFailures?.(SESSION)).toEqual([]) expect(await statusRows(host)).toEqual([]) @@ -145,12 +152,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 @@ -170,24 +173,22 @@ 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'] }) - // 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', effort: 'high' }) + expect(record(host)?.options).toEqual({ model: 'sonnet' }) behavior.optionWritesHang = false + await setOption(host, 'effort', 'high') // 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' }) @@ -241,12 +242,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 +253,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) 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 c6138a8a568c..1ea7cf7e9850 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 @@ -54,7 +54,6 @@ export async function startLocalWorker(args: { const terminal = args.params.terminal && resolveDispatchAssigneeParty(args.params.terminal, db) const params = terminal ? { ...args.params, terminal: terminal.address } : args.params const chat = terminal ? chatAssigneeOf(terminal) : null - const coordinatorPane = coordinator?.paneKey ?? null const requestedWorktree = params.worktree ?? 'current' const createsWorktree = requestedWorktree === 'new-child' || requestedWorktree === 'new-top-level' const launchParams = await resolveWorkerConfiguredAgentParams(runtime, params, async () => { @@ -156,7 +155,7 @@ export async function startLocalWorker(args: { taskRunId: run.id, // A handle-less session creates root Tasks: Task lineage is recorded by terminal only. taskCreatedByTerminalHandle: coordinator?.terminalHandle ?? undefined, - taskCreatedByPaneKey: coordinatorPane ?? undefined, + taskCreatedByPaneKey: coordinator?.paneKey ?? undefined, taskCreatedByProcessIncarnation: coordinator?.terminalHandle ? (runtime.getTerminalProcessIncarnation(coordinator.terminalHandle) ?? undefined) : undefined, @@ -201,8 +200,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, @@ -219,15 +217,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. @@ -284,6 +283,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-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts index 06bf5e4e74ff..d155df5d48d8 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,9 +111,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', @@ -173,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 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-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index ac6fb23836ab..c7daffeb620a 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 { existsSync } from 'node:fs' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' @@ -23,9 +23,6 @@ import { } from './structured-agent-session-runtime-teardown' import { AgentSessionRecoveryCapsule } from './agent-session-recovery-capsule' import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' -import type { CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' -import type { readCodexCliInstallation } from '../preflight/codex-cli-installation' -import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' import { StructuredAgentSessionHost, type StructuredAgentSessionHostDeps @@ -85,7 +82,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 @@ -96,7 +93,6 @@ export type StructuredAgentSessionRuntimeDeps = { resolveWorkspacePath: (workspaceId: string) => Promise resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string resolveClaudeCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string - openPiConnection?: PiRpcSessionDeps['openConnection'] /** Which version-gated flags a Claude CLI takes; absent never passes one. */ claudeCliFlags?: ClaudeCliFlagSupport /** Gives each chat a scratch folder and the visuals skill that teaches it, and sweeps folders @@ -106,13 +102,6 @@ export type StructuredAgentSessionRuntimeDeps = { /** Read this host's preference when a chat starts a provider process. */ isEnabled: () => boolean } - /** Provider transports are overridden only to drive the runtime against scripted children. */ - openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] - /** Scripted transports must also replace the real host's version probe. */ - readCodexInstallation?: typeof readCodexCliInstallation - 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. */ @@ -325,6 +314,7 @@ async function installOnJournal( ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), + ...(deps.startupLimits ? { startupLimits: deps.startupLimits } : {}), ...(deps.onSessionTabHidden ? { onSessionTabHidden: deps.onSessionTabHidden } : {}), ...(await modelCatalogHostDeps({ store, diff --git a/src/main/runtime/structured-agent-session-transport-overrides.ts b/src/main/runtime/structured-agent-session-transport-overrides.ts new file mode 100644 index 000000000000..b76393f0a8a5 --- /dev/null +++ b/src/main/runtime/structured-agent-session-transport-overrides.ts @@ -0,0 +1,18 @@ +import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' +import type { CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' +import type { readCodexCliInstallation } from '../preflight/codex-cli-installation' +import type { StructuredAgentSessionStartupLimits } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' +import type { PiRpcSessionDeps } from '../pi/rpc-session' + +/** Provider transports are overridden only to drive the runtime against scripted children. */ +export type StructuredAgentSessionTransportOverrides = { + openPiConnection?: PiRpcSessionDeps['openConnection'] + openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] + /** Scripted transports must also replace the real host's version probe. */ + readCodexInstallation?: typeof readCodexCliInstallation + 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/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 463b9a36006f..cdbc9af0e0b2 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') { 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 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/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx index ba932938fc72..a658e6af9b8c 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -636,12 +636,17 @@ describe('NativeChatSessionOptionPickers', () => { expect(pill('Model Opus 4.8, Effort Med')?.textContent).toBe('Opus 4.8Med') }) - it('shows a pick at once and holds every write control until it settles', async () => { + it('shows concurrent model and effort picks and releases only the settled descriptor', async () => { let settleWrite: () => void = () => {} + let settleModel: () => void = () => {} const setOption = vi.fn( - () => + (id: string) => new Promise<{ snapshot: SessionOptionDescriptor[] }>((resolve) => { - settleWrite = () => resolve({ snapshot: [] }) + if (id === 'model') { + settleModel = () => resolve({ snapshot: [] }) + } else { + settleWrite = () => resolve({ snapshot: [] }) + } }) ) render( @@ -665,21 +670,26 @@ describe('NativeChatSessionOptionPickers', () => { expect(screen.getByRole('radio', { name: 'Low' }).getAttribute('aria-checked')).toBe('true') ) expect(pill('Model Opus 4.8, Effort Low')).not.toBeNull() - // The list reports busy and the options unavailable; the surface would drop a second write. - expect(held()).toEqual(['true', 'true']) + // Effort saving leaves the model usable. + expect(held()).toEqual(['false', 'true']) screen.getByRole('option', { name: 'Sonnet 5' }).click() - expect(setOption).toHaveBeenCalledExactlyOnceWith('effort', 'low') + expect(setOption.mock.calls).toEqual([ + ['effort', 'low'], + ['model', 'sonnet'] + ]) + await waitFor(() => expect(held()).toEqual(['true', 'true'])) + screen.getByRole('radio', { name: 'High' }).click() + expect(setOption).toHaveBeenCalledTimes(2) // The snapshot is the truth once the write settles; here it still says High. await act(async () => settleWrite()) - await waitFor(() => expect(held()).toEqual(['false', 'false'])) + await waitFor(() => expect(held()).toEqual(['true', 'false'])) expect(screen.getByRole('radio', { name: 'High' }).getAttribute('aria-checked')).toBe('true') - - screen.getByRole('option', { name: 'Sonnet 5' }).click() - expect(setOption).toHaveBeenLastCalledWith('model', 'sonnet') + await act(async () => settleModel()) + await waitFor(() => expect(held()).toEqual(['false', 'false'])) }) - it("holds the model's own action while a write is in flight, without closing on it", async () => { + it('keeps the model action usable while another descriptor saves', async () => { let settleWrite: () => void = () => {} const setOption = vi.fn( () => @@ -706,14 +716,14 @@ describe('NativeChatSessionOptionPickers', () => { expect(unavailable()).toBe('false') screen.getByRole('switch', { name: 'Fast mode' }).click() - await waitFor(() => expect(unavailable()).toBe('true')) - // fireEvent flushes the render, so a picker that closed here would read as closed. + await waitFor(() => + expect(screen.getByRole('switch', { name: 'Fast mode' }).getAttribute('aria-checked')).toBe( + 'true' + ) + ) + expect(unavailable()).toBe('false') fireEvent.click(action) - expect(invokeAction).not.toHaveBeenCalled() - expect(isOpen(trigger)).toBe(true) - await act(async () => settleWrite()) - fireEvent.click(action) await waitFor(() => expect(invokeAction).toHaveBeenCalledExactlyOnceWith('model')) expect(isOpen(trigger)).toBe(false) }) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx index 0c1b17ed5d06..e243d2230672 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx @@ -34,7 +34,7 @@ export type NativeChatSessionOptionPickersProps = { focusComposer?: () => void } -/** A write in flight. Its value shows at once, and nothing else is sent until it settles. */ +/** A descriptor write in flight; its value shows until it settles. */ type PendingPick = { id: string; value?: SessionOptionValue } // Why: the picker stays open across a pick, so the choice has to land where it was made before @@ -130,14 +130,14 @@ function SessionOptionPopover(props: { model: SessionOptionDescriptor options: readonly SessionOptionDescriptor[] isWorking: boolean - /** A write is in flight: nothing else is taken, and the write controls say so. */ - busy: boolean + /** Only the descriptor being saved holds its write controls. */ + pendingIds: ReadonlySet request: NativeChatOptionPickerRequest | null onSet: (descriptor: SessionOptionDescriptor, value: SessionOptionValue) => void onAction: (descriptor: SessionOptionDescriptor) => void focusComposer?: () => void }): React.JSX.Element { - const { model, options, busy } = props + const { model, options, pendingIds } = props // Why here: this unmounts with the pill, so a picker that went away comes back closed. const [open, setOpen] = useState(false) const [seenRequest, setSeenRequest] = useState(null) @@ -162,9 +162,9 @@ function SessionOptionPopover(props: { ? nativeChatSessionOptionDisabledReason(options[0]?.disabledReason) : null - // Why not sent while busy: the surface drops a write made while another is in flight. + // Different descriptors can save together; hold only a repeat of the same pick. const set = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => { - if (busy) { + if (pendingIds.has(descriptor.id)) { return } focusReturn.notePick() @@ -177,7 +177,7 @@ function SessionOptionPopover(props: { } } const act = (descriptor: SessionOptionDescriptor): void => { - if (busy) { + if (pendingIds.has(descriptor.id)) { return } // Why: agent-picker opens the TUI, and that picker owns focus. @@ -250,11 +250,14 @@ function SessionOptionPopover(props: { currentValue={model.kind.currentValue} readOnly={!model.settable} readOnlyReason={modelReason} - busy={busy} + busy={pendingIds.has(model.id)} onSelect={(value) => set(model, value)} /> ) : ( - + {modelReason && !model.settable ? (
{modelReason}
) : null} @@ -273,7 +276,7 @@ function SessionOptionPopover(props: { @@ -289,7 +292,7 @@ function NativeChatSessionOptionPickersInner({ pickerRequest, focusComposer }: NativeChatSessionOptionPickersProps): React.JSX.Element | null { - const [pending, setPending] = useState(null) + const [pending, setPending] = useState>(() => new Map()) // Why: the session holds a request across composer remounts; one left standing reopens the // picker each time a prompt card gives the composer back. Dropped even when nothing could open. useEffect(() => pickerRequest?.consume(), [pickerRequest]) @@ -297,26 +300,40 @@ function NativeChatSessionOptionPickersInner({ if (!surface || !reportedModel) { return null } - const model = withPendingValue(reportedModel, pending) + const model = withPendingValue(reportedModel, pending.get(reportedModel.id) ?? null) const options = sortNativeChatSessionOptions(snapshot).map((descriptor) => - withPendingValue(descriptor, pending) + withPendingValue(descriptor, pending.get(descriptor.id) ?? null) ) + const markPending = (id: string) => (pick: PendingPick | null) => { + setPending((current) => { + const next = new Map(current) + if (pick === null) { + next.delete(id) + } else { + next.set(id, pick) + } + return next + }) + } + return (
- runSessionOptionSurfaceCall({ id: descriptor.id, value }, setPending, () => - surface.setOption(descriptor.id, value) + runSessionOptionSurfaceCall( + { id: descriptor.id, value }, + markPending(descriptor.id), + () => surface.setOption(descriptor.id, value) ) } onAction={(descriptor) => - runSessionOptionSurfaceCall({ id: descriptor.id }, setPending, () => + runSessionOptionSurfaceCall({ id: descriptor.id }, markPending(descriptor.id), () => surface.invokeAction(descriptor.id) ) } diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionRows.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionRows.tsx index 4f708a47a74d..f6b429c7b034 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionRows.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionRows.tsx @@ -23,8 +23,8 @@ type NativeChatSessionOptionRowsProps = { options: readonly SessionOptionDescriptor[] /** A reason already shown above the list, so a row does not repeat it. */ shownReason: string | null - /** A write is in flight; the controls take nothing until it settles. */ - busy: boolean + /** Each pending descriptor holds only its own controls. */ + pendingIds: ReadonlySet onSet: (descriptor: SessionOptionDescriptor, value: SessionOptionValue) => void /** A value-less action; it has nothing to show in place, so it closes the picker. */ onAction: (descriptor: SessionOptionDescriptor) => void @@ -161,7 +161,7 @@ function OptionRow(props: { export function NativeChatSessionOptionRows({ options, shownReason, - busy, + pendingIds, onSet, onAction }: NativeChatSessionOptionRowsProps): React.JSX.Element { @@ -197,24 +197,29 @@ export function NativeChatSessionOptionRows({
{rows.length > 0 ? (
- +
{rows.map((descriptor) => { const reason = nativeChatSessionOptionDisabledReason(descriptor.disabledReason) return ( -
- option.id === descriptor.id)} - onSet={(value) => onSet(descriptor, value)} - onAction={() => onAction(descriptor)} - /> - {reason && !descriptor.settable && reason !== shownReason ? ( -
{reason}
- ) : null} -
+ +
+ option.id === descriptor.id)} + onSet={(value) => onSet(descriptor, value)} + onAction={() => onAction(descriptor)} + /> + {reason && !descriptor.settable && reason !== shownReason ? ( +
{reason}
+ ) : null} +
+
) })} - +
) : null}
diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionWriteGroup.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionWriteGroup.tsx index 4355a43bf456..32e5a5fe83c6 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionOptionWriteGroup.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionWriteGroup.tsx @@ -1,6 +1,6 @@ import { cn } from '@/lib/utils' -/** Controls that write to the session. While another write is in flight they take nothing and say +/** Controls that write to the session. While their write is in flight they take nothing and say * so, without `disabled`: that would drop keyboard focus from the control just used. The dimming * waits out a local write, so only a slow one is seen waiting (STYLEGUIDE, in-flight feedback). */ export function NativeChatSessionOptionWriteGroup(props: { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index e3e157ad057c..bb3769d74376 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -89,7 +89,8 @@ export function NativeChatStructuredSession( hostStopping: hostExecution.stopping, hostOptionStamp: hostExecution.optionStamp, 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 0d39b2f1198a..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 @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { act, renderHook, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' import type * as LaunchIntentModule from '@/lib/launch-structured-agent-session' @@ -56,6 +56,11 @@ import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-la import { useStructuredAgentSession } from './use-structured-agent-session' import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import { AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' +import { getStructuredAgentSessionLaunchSelection } from '@/lib/structured-agent-session-launch-options' +import { resetStructuredAgentSessionSendsForTests } from './structured-agent-session-message-sender' + const LOCAL_TARGET = { kind: 'local' } as const // The account's saved list: the picker names a saved pick only from a list the host gave. const SAVED_LIST = { @@ -110,17 +115,23 @@ function publishedSnapshot(worktreeId: string, sessionId: string) { } /** The pane's wiring: the launch's view feeds the chat controller. */ -function renderLaunchedChat(worktreeId: string, sessionId: string) { - return renderHook(() => { - const provisional = useNativeChatProvisionalLaunch(worktreeId, sessionId) - return useStructuredAgentSession({ - sessionId, +function renderLaunchedChat( + worktreeId: string, + sessionId: string, + agent: 'codex' | 'claude' = 'codex' +) { + return renderHook((selectedSessionId: string = sessionId) => { + const provisional = useNativeChatProvisionalLaunch(worktreeId, selectedSessionId) + const chat = useStructuredAgentSession({ + sessionId: selectedSessionId, target: LOCAL_TARGET, - agent: 'codex', + agent, + providerStarting: true, isVisible: true, transportEnabled: provisional.transportEnabled, ...(provisional.launch ? { launch: provisional.launch } : {}) }) + return { ...chat, launchView: provisional.launch } }) } @@ -132,6 +143,8 @@ function currentModel(snapshot: readonly { id: string; kind: { type: string } }[ describe('a chat pane over its own launch', () => { beforeEach(() => { vi.clearAllMocks() + resetStructuredAgentSessionSendsForTests() + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY]) localStorage.clear() resetStructuredAgentLaunchPersistenceForTests() resetStructuredAgentLaunchRegistryForTests() @@ -142,6 +155,86 @@ describe('a chat pane over its own launch', () => { resetHostModelCatalogSnapshotsForTests() }) + afterEach(() => resetStructuredAgentSessionSendsForTests()) + + it.each([false, true])( + 'keeps launch intent after retirement and on first send (accepted picks: %s)', + async (picked) => { + useAppStore.setState({ + settings: { + ...getDefaultSettings('/tmp/orca-workspaces'), + nativeChatSessionOptions: { + claude: { model: 'sonnet', valuesByModel: { sonnet: { effort: 'low' } } } + } + } + }) + const catalog = { + origin: 'probe', + fetchedAt: 1, + models: ['sonnet', 'opus'].map((id) => ({ + id, + label: id, + isDefault: id === 'opus', + defaultEffort: 'high', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + })) + } + mocks.call.mockImplementation((_target, method) => { + if (method === 'agentSession.modelCatalog') { + return Promise.resolve(catalog) + } + if (method === 'agentSession.setOption') { + return Promise.resolve({ ok: true, value: { options: { model: 'opus', effort: 'low' } } }) + } + return new Promise(() => {}) + }) + let created!: (receipt: { sessionId: string; fence: number }) => void + mocks.launch.mockReturnValue(new Promise((resolve) => (created = resolve))) + const launch = startStructuredAgentLaunch('wt-claude', 'claude', { requestId: 'request-4' }) + const view = renderLaunchedChat('wt-claude', launch.sessionId, 'claude') + await waitFor(() => expect(currentModel(view.result.current.optionSnapshot)).toBe('sonnet')) + if (picked) { + await act(async () => { + expect(await view.result.current.setStructuredOption('model', 'opus')).toBe(true) + expect(await view.result.current.setStructuredOption('effort', 'low')).toBe(true) + }) + } + publishedTabs = [publishedSnapshot('wt-claude', launch.sessionId)] + readState = sessionState(1) + await act(async () => { + created({ sessionId: launch.sessionId, fence: 1 }) + await launch.launchResult + }) + view.rerender() + await waitFor(() => + expect(getStructuredAgentSessionLaunchSelection(launch.sessionId)).toBeNull() + ) + const expected = { model: picked ? 'opus' : 'sonnet', effort: 'low' } + expect(currentModel(view.result.current.optionSnapshot)).toBe(expected.model) + const effort = view.result.current.optionSnapshot.find((entry) => entry.id === 'effort')?.kind + expect(effort?.type === 'select' && effort.currentValue).toBe('low') + act(() => { + expect(view.result.current.send('first message')).toBe(true) + }) + await waitFor(() => + expect(mocks.call).toHaveBeenCalledWith( + LOCAL_TARGET, + 'agentSession.send', + expect.objectContaining({ options: expected }) + ) + ) + await act(async () => { + await view.result.current.setStructuredOption('model', 'sonnet') + }) + expect(currentModel(view.result.current.optionSnapshot)).toBe('opus') + view.rerender('unrelated-chat') + expect(view.result.current.launchView).toBeUndefined() + } + ) + it('keeps the selection its create seeded when a pick in another chat saves a new one', async () => { saveSelection('gpt-5.5') startStructuredAgentLaunch('wt-first', 'codex', { requestId: 'request-1' }) @@ -184,6 +277,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 @@ -193,6 +296,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 193037770d4d..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 @@ -1,12 +1,13 @@ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo, useState, useSyncExternalStore } from 'react' import { getStructuredAgentSessionLaunchResumes, relaunchFailedStructuredAgentSessionForMessage, retryStructuredAgentSessionLaunch, useStructuredAgentSessionLaunchFailure, - useStructuredAgentSessionLaunchLifecycle, - useStructuredAgentSessionLaunchSelection + useStructuredAgentSessionLaunchLifecycle } from '@/lib/structured-agent-session-launch' +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' @@ -23,42 +24,62 @@ export type StructuredAgentSessionLaunchView = { const NO_HELD_OPTIONS: Readonly> = {} -type LatchedLaunch = { - kind: StructuredAgentSessionLaunchView['kind'] - seed: Readonly> | undefined -} - -/** 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?.seed - } - : null - ) - if (latched && selection && selection.seed !== latched.seed) { - setLatched({ kind: latched.kind, seed: selection.seed }) + const owner = useMemo(() => { + let snapshot = getStructuredAgentSessionLaunchSelection(sessionId) + const read = () => { + const current = getStructuredAgentSessionLaunchSelection(sessionId) + if (current) { + snapshot = current + } else if (snapshot && snapshot.held !== NO_HELD_OPTIONS) { + snapshot = { seed: snapshot.seed, held: NO_HELD_OPTIONS } + } + return snapshot + } + return { + read, + subscribe: (notify: () => void) => + subscribeStructuredAgentLaunchStatus(() => { + // Capture accepted picks even when retirement happens before React renders. + read() + notify() + }) + } + }, [sessionId]) + const selection = useSyncExternalStore(owner.subscribe, owner.read, () => null) + const [intent, setIntent] = useState<{ + sessionId: string + kind: StructuredAgentSessionLaunchView['kind'] | null + }>(() => ({ + sessionId, + kind: launching ? (getStructuredAgentSessionLaunchResumes(sessionId) ? 'resume' : 'new') : null + })) + if (intent.sessionId !== sessionId) { + setIntent({ + sessionId, + kind: launching + ? getStructuredAgentSessionLaunchResumes(sessionId) + ? 'resume' + : 'new' + : null + }) } const held = selection?.held ?? NO_HELD_OPTIONS return useMemo( () => - latched + intent.kind ? { - kind: latched.kind, - ...(latched.seed ? { seedOptions: latched.seed } : {}), + kind: intent.kind, + ...(selection?.seed ? { seedOptions: selection.seed } : {}), heldOptions: held, ...(worktreeId ? { worktree: toRuntimeWorktreeSelector(worktreeId) } : {}) } : undefined, - [held, latched, worktreeId] + [held, intent.kind, selection?.seed, worktreeId] ) } @@ -68,7 +89,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) 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 3111fcbae6ff..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 @@ -12,14 +12,12 @@ import { type StructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' -import { - structuredAgentSessionOptionView, - type StructuredAgentSessionPendingPick -} 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 @@ -38,7 +36,7 @@ export 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 @@ -105,7 +103,7 @@ export function useStructuredAgentSessionOptionState(args: { const optionStateRef = useRef(optionState) const activeOptionRecordRef = useRef(optionState.record) // A pick on its way to the host, which every send carries too. - const pendingOptionRef = useRef(null) + const pendingOptionsRef = useRef>({}) const optionMutationGeneration = useRef(0) const updateOptionState = useCallback( (update: (current: StructuredAgentSessionOptionState) => StructuredAgentSessionOptionState) => { @@ -126,15 +124,20 @@ 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 kept = - 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 // 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 + pendingOptionsRef.current = {} optionStateRef.current = next activeOptionRecordRef.current = next.record setOptionState(next) @@ -228,7 +231,7 @@ export function useStructuredAgentSessionOptionState(args: { optionState, optionStateRef, activeOptionRecordRef, - pendingOptionRef, + pendingOptionsRef, optionMutationGeneration, updateOptionState } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx index 3041328fadad..479563a10b80 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.permission-mode.test.tsx @@ -122,8 +122,10 @@ describe('a permission pick on this device', () => { }) it('shows at once and stays pickable while the write waits behind a start', async () => { - optionsAnswering('ask') - vi.mocked(mutate).mockImplementation(() => new Promise(() => {})) + optionsAnswering('ask', 'auto') + const first = Promise.withResolvers<{ options: Record }>() + const second = Promise.withResolvers<{ options: Record }>() + vi.mocked(mutate).mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) const { result, unmount } = renderReopened(1) await waitFor(() => expect(permission(result.current.optionSnapshot)).toBe('ask')) @@ -136,6 +138,9 @@ describe('a permission pick on this device', () => { }) expect(permission(result.current.optionSnapshot)).toBe('auto') expect(vi.mocked(mutate)).toHaveBeenCalledTimes(2) + await act(async () => second.resolve({ options: { permissionMode: 'auto' } })) + await act(async () => first.resolve({ options: { permissionMode: 'full-access' } })) + expect(permission(result.current.optionSnapshot)).toBe('auto') 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..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 @@ -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/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 11a1ab3b4a96..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 @@ -61,19 +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}` const { optionView, takesSendOptions, optionState, optionStateRef, activeOptionRecordRef, - pendingOptionRef, + pendingOptionsRef, optionMutationGeneration, updateOptionState, rereadOptions, @@ -81,7 +81,7 @@ export function useStructuredAgentSessionOptions(args: { } = useStructuredAgentSessionOptionState({ agent, optionCatalog, - identity, + identity: `${agent}:${sessionId}`, fence, sessionId, target, @@ -89,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. @@ -134,19 +134,18 @@ export function useStructuredAgentSessionOptions(args: { 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 + 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 = id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID + const shownAtOnce = providerStarting || id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID if (shownAtOnce) { updateOptionState((current) => - current.record === targetRecord - ? commitStructuredAgentSessionOptionValues(current, { [id]: encoded }) - : current + commitStructuredAgentSessionOptionValues(current, { [id]: encoded }) ) } else { - pendingOptionRef.current = { id, value: encoded } + pendingOptionsRef.current[id] = encoded updateOptionState((current) => ({ ...current, pendingId: id })) } let result: AgentSessionOptionResult | null = null @@ -156,8 +155,9 @@ export function useStructuredAgentSessionOptions(args: { 'agentSession.setOption', { key: id, value: encoded } ) - if (result && isCurrent()) { - const committed = result.options ?? { [id]: encoded } + if (result && sameRecord() && currentPick()) { + const returned = result.options ?? { [id]: encoded } + const committed = isCurrent() ? returned : { [id]: returned[id] ?? encoded } updateOptionState((current) => current.record === targetRecord ? commitStructuredAgentSessionOptionValues(current, committed) @@ -171,6 +171,9 @@ export function useStructuredAgentSessionOptions(args: { } return Boolean(result) } finally { + 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( @@ -190,7 +193,6 @@ export function useStructuredAgentSessionOptions(args: { .catch(() => {}) } if (isCurrent()) { - pendingOptionRef.current = null updateOptionState((current) => current.record === targetRecord && current.pendingId === id ? { ...current, pendingId: null } @@ -207,7 +209,8 @@ export function useStructuredAgentSessionOptions(args: { optionMutationGeneration, optionStateRef, optionView, - pendingOptionRef, + pendingOptionsRef, + providerStarting, rememberOptionPicks, sessionId, target, @@ -236,11 +239,15 @@ export function useStructuredAgentSessionOptions(args: { 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(view, id, value) + !canSetStructuredAgentSessionOption(pickState, id, value) ) { return false } @@ -250,9 +257,6 @@ export function useStructuredAgentSessionOptions(args: { void applied?.then(settleLaunchOptionPick) return applied !== null } - if (pendingOptionRef.current !== null) { - return false - } return sendStructuredOption(id, encoded) }, [ @@ -262,7 +266,8 @@ export function useStructuredAgentSessionOptions(args: { optionCatalog, optionStateRef, optionView, - pendingOptionRef, + pendingOptionsRef, + providerStarting, sendStructuredOption, sessionId, settleLaunchOptionPick, @@ -309,7 +314,7 @@ export function useStructuredAgentSessionOptions(args: { optionSnapshot, optionSurface, setStructuredOption, - sendSource: { optionStateRef, pendingOptionRef, optionView, 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 index 6b72784b3865..25939e1ec825 100644 --- 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 @@ -12,17 +12,14 @@ import { applyStructuredAgentSessionModelCatalog, createStructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' -import { - structuredAgentSessionOptionView, - type StructuredAgentSessionPendingPick -} from '../../../../shared/structured-agent-session-option-view' +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: StructuredAgentSessionPendingPick | null, + pending: Record, seedOptions: Record | undefined = { model: 'claude-gone', effort: 'max' } ) { const optionStateRef = { @@ -46,7 +43,7 @@ function sendOptionsFor( { kind: 'new', seedOptions, heldOptions: {} }, { optionStateRef, - pendingOptionRef: { current: pending }, + pendingOptionsRef: { current: pending }, optionView: (state, seed, held) => structuredAgentSessionOptionView(state, seed, held, takesSendOptions), takesSendOptions @@ -58,26 +55,27 @@ function sendOptionsFor( 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, null) + 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, null, undefined)()).toEqual({ model: 'sonnet', effort: 'high' }) + 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, { id: 'model', value: 'opus[1m]' })()).toMatchObject({ - model: 'opus[1m]' + 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, { id: 'model', value: 'claude-retired' })()).toMatchObject({ + expect(sendOptionsFor(true, { model: 'claude-retired' })()).toMatchObject({ model: 'sonnet' }) }) it('carries nothing to an older host', () => { - expect(sendOptionsFor(false, null)()).toBeUndefined() + 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 index 56e8d5b53ee8..1211e2c44f56 100644 --- 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 @@ -11,10 +11,10 @@ export function useStructuredAgentSessionSendOptions( launch: StructuredAgentSessionLaunchView | undefined, source: Pick< ReturnType, - 'optionStateRef' | 'pendingOptionRef' | 'optionView' | 'takesSendOptions' + 'optionStateRef' | 'pendingOptionsRef' | 'optionView' | 'takesSendOptions' > ): () => Readonly> | undefined { - const { optionStateRef, optionView, pendingOptionRef, takesSendOptions } = source + const { optionStateRef, optionView, pendingOptionsRef, takesSendOptions } = source return useCallback(() => { if (!takesSendOptions) { return undefined @@ -22,14 +22,13 @@ export function useStructuredAgentSessionSendOptions( // 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 pending = pendingOptionRef.current const view = optionView(optionStateRef.current, selection?.seed ?? launch?.seedOptions, { ...(selection?.held ?? launch?.heldOptions), - ...(pending ? { [pending.id]: pending.value } : {}) + ...pendingOptionsRef.current }) return structuredAgentSessionSendSelection( view.record.agent, structuredAgentSessionOptionSnapshot(view) ) - }, [launch, optionStateRef, optionView, pendingOptionRef, sessionId, takesSendOptions]) + }, [launch, optionStateRef, optionView, pendingOptionsRef, sessionId, takesSendOptions]) } 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 b513ecfb9b50..1fa2b4847d3a 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 @@ -54,7 +54,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 84c9b47cb1ca..554630ee2412 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -393,7 +393,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/src/shared/structured-agent-session-option-view.ts b/src/shared/structured-agent-session-option-view.ts index a762c6add751..ad73cb341f11 100644 --- a/src/shared/structured-agent-session-option-view.ts +++ b/src/shared/structured-agent-session-option-view.ts @@ -143,9 +143,6 @@ export function structuredAgentSessionListedPicks( return { model: target.id, ...(carried ? { effort: carried } : {}) } } -/** A pick on its way to the host. */ -export type StructuredAgentSessionPendingPick = { id: string; value: string } - /** The Claude model and effort the composer shows (`snapshot`), which every send carries. */ export function structuredAgentSessionSendSelection( agent: string, diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index d9996213d413..71af405ff0d9 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' @@ -251,7 +252,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)) || @@ -279,7 +280,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/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..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 @@ -17,6 +17,7 @@ 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 { 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 +42,7 @@ import { } from './structured-agent-session-surface-execution' import { installableHost, + readyCodexProviderAdapter, structuredHostStub, turnItemSkew } from './structured-agent-session-host-fixture' @@ -76,7 +78,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 +156,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 +221,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 +240,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 +256,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 +370,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 +461,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 +495,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 +621,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 +638,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 +663,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 +682,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 { + 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 + 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 - } + host + ) } /** 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 +815,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 +837,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 +916,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 +931,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/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..d63d5612edcb --- /dev/null +++ b/tests/e2e/native-chat-startup-option-pickers.unit.test.ts @@ -0,0 +1,178 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen } 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 () => + ['sonnet', 'opus'].map((value) => ({ + value, + displayName: value, + 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, Reasoning effort High' })) + fireEvent.click(await screen.findByRole('option', { name: 'sonnet' })) + fireEvent.click(await screen.findByRole('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/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}`)