From 0c4770f26ac41d0db4b6f64f7989ee39bfe2c0de Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:56:06 -0700 Subject: [PATCH 1/9] Remove the completed-session acquisition path --- .../scripts/vitest-sqlite-runtime-files.mjs | 9 +- src/main/acp/acp-structured-acquire.ts | 2 +- src/main/acp/acp-structured-handshake.ts | 1 - .../acp/acp-structured-session-adapter.ts | 5 - src/main/acp/acp-structured-session.ts | 1 - .../claude-structured-session-acquisition.ts | 2 +- .../claude-structured-session-adapter.test.ts | 179 ++----------- .../claude-structured-session-adapter.ts | 12 - .../claude-structured-session-prompts.test.ts | 159 ++++++++++++ ...claude-structured-session-startup-state.ts | 17 -- .../claude-structured-session-startup.test.ts | 30 +-- .../codex/codex-structured-session-acquire.ts | 2 +- .../codex/codex-structured-session-adapter.ts | 9 +- .../codex/codex-structured-session-options.ts | 25 -- ...-agent-session-accept-then-deliver.test.ts | 1 - ...-agent-session-acquisition-options.test.ts | 244 +----------------- .../structured-agent-session-acquisition.ts | 60 +---- ...tured-agent-session-adapter-router.test.ts | 24 -- ...structured-agent-session-adapter-router.ts | 20 -- .../structured-agent-session-adapter.ts | 47 +--- ...ured-agent-session-append-delivery.test.ts | 1 - .../structured-agent-session-attach-flow.ts | 18 +- ...ured-agent-session-attach-orchestration.ts | 12 +- ...-agent-session-claude-option-queue.test.ts | 1 - ...d-agent-session-held-start.test-support.ts | 4 +- .../structured-agent-session-host-types.ts | 2 +- ...gent-session-instant-start.test-support.ts | 2 +- ...ured-agent-session-late-settlement.test.ts | 1 - ...gent-session-legacy-handoff-record.test.ts | 1 - ...ssion-main-agent-working-agreement.test.ts | 1 - ...ctured-agent-session-option-restoration.ts | 20 +- ...ed-agent-session-option-settlement.test.ts | 1 - ...gent-session-provider-child-record.test.ts | 1 - ...t-session-provider-started-catalog.test.ts | 1 - ...ructured-agent-session-provider-started.ts | 2 +- ...ueued-message-rig-provider.test-fixture.ts | 1 - ...ctured-agent-session-refusal-retry.test.ts | 1 - ...uctured-agent-session-repeat-press.test.ts | 1 - ...tured-agent-session-restart-resume-host.ts | 1 - ...session-restart-status-publication.test.ts | 1 - ...ed-agent-session-restart-witnesses.test.ts | 1 - ...uctured-agent-session-restart-witnesses.ts | 2 - ...tured-agent-session-rewind-at-rest.test.ts | 1 - ...red-agent-session-rewind-live-send.test.ts | 1 - .../structured-agent-session-rewind.test.ts | 1 - ...red-agent-session-send-preparation.test.ts | 1 - ...session-send-restarts-failed-start.test.ts | 1 - ...agent-session-settled-attach-retry.test.ts | 3 - ...agent-session-start-failure-writer.test.ts | 1 - ...agent-session-startup-attempt-host.test.ts | 1 - ...sion-startup-attempt-registrations.test.ts | 192 -------------- ...ured-agent-session-startup-attempt.test.ts | 30 +-- ...tructured-agent-session-startup-attempt.ts | 6 +- ...ctured-agent-session-startup-picks.test.ts | 1 - ...red-agent-session-surface-lifetime.test.ts | 1 - ...tured-agent-session-working-at-teardown.ts | 8 +- .../structured-conversation-command.test.ts | 1 - src/main/pi/rpc-session-adapter.ts | 6 +- src/main/pi/rpc-session-startup.ts | 2 - .../agent-session-record-options.test.ts | 109 ++++---- .../runtime/agent-session-record-store.ts | 10 +- ...ss-version-agent-session-wire.unit.test.ts | 1 - ...-chat-owner-status-activation.unit.test.ts | 1 - 63 files changed, 283 insertions(+), 1020 deletions(-) create mode 100644 src/main/claude/claude-structured-session-prompts.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index d7eabf412d84..3ada63a631ea 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -167,7 +167,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts', @@ -237,20 +236,20 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/runtime-orchestration-sender-names.test.ts', 'src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts', 'src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts', + 'src/main/runtime/structured-agent-registration-lifecycle-matrix.test.ts', 'src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts', 'src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts', 'src/main/runtime/runtime-client-agent-launch-settings-durability.test.ts', 'src/main/runtime/structured-agent-session-codex-scripted-child.test.ts', 'src/main/runtime/structured-agent-session-codex-start-deadline.test.ts', - 'src/main/runtime/structured-agent-registration-lifecycle-matrix.test.ts', - 'src/main/runtime/structured-agent-session-pi-startup.test.ts', - 'src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts', - 'src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts', 'src/main/runtime/structured-agent-session-codex-settlement-order.test.ts', 'src/main/runtime/structured-agent-session-integration-replay.test.ts', 'src/main/runtime/structured-agent-session-integration.test.ts', 'src/main/runtime/structured-agent-session-mobile-restore.test.ts', 'src/main/runtime/structured-agent-session-paired-caller.test.ts', + 'src/main/runtime/structured-agent-session-pi-signed-out-start.test.ts', + 'src/main/runtime/structured-agent-session-pi-startup.test.ts', + 'src/main/runtime/structured-agent-session-provider-operations-while-starting.test.ts', 'src/main/runtime/structured-agent-session-rollback-compatibility.test.ts', 'src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts', 'src/main/runtime/structured-agent-session-startup-reconcile.test.ts', diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index a72dc4670467..c74bc1f8e003 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -281,7 +281,7 @@ export async function acquireAcpStructuredSession(input: { } }) return { - acquisition: { process, acquisitionGeneration: generation, providerChildPhase: 'starting' }, + acquisition: { process, acquisitionGeneration: generation }, session: starting, initialize } diff --git a/src/main/acp/acp-structured-handshake.ts b/src/main/acp/acp-structured-handshake.ts index 6aee71147adc..ae1b2b9bc624 100644 --- a/src/main/acp/acp-structured-handshake.ts +++ b/src/main/acp/acp-structured-handshake.ts @@ -133,7 +133,6 @@ export async function initializeAcpStructuredSession(input: AcpHandshakeInput): now, settle: onSettled }), - restoreSkipped, closeRequested: false, journalClosed: null, ended: false, diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 42f558baf7c4..473bd8a3f630 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -230,11 +230,6 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte readOptions = async (input: { sessionId: string; fence: number }) => readAcpLiveOptions(this.live(input.sessionId)) - readOptionRestoreFailures = (sessionId: string): readonly string[] => { - const session = this.children.sessions.get(sessionId) - return session?.phase === 'ready' ? session.restoreSkipped : [] - } - readCommands = (sessionId: string) => { const session = this.children.sessions.get(sessionId) return session?.phase === 'ready' ? session.options.readCommands() : undefined diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts index 19457d09d5d8..126201558223 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -29,7 +29,6 @@ export type AcpStructuredSession = { prompts: AcpStructuredPrompts options: AcpStructuredOptions turns: AcpStructuredTurns - restoreSkipped: readonly string[] /** Orca asked this child to stop; its exit is then a requested close. */ closeRequested: boolean /** Why nothing this child says reaches the journal any more; null while it does. */ diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 1cf0ded65df2..244c57194718 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -296,7 +296,7 @@ export async function acquireClaudeSession({ ) } // The start reads its facts only after publish, so the child is `starting` until `started`. - return { ...publication.acquisition, providerChildPhase: 'starting' } + return publication.acquisition } catch (error) { unbindReadingControl?.() const acquisitionError = await resolveClaudeAcquisitionError({ diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 7dd8c4c1c64b..7801ebae93a9 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -3,8 +3,7 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { AgentSessionAcquisitionExitUnprovenError, - AgentSessionAcquisitionRootExitObservedError, - AgentSessionPromptAnswerRejectedError + AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' import { ORCA_AGENT_SESSION_SPAWN_TOKEN_ENV as SPAWN_TOKEN_ENV } from '../../shared/agent-session-caller-env' @@ -26,7 +25,6 @@ import { USER_MESSAGE, type FakeConnection } from './claude-structured-session-test-support' -import { invokeCanUseTool } from './claude-can-use-tool-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' describe('ClaudeStructuredSessionAdapter.acquire', () => { @@ -129,7 +127,6 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') ).toEqual([]) // Left out, not refused: the record takes the provider's own Fast, as before. - expect(adapter.readOptionRestoreFailures('session-1')).toEqual([]) expect(events.find((event) => event.type === 'options-reported')).toMatchObject({ reportedOptions: { fastMode: false } }) @@ -207,7 +204,6 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { expect(skipped).toEqual( kept ? [] : [expect.objectContaining({ fence: 7, options: { fastMode: 'true' } })] ) - expect(adapter.readOptionRestoreFailures('session-1')).toEqual(kept ? [] : ['fastMode']) const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) expect(options.current.fastMode === true).toBe(kept) expect(options.current.confirmed ?? []).not.toContain('fastMode') @@ -219,7 +215,8 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { settings: { applied: {}, effective: {}, sources: {} }, initModels: [{ value: 'opus', displayName: 'Opus', supportsFastMode: false }] }) - const adapter = adapterFor(claude) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) await adapter.acquire({ identity: identityFor(), @@ -231,7 +228,12 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { expect( claude.connections[0].calls.filter((call) => call.subtype === 'apply_flag_settings') ).toEqual([]) - expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['fastMode']) + // Fast is decided once settings are read, after `started`. + await vi.waitFor(() => + expect(events.find((event) => event.type === 'options-reported')).toMatchObject({ + restoreSkippedOptions: ['fastMode'] + }) + ) }) it.each([ @@ -239,12 +241,17 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { ['permissionMode', { permissionMode: 'retired-mode' }] ] as const)('self-heals a persisted %s the CLI would refuse at launch', async (key, options) => { const claude = fakeClaude() - const adapter = adapterFor(claude) + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = adapterFor(claude, {}, events) await expect( adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', options }) ).resolves.toBeDefined() - expect(adapter.readOptionRestoreFailures('session-1')).toEqual([key]) + await vi.waitFor(() => + expect(events.find((event) => event.type === 'started')).toMatchObject({ + restoreSkippedOptions: [key] + }) + ) expect(claude.connections[0].launch.options).not.toHaveProperty('effort') expect(claude.connections[0].launch.options).not.toHaveProperty('permissionMode') }) @@ -765,157 +772,3 @@ describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { expect(connection.close).toHaveBeenCalledTimes(4) }) }) - -describe('ClaudeStructuredSessionAdapter prompts', () => { - it('turns can_use_tool into an addressable durable approval that settles the SDK callback', async () => { - const claude = fakeClaude() - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = await acquired(claude, {}, events) - const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-1', 'tool-1', { - input: { command: 'git status' }, - suggestions: [{ type: 'addRules' }] - }) - expect(events.at(-1)).toMatchObject({ - type: 'prompt', - prompt: { kind: 'approval', toolName: 'Bash', promptKey: 'permission-1' } - }) - - adapter.bindPromptItemId('session-1', 'journal-approval', 'permission-1') - await adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-approval', - kind: 'approval', - response: { kind: 'option', optionId: 'allowForSession' }, - fence: 7, - commit: async () => undefined - }) - // The answer resolves the SDK's own callback promise; the SDK writes the wire response. - await expect(answered.promise).resolves.toEqual({ - behavior: 'allow', - updatedInput: { command: 'git status' }, - updatedPermissions: [{ type: 'addRules' }], - toolUseID: 'tool-1' - }) - }) - - it('settles the one AskUserQuestion callback from structured answers, including a long typed answer', async () => { - const claude = fakeClaude() - const adapter = await acquired(claude) - const answered = invokeCanUseTool( - claude.connections[0], - 'AskUserQuestion', - 'question-1', - 'tool-question', - { - input: { - questions: [ - { question: 'Library?', options: [{ label: 'Luxon' }] }, - { question: 'Ship now?', options: [{ label: 'Yes' }] } - ] - } - } - ) - adapter.bindPromptItemId('session-1', 'journal-question', 'question-1') - const typed = 'Wait for the capture to finish first. '.repeat(60) - - await adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-question', - kind: 'question', - response: { - kind: 'answers', - answers: [ - { questionId: 'q1', optionIds: ['q1:choice-1'] }, - { questionId: 'q2', optionIds: [], other: typed } - ] - }, - fence: 7, - commit: async () => undefined - }) - await expect(answered.promise).resolves.toMatchObject({ - behavior: 'allow', - updatedInput: { answers: { 'Library?': 'Luxon', 'Ship now?': typed.trim() } }, - toolUseID: 'tool-question' - }) - }) - - it('refuses answers Claude cannot take before the journal commits them', async () => { - const claude = fakeClaude() - const adapter = await acquired(claude) - const answered = invokeCanUseTool( - claude.connections[0], - 'AskUserQuestion', - 'question-1', - 'tool-question', - { input: { questions: [{ question: 'Library?', options: [{ label: 'Luxon' }] }] } } - ) - adapter.bindPromptItemId('session-1', 'journal-question', 'question-1') - const commit = vi.fn(async () => undefined) - - await expect( - adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-question', - kind: 'question', - response: { kind: 'option', optionId: 'allow' }, - fence: 7, - commit - }) - ).rejects.toBeInstanceOf(AgentSessionPromptAnswerRejectedError) - expect(commit).not.toHaveBeenCalled() - - await adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-question', - kind: 'question', - response: { kind: 'answers', answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }] }, - fence: 7, - commit - }) - await expect(answered.promise).resolves.toMatchObject({ - updatedInput: { answers: { 'Library?': 'Luxon' } } - }) - }) - - it('leaves a prompt cancelled and unanswerable once the SDK abort signal fires', async () => { - const claude = fakeClaude() - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = await acquired(claude, {}, events) - const controller = new AbortController() - const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-9', 'tool-9', { - input: { command: 'rm -rf /' }, - signal: controller.signal - }) - adapter.bindPromptItemId('session-1', 'journal-9', 'permission-9') - - controller.abort() - // A cancelled request is forgotten and settled with null — never an authorization. - await expect(answered.promise).resolves.toBeNull() - expect(events.at(-1)).toMatchObject({ type: 'prompt-cancelled', promptKey: 'permission-9' }) - // A late answer after the abort must not authorize the wrong tool. - await expect( - adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-9', - kind: 'approval', - response: { kind: 'option', optionId: 'allow' }, - fence: 7, - commit: async () => undefined - }) - ).rejects.toThrow(/no longer waiting/) - }) - - it('settles an in-flight permission callback when the session closes, leaving no dangling promise', async () => { - const claude = fakeClaude() - const adapter = await acquired(claude) - const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-close', 'tool-c', { - input: { command: 'ls' } - }) - await tick() - expect(answered.settled()).toBe(false) - - await adapter.closeSession('session-1') - - await expect(answered.promise).resolves.toBeNull() - }) -}) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 6d5267a80759..04b467e23dab 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -13,8 +13,6 @@ import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' import { setClaudeStructuredSessionOption } from './claude-structured-options' import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' -import { claudeStartupSettledWithin } from './claude-structured-session-startup-state' -import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' import { ClaudeAcquisitionRegistry, type ClaudeAcquisitionAttempt, @@ -231,18 +229,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda input, this.deps.requestTimeoutMs ) - startAnswered = (sessionId: string): boolean | undefined => - this.sessions.get(sessionId)?.startup.answered - awaitOptionWritable = (sessionId: string): Promise => - claudeStartupSettledWithin( - this.sessions.get(sessionId), - this.deps.requestTimeoutMs ?? CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS - ) readOptions = (input: { sessionId: string; fence: number }) => readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) - readOptionRestoreFailures = (sessionId: string): readonly string[] => [ - ...(this.sessions.get(sessionId)?.restoreSkippedOptions ?? []) - ] releaseAcquisition = (input: { sessionId: string }): Promise => this.afterClose(input.sessionId, () => this.releaseProviderSession(input.sessionId)) diff --git a/src/main/claude/claude-structured-session-prompts.test.ts b/src/main/claude/claude-structured-session-prompts.test.ts new file mode 100644 index 000000000000..f66ba0c804af --- /dev/null +++ b/src/main/claude/claude-structured-session-prompts.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest' +import { AgentSessionPromptAnswerRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' +import { acquired, fakeClaude, tick } from './claude-structured-session-test-support' +import { invokeCanUseTool } from './claude-can-use-tool-test-support' + +describe('ClaudeStructuredSessionAdapter prompts', () => { + it('turns can_use_tool into an addressable durable approval that settles the SDK callback', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-1', 'tool-1', { + input: { command: 'git status' }, + suggestions: [{ type: 'addRules' }] + }) + expect(events.at(-1)).toMatchObject({ + type: 'prompt', + prompt: { kind: 'approval', toolName: 'Bash', promptKey: 'permission-1' } + }) + + adapter.bindPromptItemId('session-1', 'journal-approval', 'permission-1') + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-approval', + kind: 'approval', + response: { kind: 'option', optionId: 'allowForSession' }, + fence: 7, + commit: async () => undefined + }) + // The answer resolves the SDK's own callback promise; the SDK writes the wire response. + await expect(answered.promise).resolves.toEqual({ + behavior: 'allow', + updatedInput: { command: 'git status' }, + updatedPermissions: [{ type: 'addRules' }], + toolUseID: 'tool-1' + }) + }) + + it('settles the one AskUserQuestion callback from structured answers, including a long typed answer', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + const answered = invokeCanUseTool( + claude.connections[0], + 'AskUserQuestion', + 'question-1', + 'tool-question', + { + input: { + questions: [ + { question: 'Library?', options: [{ label: 'Luxon' }] }, + { question: 'Ship now?', options: [{ label: 'Yes' }] } + ] + } + } + ) + adapter.bindPromptItemId('session-1', 'journal-question', 'question-1') + const typed = 'Wait for the capture to finish first. '.repeat(60) + + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-question', + kind: 'question', + response: { + kind: 'answers', + answers: [ + { questionId: 'q1', optionIds: ['q1:choice-1'] }, + { questionId: 'q2', optionIds: [], other: typed } + ] + }, + fence: 7, + commit: async () => undefined + }) + await expect(answered.promise).resolves.toMatchObject({ + behavior: 'allow', + updatedInput: { answers: { 'Library?': 'Luxon', 'Ship now?': typed.trim() } }, + toolUseID: 'tool-question' + }) + }) + + it('refuses answers Claude cannot take before the journal commits them', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + const answered = invokeCanUseTool( + claude.connections[0], + 'AskUserQuestion', + 'question-1', + 'tool-question', + { input: { questions: [{ question: 'Library?', options: [{ label: 'Luxon' }] }] } } + ) + adapter.bindPromptItemId('session-1', 'journal-question', 'question-1') + const commit = vi.fn(async () => undefined) + + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-question', + kind: 'question', + response: { kind: 'option', optionId: 'allow' }, + fence: 7, + commit + }) + ).rejects.toBeInstanceOf(AgentSessionPromptAnswerRejectedError) + expect(commit).not.toHaveBeenCalled() + + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-question', + kind: 'question', + response: { kind: 'answers', answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }] }, + fence: 7, + commit + }) + await expect(answered.promise).resolves.toMatchObject({ + updatedInput: { answers: { 'Library?': 'Luxon' } } + }) + }) + + it('leaves a prompt cancelled and unanswerable once the SDK abort signal fires', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const adapter = await acquired(claude, {}, events) + const controller = new AbortController() + const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-9', 'tool-9', { + input: { command: 'rm -rf /' }, + signal: controller.signal + }) + adapter.bindPromptItemId('session-1', 'journal-9', 'permission-9') + + controller.abort() + // A cancelled request is forgotten and settled with null — never an authorization. + await expect(answered.promise).resolves.toBeNull() + expect(events.at(-1)).toMatchObject({ type: 'prompt-cancelled', promptKey: 'permission-9' }) + // A late answer after the abort must not authorize the wrong tool. + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'journal-9', + kind: 'approval', + response: { kind: 'option', optionId: 'allow' }, + fence: 7, + commit: async () => undefined + }) + ).rejects.toThrow(/no longer waiting/) + }) + + it('settles an in-flight permission callback when the session closes, leaving no dangling promise', async () => { + const claude = fakeClaude() + const adapter = await acquired(claude) + const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-close', 'tool-c', { + input: { command: 'ls' } + }) + await tick() + expect(answered.settled()).toBe(false) + + await adapter.closeSession('session-1') + + await expect(answered.promise).resolves.toBeNull() + }) +}) diff --git a/src/main/claude/claude-structured-session-startup-state.ts b/src/main/claude/claude-structured-session-startup-state.ts index 2ece1890fe82..88046e02c787 100644 --- a/src/main/claude/claude-structured-session-startup-state.ts +++ b/src/main/claude/claude-structured-session-startup-state.ts @@ -43,23 +43,6 @@ export function claudeStartupFailureFact(session: ClaudeSession): SubmissionReje : null } -/** Resolves when startup lands or `timeoutMs` passes; a stuck start then refuses the write as before. */ -export function claudeStartupSettledWithin( - session: ClaudeSession | undefined, - timeoutMs: number -): Promise { - if (session?.startup.state !== 'pending') { - return Promise.resolve() - } - let timer: ReturnType | undefined - return Promise.race([ - session.startup.settled, - new Promise((resolve) => { - timer = setTimeout(resolve, timeoutMs) - }) - ]).finally(() => clearTimeout(timer)) -} - /** Startup cannot land any more: the child exited, was closed, or its start faulted. */ export function failClaudeStartup(session: ClaudeSession, error: Error): void { const startup = session.startup diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index 117dc49e4634..eb8496ffb464 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -8,7 +8,6 @@ import { USER_MESSAGE, claudeStartupSettled } from './claude-structured-session-test-support' -import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' /** Far past any deadline a start used to have. */ const NEVER_MS = 10 * 60_000 @@ -134,42 +133,19 @@ describe('Claude structured session publishes before the CLI answers initialize' await adapter.closeAll() }) - it('lets an option write wait for startup instead of refusing it', async () => { + // The host records a pick made while the child starts as intent and applies it after `started`. + it('refuses an option write before the CLI answers initialize', async () => { const claude = fakeClaude({ initDelayMs: SLOW_INIT_MS }) const { adapter } = startingAdapter(claude) await adapter.acquire(ACQUIRE) const pick = { sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 } await expect(adapter.setOption(pick)).rejects.toThrow('still starting') - let writable = false - const waited = adapter.awaitOptionWritable('session-1').then(() => { - writable = true - }) - await vi.advanceTimersByTimeAsync(SLOW_INIT_MS - 1) - expect(writable).toBe(false) - await vi.advanceTimersByTimeAsync(1) - await waited - + await vi.advanceTimersByTimeAsync(SLOW_INIT_MS) await expect(adapter.setOption(pick)).resolves.toMatchObject({ model: 'opus' }) await adapter.closeAll() }) - it('stops waiting on a start that never lands, so the write is refused as before', async () => { - const claude = fakeClaude({ initDelayMs: 10 * CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS }) - const { adapter } = startingAdapter(claude) - await adapter.acquire(ACQUIRE) - let writable = false - void adapter.awaitOptionWritable('session-1').then(() => { - writable = true - }) - await vi.advanceTimersByTimeAsync(CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS) - expect(writable).toBe(true) - await expect( - adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'opus', fence: 7 }) - ).rejects.toThrow('still starting') - await adapter.closeAll() - }) - // The child was launched with the chat's saved options, so nothing waits on initialize: the SDK // streams the message to the CLI, which takes it behind its own start. it('writes a message sent before initialize answers at once', async () => { diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index b5f5ef16d645..4b41422f845d 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -213,7 +213,7 @@ export async function acquireCodexStructuredSession(input: { forceCloseUnexpected: input.forceCloseUnexpected, ...(unbindReadingControl ? { unbindReadingControl } : {}) }) - return { process, acquisitionGeneration, providerChildPhase: 'starting' } + return { process, acquisitionGeneration } } catch (error) { if (sessions.get(sessionId)?.connection !== acquisition.connection) { return closeFailedCodexAcquisition({ diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 13e184d9fd6b..caa55a51d022 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -17,8 +17,7 @@ import { CodexStructuredSessionTeardown } from './codex-structured-session-teard import { applyCodexStructuredSessionOption, prepareLiveCodexSessionOptions, - readLiveCodexSessionOptions, - readCodexAcquisitionOptions + readLiveCodexSessionOptions } from './codex-structured-session-options' import { CodexAcquisitionRegistry, @@ -312,12 +311,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap prepareReadOptions = (input: { sessionId: string; fence: number }) => prepareLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) - readAcquisitionOptions = (input: { - sessionId: string - fence: number - priorOptions?: Readonly> - }) => readCodexAcquisitionOptions(this.session(input.sessionId), input.priorOptions) - closeSession = (sessionId: string): Promise => this.teardown.close(sessionId) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) disposeSession = (sessionId: string): Promise => this.teardown.close(sessionId) diff --git a/src/main/codex/codex-structured-session-options.ts b/src/main/codex/codex-structured-session-options.ts index e9118f111ac1..bbac1d4c3006 100644 --- a/src/main/codex/codex-structured-session-options.ts +++ b/src/main/codex/codex-structured-session-options.ts @@ -26,31 +26,6 @@ export function restoredCodexSessionOptions( return new Map(Object.entries(options ?? {}).filter(([key]) => isCodexTurnOptionKey(key))) } -export function readCodexAcquisitionOptions( - session: CodexSession, - priorOptions: Readonly> | undefined -): Readonly> | undefined { - const options = { - ...Object.fromEntries( - Object.entries(priorOptions ?? {}).filter(([key]) => !isCodexTurnOptionKey(key)) - ), - ...Object.fromEntries(session.options) - } - const reported = session.reportedOptions - // The thread's effort belongs to the thread's model, not to a different saved one. - if ( - options.effort === undefined && - reported.effort && - (options.model === undefined || options.model === reported.model) - ) { - options.effort = reported.effort - } - if (options.model === undefined && reported.model) { - options.model = reported.model - } - return Object.keys(options).length > 0 ? options : undefined -} - /** Startup reports the thread's defaults together with the saved next-turn intent. */ export function reportedCodexSessionOptions( session: CodexSession diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 71ea4eb72968..659db49bf33b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -70,7 +70,6 @@ let idleMs: number let clock: number const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, acquisitionGeneration: `generation-${acquire.mock.calls.length}`, link: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index 495b7c629989..5a855c40f2e3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -3,7 +3,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' @@ -75,11 +74,7 @@ function attachParams( } } -function adapter(input: { - origin: 'created' | 'resumed' - options?: AgentSessionOptionsResult - restoreFailures?: readonly string[] -}): StructuredAgentSessionAdapter { +function adapter(input: { origin: 'created' | 'resumed' }): StructuredAgentSessionAdapter { return { acquire: vi .fn() @@ -98,10 +93,6 @@ function adapter(input: { observedAt: NOW } })), - ...(input.options ? { readOptions: vi.fn(async () => input.options!) } : {}), - ...(input.restoreFailures - ? { readOptionRestoreFailures: vi.fn(() => input.restoreFailures!) } - : {}), dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), @@ -288,231 +279,8 @@ describe('structured session acquisition options', () => { expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5.6-sol', effort: 'medium' }) }) - it('persists provider options before proving a resumed legacy record', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-')) - const store = await openTestAgentSessionRecordStore(root) - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter({ origin: 'created' }), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - optionRevision: () => 0, - params: attachParams(CREATE_OPERATION, null), - now: () => NOW, - onAttached: () => {} - }) - expect(created).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toBeUndefined() - await store.replaceSessionOptions({ - sessionId: SESSION, - fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, - options: { approvalPolicy: 'on-request', personality: 'concise', fastMode: 'true' }, - now: NOW - }) - - const resumedStore = await openTestAgentSessionRecordStore(root) - await resumedStore.reconcileOnRestart({ - probe: async () => ({ outcome: 'pid-absent' }), - now: NOW + 1 - }) - const releasedFence = resumedStore.getRecord(SESSION)?.lease.runtimeFence ?? 0 - const resumed = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store: resumedStore, - adapter: adapter({ - origin: 'resumed', - options: { - current: { model: 'gpt-5.6-terra', effort: 'medium', fastMode: false }, - models: [] - } - }), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-b', - claimKeyId: 'key-1', - handoffOperationId: RESUME_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - optionRevision: () => 0, - params: attachParams(RESUME_OPERATION, releasedFence), - now: () => NOW + 1, - onAttached: () => {} - }) - - expect(resumed).toMatchObject({ ok: true }) - const reopened = await openTestAgentSessionRecordStore(root) - expect(reopened.getRecord(SESSION)?.options).toEqual({ - approvalPolicy: 'on-request', - personality: 'concise', - model: 'gpt-5.6-terra', - effort: 'medium', - fastMode: 'false' - }) - }) - - it('clears a rejected Fast restore instead of retaining the prior encoded value', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-fast-restore-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter = adapter({ - origin: 'created', - options: { current: { model: 'gpt-standard' }, models: [] }, - restoreFailures: ['fastMode'] - }) - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - optionRevision: () => 0, - params: attachParams(CREATE_OPERATION, null, { - model: 'gpt-standard', - fastMode: 'true' - }), - now: () => NOW, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-standard' }) - }) - - it('proves an opened Codex owner from effective options without reading its catalog', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-known-options-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter: StructuredAgentSessionAdapter = { - ...adapter({ origin: 'created' }), - readOptions: vi.fn(async () => { - throw new Error('model list unavailable') - }), - readAcquisitionOptions: vi.fn(() => ({ model: 'gpt-reported', effort: 'high' })) - } - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - optionRevision: () => 0, - params: attachParams(CREATE_OPERATION, null, { model: 'gpt-saved', effort: 'medium' }), - now: () => NOW, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(sessionAdapter.readOptions).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-reported', effort: 'high' }) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) - - it('retains saved options when an opened Codex thread reports no effective model', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-saved-options-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter: StructuredAgentSessionAdapter = { - ...adapter({ origin: 'created' }), - readOptions: vi.fn(async () => { - throw new Error('model list unavailable') - }), - readAcquisitionOptions: vi.fn(() => undefined) - } - const options = { model: 'gpt-saved', effort: 'medium' } - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - optionRevision: () => 0, - params: attachParams(CREATE_OPERATION, null, options), - now: () => NOW, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual(options) - expect(sessionAdapter.readOptions).not.toHaveBeenCalled() - }) - - it('releases an acquisition when provider options cannot be read', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-failure-')) - const store = await openTestAgentSessionRecordStore(root) - const releaseAcquisition = vi.fn(async () => true) - const failingAdapter: StructuredAgentSessionAdapter = { - ...adapter({ origin: 'created' }), - readOptions: vi.fn(async () => { - throw new Error('model list unavailable') - }), - releaseAcquisition - } - - await expect( - performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: failingAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - optionRevision: () => 0, - params: attachParams(CREATE_OPERATION, null), - now: () => NOW, - onAttached: () => {} - }) - ).resolves.toEqual({ - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: "Codex couldn't restart. Send your message to try again." - } - }) - expect(releaseAcquisition).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.ownerProcess).toBeNull() - }) - describe.each([ ['adapter acquire', 'acquire'], - ['options read', 'options'], ['identity commit', 'identity'], ['owner proof', 'proof'], ['journal attach', 'journal'] @@ -525,17 +293,11 @@ describe('structured session acquisition options', () => { const exitProven = cleanup === true root = await mkdtemp(join(tmpdir(), `orca-acquisition-${failurePoint}-`)) const store = await openTestAgentSessionRecordStore(root) - const base = adapter({ - origin: 'created', - options: { current: { model: 'gpt-5.6-terra' }, models: [] } - }) + const base = adapter({ origin: 'created' }) const injected = new Error(`${failurePoint} failed`) const acquire = vi.mocked(base.acquire) - const readOptions = vi.mocked(base.readOptions!) if (failurePoint === 'acquire') { acquire.mockRejectedValueOnce(injected) - } else if (failurePoint === 'options') { - readOptions.mockRejectedValueOnce(injected) } else if (failurePoint === 'identity') { vi.spyOn(store, 'commitProcessIdentity').mockRejectedValueOnce(injected) } else if (failurePoint === 'proof') { @@ -563,7 +325,7 @@ describe('structured session acquisition options', () => { } return cleanup }) - const failingAdapter = { ...base, acquire, readOptions, releaseAcquisition } + const failingAdapter = { ...base, acquire, releaseAcquisition } let journalFailure = failurePoint === 'journal' ? injected : null const perform = ( target: AgentSessionRecordStore, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index 09efedf111b6..c5e5e41464fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -1,21 +1,16 @@ import { isDeepStrictEqual } from 'node:util' -import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import type { AgentSessionProcessIdentity, AgentSessionRecord } from '../../../shared/agent-session-record' import { AgentSessionPreSpawnError, - isAgentSessionPreSpawnError, - type StructuredAgentSessionProviderChildPhase + isAgentSessionPreSpawnError } from './structured-agent-session-adapter' import { rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-provider-exit-proof' import { journalIdentityFor } from './structured-agent-session-attach' import type { AttachFlowInput } from './structured-agent-session-attach-flow' -import { readNativeSessionOptions } from './structured-agent-session-option-restoration' -import { withAgentSessionCreatePhase } from '../../observability/agent-session-instrumentation' import { mintStructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt' -import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' /** The same process, whatever Orca runtime the store stamped on its record (`runtime`): that stamp * is about who holds the process, not which process it is. */ @@ -28,23 +23,6 @@ function sameOwnerProcess( return isDeepStrictEqual(storedProcess, acquiredProcess) } -/** Bookkeeping: a failure is logged, never the proven start's. */ -function handOverStartCatalogListing( - input: AttachFlowInput, - sessionId: string, - listing: AgentModelCatalogLiveListing -): void { - try { - input.onStartCatalogListing?.(listing) - } catch (error) { - input.logger.warn('saving what a started provider listed failed', { - scope: 'provider-started-catalog', - sessionId, - error - }) - } -} - /** A reservation with no process behind it is only a promise to spawn; the * adapter makes it real and the store then grants the writer. */ export async function acquireOwner( @@ -53,7 +31,6 @@ export async function acquireOwner( ): Promise<{ record: AgentSessionRecord acquisitionGeneration: string | null - providerChildPhase: StructuredAgentSessionProviderChildPhase }> { const fence = record.lease.runtimeFence const spawnToken = record.lease.reservedSpawnToken @@ -92,28 +69,6 @@ export async function acquireOwner( }) } }) - const providerChildPhase = acquired.providerChildPhase ?? 'ready' - // A starting child has proven nothing: the record keeps the reservation's saved options as - // intent, never a catalog guess, and the `started` event persists what the child reports. - const options = - providerChildPhase === 'starting' - ? undefined - : await withAgentSessionCreatePhase('restore_options', input.recordPhase, async () => { - const read = { - sessionId: record.sessionId, - fence, - ...(record.options ? { priorOptions: record.options } : {}) - } - // Still inside the start, so the limit or a Stop ends a read the provider never answers. - return waitForPromiseWithSignal( - Promise.resolve( - input.adapter.readAcquisitionOptions - ? input.adapter.readAcquisitionOptions(read) - : readNativeSessionOptions({ adapter: input.adapter, ...read }) - ), - input.acquireSignal - ) - }) if (record.lease.ownerProcess === null) { await input.store.commitProcessIdentity({ sessionId: record.sessionId, @@ -124,21 +79,14 @@ export async function acquireOwner( } else if (!sameOwnerProcess(record.lease.ownerProcess, acquired.process)) { throw new Error('agent_session_ownership_unknown') } + // The process owns the lease now; a handle the provider answers with is recorded on `started`. const proved = await input.store.proveOwner({ sessionId: record.sessionId, fence, ...(acquired.link ? { link: acquired.link } : {}), - now: input.now(), - ...(options ? { options } : {}) + now: input.now() }) - if (providerChildPhase === 'ready' && acquired.catalogListing) { - handOverStartCatalogListing(input, record.sessionId, acquired.catalogListing) - } - return { - record: proved, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, - providerChildPhase - } + return { record: proved, acquisitionGeneration: acquired.acquisitionGeneration ?? null } } catch (error) { if (isAgentSessionPreSpawnError(error)) { throw error diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts index 08f941ee2a33..f03b8b8072d1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -63,30 +63,6 @@ describe('StructuredAgentSessionAdapterRouter.releaseAcquisition', () => { }) }) -describe('StructuredAgentSessionAdapterRouter.readAcquisitionOptions', () => { - it('keeps the existing live read for providers without acquisition options', async () => { - const claude = adapterOf(vi.fn(async () => true)) - claude.readOptions = vi.fn(async () => ({ - current: { model: 'reported-model', effort: 'high' }, - models: [] - })) - const router = claudeAndCodexRouter( - { claude, codex: adapterOf(vi.fn(async () => false)) }, - async () => {} - ) - await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) - - await expect( - router.readAcquisitionOptions({ - sessionId: 'session-1', - fence: 1, - priorOptions: { model: 'saved-model', personality: 'concise' } - }) - ).resolves.toEqual({ model: 'reported-model', effort: 'high', personality: 'concise' }) - expect(claude.readOptions).toHaveBeenCalledOnce() - }) -}) - describe('StructuredAgentSessionAdapterRouter.closeSession', () => { it('retains the owner after an unproven close so a later retry reaches the same adapter', async () => { const claude = adapterOf(vi.fn(async () => true)) 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 bb86d7891e27..bcdf0036eaa8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -1,4 +1,3 @@ -import { readNativeSessionOptions } from './structured-agent-session-option-restoration' import type { StructuredAgentSessionAtRestCommands } from './structured-agent-session-at-rest-commands' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { @@ -163,11 +162,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi setOption: StructuredAgentSessionAdapter['setOption'] = (input) => this.owner(input.sessionId).setOption(input) - awaitOptionWritable = (sessionId: string): Promise => - this.liveOwnerOrNull(sessionId)?.awaitOptionWritable?.(sessionId) ?? Promise.resolve() - startAnswered = (sessionId: string): boolean | undefined => - this.liveOwnerOrNull(sessionId)?.startAnswered?.(sessionId) - prepareReadOptions = (input: { sessionId: string; fence: number }) => this.liveOwnerOrNull(input.sessionId)?.prepareReadOptions?.(input) @@ -179,20 +173,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return reader(input) } - readAcquisitionOptions = (input: { - sessionId: string - fence: number - priorOptions?: Readonly> - }) => { - const adapter = this.owner(input.sessionId) - return adapter.readAcquisitionOptions - ? adapter.readAcquisitionOptions(input) - : readNativeSessionOptions({ adapter, ...input }) - } - - readOptionRestoreFailures = (sessionId: string): readonly string[] => - this.owner(sessionId).readOptionRestoreFailures?.(sessionId) ?? [] - providerHistoryWindow = (input: { identity: AgentSessionJournalIdentity accountHome: AgentSessionAccountHome diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index e85d443667f9..f0278b08b30e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -103,22 +103,17 @@ export { AgentSessionAcquisitionRootExitObservedError } from './structured-agent-session-acquisition-errors' -/** What a reservation turns into once something is actually running under it: - * the process the host can probe, and the provider handle it was minted with. */ +/** What a reservation turns into once a process is running under it. Every acquire answers as + * soon as its process exists, before any handshake: the host publishes the child `starting` and + * hands it nothing until its `started` event. */ export type AgentSessionAcquisition = { process: AgentSessionProcessIdentity + /** Only a handle the adapter already holds at spawn (one Orca chose itself); a handle the + * provider answers with comes on `started`, never made up here. */ link?: AgentSessionProviderHandleLink /** Host-local identity for this exact provider child, distinct even when the durable fence is * reused by a superseding acquisition. */ acquisitionGeneration?: string - /** `starting`: published at spawn, proven later by a `started` event. Absent means `ready`, an - * acquire that ran the whole handshake before answering; that bridge ends once every adapter - * publishes at spawn. Either way the host holds input until the child is `ready`. */ - providerChildPhase?: StructuredAgentSessionProviderChildPhase - /** What a `ready` child listed at its start, with the configured default its start resolved; - * the host saves it once. A `starting` child (Claude) hands its listing on `started` and its - * resolved default on the settings readback's `options-reported`. */ - catalogListing?: AgentModelCatalogLiveListing } /** A refusal before spawn that a person can act on; the site that refused names it. */ @@ -215,15 +210,15 @@ export type StructuredAgentSessionEndedEvent = { startupUnanswered?: true } -/** The child a publish-first acquire handed over has now proven its start: startup facts applied. - * What it reports from here on is fact, not a catalog guess. */ +/** The child an acquire handed over has now proven its start: its protocol session exists and the + * saved options it launched with are applied. What it reports from here on is fact. */ export type StructuredAgentSessionStartedEvent = { - /** The handle supplied when the provider finishes starting. */ - link?: AgentSessionProviderHandleLink type: 'started' sessionId: string fence: number acquisitionGeneration: string + /** The provider handle its protocol session answered with; absent when the acquisition held it. */ + link?: AgentSessionProviderHandleLink /** What the child proved, snapshotted by the adapter from what startup already read. The host * handles this inside the session's serialized step, so it must not ask the CLI. */ reportedOptions: AgentSessionOptionsResult['current'] @@ -231,7 +226,8 @@ export type StructuredAgentSessionStartedEvent = { 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. */ + /** What the child listed at startup, with the configured default its start resolved, saved once + * as its account's catalog. Claude's default comes on its settings readback's `options-reported`. */ 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. */ @@ -262,9 +258,8 @@ export type StructuredAgentSessionLifecycleEvent = | StructuredAgentSessionOptionsReportedEvent | StructuredAgentSessionOptionsSkippedEvent -/** Whether the provider child behind an acquisition has proven its start. A publish-first - * acquire hands over a `starting` child, which the host gives no input until the `started` - * lifecycle event flips it. */ +/** Whether the provider child behind an acquisition has proven its start. Every acquire hands + * over a `starting` child, which the host gives no input until the `started` event flips it. */ export type StructuredAgentSessionProviderChildPhase = 'starting' | 'ready' /** The host's startup attempt (`structured-agent-session-startup-attempt`), which the host always @@ -409,11 +404,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & setOption( input: StructuredAgentSessionSetOptionInput ): Promise>> - /** Resolves once a live session can take an option write, or after a bound; never rejects. */ - awaitOptionWritable?(sessionId: string): Promise - /** False while the live child has not answered its start, so it has run nothing it was handed. - * Absent or undefined reads as answered. */ - startAnswered?(sessionId: string): boolean | undefined /** Fetch off the lane, then apply the result under the same child's fence. */ prepareReadOptions?(input: { sessionId: string @@ -423,17 +413,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & sessionId: string fence: number }): Promise - /** Effective options already known after acquisition, without discovering picker choices. */ - readAcquisitionOptions?(input: { - sessionId: string - fence: number - priorOptions?: Readonly> - }): - | Promise> | undefined> - | Readonly> - | undefined - /** Option keys skipped after a provider rejected their persisted restore value. */ - readOptionRestoreFailures?(sessionId: string): readonly string[] /** Provider history for restart reconciliation, bounded to what the provider * recorded after the journal's last committed item. Only the adapter can say * whether the read has a proven start and whether a turn is still running, so diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index c5ce6a79984c..17112f3679a9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -118,7 +118,6 @@ beforeEach(async () => { resetHostTestOperationIds() generation = 0 acquire = vi.fn(async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, 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 66f1f3907569..a759a95b78b5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -5,10 +5,7 @@ import { failedAcquisitionSettlement, preSpawnFailureInWords } from './structured-agent-session-failed-create-refusal' -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionProviderChildPhase -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' // The host supplies owner authority; this flow reserves, proves, and publishes the session. import type { @@ -52,8 +49,6 @@ import type { StructuredAgentSessionStartupProgress } from './structured-agent-s import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' -import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' - export type AttachedOwner = 'acquired' | 'kept' | 'none' export type AttachFlowInput = { @@ -74,17 +69,14 @@ export type AttachFlowInput = { onStartupAttempt?: ( attempt: StructuredAgentSessionStartupAttempt ) => StructuredAgentSessionStartupProgress - /** A completed acquisition's start listing. */ - onStartCatalogListing?: (listing: AgentModelCatalogLiveListing) => void /** The conversation's option revision, which the child's reports are stamped with. */ optionRevision: () => number /** Publishes the journal before clients can send against the new owner. `owner` says whether - * this attach spawned the child, kept a live one, or left the chat at rest. */ + * this attach spawned the child (then `starting`), kept a live one, or left the chat at rest. */ onAttached: ( attached: AttachedJournal, acquisitionGeneration: string | null, - owner: AttachedOwner, - providerChildPhase: StructuredAgentSessionProviderChildPhase + owner: AttachedOwner ) => Promise | void /** Host-owned provider sink, bound to the journal inside `onAttached`. */ eventSink?: StructuredAgentSessionEventSink @@ -134,7 +126,6 @@ export async function performAttach( let record: AgentSessionRecord let acquisitionGeneration: string | null = null let owner: AttachedOwner = 'kept' - let providerChildPhase: StructuredAgentSessionProviderChildPhase = 'ready' let reservedRecord: AgentSessionRecord | null = null let unsupportedReservationSettlementAttempted = false let replayed = false @@ -210,7 +201,6 @@ export async function performAttach( ) record = acquired.record acquisitionGeneration = acquired.acquisitionGeneration - providerChildPhase = acquired.providerChildPhase owner = 'acquired' } } catch (error) { @@ -272,7 +262,7 @@ export async function performAttach( providerHistoryWindow }) await importAdoptedTranscript(params, attached, record, preparedTranscript.items) - await input.onAttached(attached, acquisitionGeneration, owner, providerChildPhase) + await input.onAttached(attached, acquisitionGeneration, owner) await store.recordOperationOutcome({ callerKey: input.callerKey, operationId: params.envelope.clientOperationId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 62bdabffe6b3..1d13570727d0 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 @@ -35,7 +35,6 @@ import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { noteStructuredAgentSessionProviderStarted } from './structured-agent-session-provider-started' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { isStructuredAgentSessionStartupExpired } from './structured-agent-session-startup-attempt' import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' @@ -202,8 +201,6 @@ async function runAttachUnderAbort( now: () => context.now(), recordPhase, acquireSignal, - onStartCatalogListing: (listing) => - context.deps.modelCatalog?.recordLiveListing(sessionId, listing), onStartupAttempt: (startup) => { attempt.startup = startup return startupAttempts.track(sessionId, startup) @@ -220,7 +217,7 @@ async function runAttachUnderAbort( // The cleanup released the acquisition, which for a re-attach is the live child itself. onAcquisitionReleased: (cause, verdict) => endStructuredAgentSessionReleasedChild(context, sessionId, cause, verdict), - onAttached: async (attached, acquisitionGeneration, owner, providerChildPhase) => { + onAttached: async (attached, acquisitionGeneration, owner) => { const fence = structuredAgentSessionConversationFence(context.deps.store, sessionId) const current = context.sessions.get(sessionId)?.child ?? null const startedFor = owner === 'acquired' ? options.startedFor : current?.startedFor @@ -251,8 +248,8 @@ async function runAttachUnderAbort( child: { generation: acquisitionGeneration ?? current?.generation ?? null, fence, - // An acquire reports its phase; a re-attach keeps what its child already proved. - phase: owner === 'acquired' ? providerChildPhase : (current?.phase ?? 'ready'), + // Every acquired child starts unproven; a re-attach keeps what its child already proved. + phase: owner === 'acquired' ? 'starting' : (current?.phase ?? 'ready'), ...(startedFor === undefined ? {} : { startedFor }), ...launchedOptionsOf(owner, attempt.startup, current) } @@ -285,9 +282,6 @@ async function runAttachUnderAbort( indexProviderChild(conversation, candidate.child) if (attempt.startup) { startupAttempts.published(sessionId, attempt.startup.attemptId, candidate.child) - if (candidate.child.phase === 'ready') { - noteStructuredAgentSessionProviderStarted(context.deps, sessionId) - } } context.publishStatus?.(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts index 6e047ae5773c..46062cfe290b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts @@ -63,7 +63,6 @@ function adapter(): StructuredAgentSessionAdapter { acquire = vi.fn(async ({ fence, spawnToken, options }) => { activeModel = options?.model ?? DEFAULT_MODEL return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4200, processStartTimeMs: NOW, spawnToken }, acquisitionGeneration: 'generation-1', link: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts index a8f493d9d551..1cf90b4749ed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts @@ -38,7 +38,7 @@ export function structuredAgentSessionHeldStarts( const acquired = await acquire(input) const acquisitionGeneration = acquired.acquisitionGeneration ?? `held-start-${++generations}` heldGenerations.add(acquisitionGeneration) - return { ...acquired, acquisitionGeneration, providerChildPhase: 'starting' as const } + return { ...acquired, acquisitionGeneration } }, wrap: (adapter) => { const acquire: Acquire = async (input) => { @@ -52,7 +52,7 @@ export function structuredAgentSessionHeldStarts( acquisitionGeneration }) } - return { ...acquired, acquisitionGeneration, providerChildPhase: 'starting' as const } + return { ...acquired, acquisitionGeneration } } return { ...adapter, acquire } }, 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 149e9474fd88..4f2395727988 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -67,7 +67,7 @@ export type StructuredAgentSessionChildClose = { /** The provider process behind a conversation. Written only in * `structured-agent-session-provider-child`. */ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProviderChildIdentity & { - /** A starting acquisition stays `starting` until the host settles its `started` event; only then is + /** Every acquired child is `starting` until the host settles its `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 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts index c228180de5c0..61b62598beeb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts @@ -29,7 +29,7 @@ export function startsWhenPublished( restoreSkippedOptions: [], optionRevision: NO_OPTIONS_REPORTED }) - return { ...acquired, acquisitionGeneration, providerChildPhase: 'starting' as const } + return { ...acquired, acquisitionGeneration } } return { ...adapter, acquire } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 209260f1406c..96370376918c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -105,7 +105,6 @@ beforeEach(async () => { adapter: startsWhenPublished( { acquire: vi.fn(async ({ fence }) => ({ - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index e7a814ec0481..d32ea5f05a56 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -129,7 +129,6 @@ 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 9b65150cc704..d00c72f22c5b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -74,7 +74,6 @@ beforeEach(async () => { acquire: async (input) => { events = input.events return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts index b1ec8d997a93..491021a131e6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts @@ -1,25 +1,6 @@ import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { encodeStructuredAgentSessionOptionValue } from '../../../shared/structured-agent-session-option-codec' -export async function readNativeSessionOptions(input: { - adapter: Pick - sessionId: string - fence: number - priorOptions?: Readonly> -}): Promise> | undefined> { - const { adapter, sessionId, fence, priorOptions } = input - const reported = await adapter.readOptions?.({ sessionId, fence }) - if (!reported) { - return undefined - } - return nativeSessionOptionsFromReport({ - reported: reported.current, - restoreSkipped: adapter.readOptionRestoreFailures?.(sessionId) ?? [], - ...(priorOptions ? { priorOptions } : {}) - }) -} - /** The record's options once the provider has reported: its model, effort, Fast and service tier * replace the saved ones, other saved options stay, and any the child could not take are dropped. */ export function nativeSessionOptionsFromReport(input: { @@ -31,6 +12,7 @@ export function nativeSessionOptionsFromReport(input: { }): Readonly> { const { reported, priorOptions } = input const restored = priorOptions ? { ...priorOptions } : {} + // A report naming no model drops the saved one: the next start runs the provider's own. delete restored.model delete restored.effort delete restored.fastMode diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 8eba7f5bb605..30e1e372f11f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -73,7 +73,6 @@ function adapter(): StructuredAgentSessionAdapter { .runtimeState.optionRevisions.current(identity.sessionId) }) return { - providerChildPhase: 'starting' as const, acquisitionGeneration, process: { hostId: 'local', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 6811a9575227..c0037c442d9c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -67,7 +67,6 @@ 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: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts index 883b63dfaf7d..67a939aceabd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts @@ -30,7 +30,6 @@ const providerStarted = vi.fn() function startHost(): void { const acquire: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.ts index 8e615e476431..bbd795af769a 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,6 +1,6 @@ // The host's half of a provider child proving its start. // -// A starting acquisition hands the host an unproven child, so the record keeps only the +// Every acquire hands the host a child that has answered nothing yet, so the record keeps only the // saved options the reservation carried, and the delivery loop hands it nothing. This is where the // host learns the start landed: the provider handle the child answered with is recorded (a resume // needs it), picks made meanwhile are applied, a rewind left in doubt is settled, the child turns 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 0b27054a80de..52532a3ff06b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts @@ -78,7 +78,6 @@ export function createQueuedRigProvider( ? `${THREAD}-context-${record!.providerContextBoundary!.operationId}` : (context?.head?.handle.nativeId ?? THREAD) return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 744f933a10f2..82ca6e94c86c 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 @@ -68,7 +68,6 @@ async function createHarness(options: { attached?: boolean } = {}) { const adapter: StructuredAgentSessionAdapter = startsWhenPublished( { acquire: async ({ fence }) => ({ - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts index 426e04c63936..de186de3487b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts @@ -48,7 +48,6 @@ beforeEach(async () => { const adapter: StructuredAgentSessionAdapter = startsWhenPublished( { acquire: async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, acquisitionGeneration: 'generation-1', link: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index e1a4e14cd0d9..d26f99105a54 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -69,7 +69,6 @@ export function createStructuredAgentSessionRestartResume( sessions, getRecord: deps.store.getRecord, childWork: surfaces.readChildWork, - startAnswered: (sessionId) => deps.adapter.startAnswered?.(sessionId), ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), teardownId: randomUUID(), now: surfaces.now, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts index c0e8ee04a552..c585fb2a3c52 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -40,7 +40,6 @@ 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}`, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts index dc71bc09f071..739ad2c5a33f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.test.ts @@ -20,7 +20,6 @@ function witnessed() { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: clear reads only the record's provider. AGENTS[sessionId] ? ({ provider: AGENTS[sessionId] } as AgentSessionRecord) : null, childWork: () => undefined, - startAnswered: () => undefined, capsule: { record: async (markers) => void recorded.push([...markers]) }, teardownId: 'teardown', now: () => 1, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts index 648f92b6d595..683fe79d9e87 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts @@ -33,7 +33,6 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { sessions: ReadonlyMap> getRecord: (sessionId: string) => AgentSessionRecord | null childWork: WorkingAtStopInput['childWork'] - startAnswered: NonNullable capsule?: Pick teardownId: string now: () => number @@ -73,7 +72,6 @@ export function createStructuredAgentSessionRestartWitnesses(deps: { session: deps.sessions.get(sessionId), getRecord: deps.getRecord, childWork: deps.childWork, - startAnswered: deps.startAnswered, trigger, teardownId: deps.teardownId, now: deps.now() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index b36a7bcb7854..03e6a399cecd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -53,7 +53,6 @@ function adapter(): StructuredAgentSessionAdapter { acquires += 1 sink = input.events! return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4000 + acquires, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts index 88474c453736..4ba833a93f77 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts @@ -56,7 +56,6 @@ function adapter(): StructuredAgentSessionAdapter { acquires += 1 sink = input.events! return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4000 + acquires, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index f83e5ef34848..fc5ec2684674 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -77,7 +77,6 @@ beforeEach(async () => { acquires.push(input) sink = input.events! return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4000 + acquires.length, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index a97271a580a9..089427f5b2ab 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 @@ -50,7 +50,6 @@ 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: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index 48af8306b102..4e9d1503e820 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -142,7 +142,6 @@ beforeEach(async () => { resetHostTestOperationIds() generation = 0 acquire = vi.fn(async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, 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 4377cf7b4840..c8914204c133 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -97,7 +97,6 @@ beforeEach(async () => { publishFault.publishCount = 0 resetHostTestOperationIds() acquire = vi.fn(async ({ fence }) => ({ - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, @@ -187,7 +186,6 @@ describe('settled attach retry', () => { throw new Error('reply lost') } return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, @@ -233,7 +231,6 @@ describe('settled attach retry', () => { throw new Error('reply lost') } return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 3bfa2112f3f8..b32d8e50119c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -120,7 +120,6 @@ beforeEach(async () => { adapter: { acquire: vi.fn(async ({ fence, spawnToken }) => { const child = { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts index c453ac96d861..64b118fdee96 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts @@ -51,7 +51,6 @@ const providerStarted = vi.fn() function acquisition(input: StructuredAgentSessionAcquireInput): AgentSessionAcquisition { return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts deleted file mode 100644 index 64c681c9f9ed..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -// Every agent this build registers starts under the host's one startup attempt and its hold on -// delivery. Iterates the runtime's real registration list, so an agent registered later is covered -// without being named here. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' -import type { AgentSessionAccountHome } from '../../../shared/agent-session-account-home' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionProviderHandle } from '../../../shared/agent-session-provider-handle' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from '../../runtime/structured-agent-runtime-registrations' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentDefinition } from './structured-agent-definition' -import { StructuredAgentRegistry } from './structured-agent-registry' -import type { - StructuredAgentSessionAcquireInput, - StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' - -const CALLER = { callerKey: 'client-1' } - -// Temporary, removed when every adapter publishes at spawn: adapters whose own acquire already does, -// with the test that drives the real one. Every other registration still runs its handshake inside -// acquire (the bridge) until then. -const REAL_ADAPTER_PUBLISHES_AT_SPAWN: Readonly> = { - claude: 'structured-agent-session-claude-hung-start-stop.test.ts' -} - -let root: string | null = null - -afterEach(async () => { - if (root) { - await rm(root, { recursive: true, force: true }) - root = null - } -}) - -function accountHome(definition: StructuredAgentDefinition): AgentSessionAccountHome { - return definition.accountLocatorKind === 'opencode' - ? { kind: 'opencode', locator: { kind: 'unmanaged' } } - : { variable: definition.accountHomeVariable, path: `/home/dev/.${definition.agent}` } -} - -function handle(definition: StructuredAgentDefinition): AgentSessionProviderHandle { - return { transport: definition.handleTransport, agent: definition.agent, nativeId: 'native-1' } -} - -/** A host driving `definition` over a scripted adapter that publishes its child at spawn. */ -async function hostFor(definition: StructuredAgentDefinition): Promise<{ - host: StructuredAgentSessionHost - store: AgentSessionRecordStore - acquire: Mock - dispatch: Mock -}> { - root = await mkdtemp(join(tmpdir(), 'orca-startup-registrations-')) - resetHostTestOperationIds() - const store = await openTestAgentSessionRecordStore(root) - const acquire: Mock = vi.fn( - async (input: StructuredAgentSessionAcquireInput) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: input.spawnToken - }, - acquisitionGeneration: `generation-${acquire.mock.calls.length}`, - providerChildPhase: 'starting' as const, - link: { - linkId: `link-${input.fence}`, - handle: handle(definition), - origin: 'created' as const, - mintedAtFence: input.fence, - observedAt: NOW - } - }) - ) - const dispatch: Mock = vi.fn(async () => ({ - state: 'admitted' as const - })) - const adapter: StructuredAgentSessionAdapter = { - acquire, - dispatch, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn: vi.fn(async () => ({ cancelled: false })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - } - const declared: StructuredAgentDefinition = { - ...definition, - // The double implements none of the optional capabilities' methods. - capabilities: { ...definition.capabilities, rewind: false, compact: false, threadGoal: false } - } - const host = new StructuredAgentSessionHost({ - agents: new StructuredAgentRegistry([{ definition: declared, adapter }]), - logger: createStructuredAgentSessionLogger(), - store, - adapter, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - now: () => NOW - }) - return { host, store, acquire, dispatch } -} - -describe.each( - STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map( - ({ definition }) => [definition.agent, definition] as const - ) -)('%s starts under the host attempt', (agent, definition) => { - it('acquires under a host-minted attempt and is handed nothing until it proves its start', async () => { - const { host, store, acquire, dispatch } = await hostFor(definition) - try { - const attached = await host.attach( - CALLER, - hostTestAttachParams(null, { - provider: agent, - agent, - accountHome: accountHome(definition), - // A new chat: the provider mints its handle at acquire. - providerHandle: undefined - }) - ) - expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true }) - const record = store.getRecord(SESSION)! - expect(acquire.mock.calls[0]?.[0]).toMatchObject({ - attemptId: expect.any(String), - fence: record.lease.runtimeFence, - launch: { location: record.location, accountHome: record.accountHome }, - onOutput: expect.any(Function) - }) - - const body = hostTestMessage('hello') - const sent = await host.send(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: record.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body - }) - expect(sent).toMatchObject({ ok: true }) - await vi.waitFor(() => - expect(host.collaboratorsForTests().conversationDelivery.loop.isRunning(SESSION)).toBe( - false - ) - ) - expect(dispatch).not.toHaveBeenCalled() - - await host.handleAdapterEvent({ - type: 'started', - sessionId: SESSION, - fence: record.lease.runtimeFence, - acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'default' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) - }) - - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - } finally { - await host.flushAllStreamedEvents() - } - }) -}) - -describe.each( - STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent).filter( - (agent) => !REAL_ADAPTER_PUBLISHES_AT_SPAWN[agent] - ) -)('%s with its own adapter', () => { - it.todo('publishes at spawn instead of finishing its handshake first') -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts index ddf04b4b0e73..224c92c4c1e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts @@ -88,22 +88,11 @@ describe('the startup clock', () => { expect(settled).toEqual([{ agent: 'codex', outcome: 'silent', durationMs: SILENCE_MS }]) }) - it('records a start that turned ready as the limit fired as ready', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - vi.advanceTimersByTime(SILENCE_MS) - expect(expired).toHaveLength(1) - - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'ready' }) - - expect(settled).toEqual([{ agent: 'codex', outcome: 'ready', durationMs: SILENCE_MS }]) - }) - it('keeps a slow start that is still talking past the silence limit', () => { const attempt = mint() const progress = attempts.track(SESSION, attempt) progress.spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) + attempts.published(SESSION, attempt.attemptId, CHILD) chatter(progress.output, SILENCE_MS * 3) attempts.ready(SESSION, CHILD) @@ -130,7 +119,7 @@ describe('the startup clock', () => { const attempt = mint() const progress = attempts.track(SESSION, attempt) progress.spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) + attempts.published(SESSION, attempt.attemptId, CHILD) chatter(progress.output, CEILING_MS - 10_000) expect(expired).toEqual([]) @@ -145,7 +134,7 @@ describe('the startup clock', () => { attempts.track(SESSION, byOutput).output() const byPublish = mint() attempts.track('session-beta', byPublish) - attempts.published('session-beta', byPublish.attemptId, { ...CHILD, phase: 'starting' }) + attempts.published('session-beta', byPublish.attemptId, CHILD) vi.advanceTimersByTime(SILENCE_MS) @@ -159,7 +148,7 @@ describe('an attempt ends', () => { it('with the start its child proved, and ignores a stale child’s proof', () => { const attempt = mint() attempts.track(SESSION, attempt).spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) + attempts.published(SESSION, attempt.attemptId, CHILD) attempts.ready(SESSION, { ...CHILD, generation: 'generation-0' }) expect(attempts.isOpen(SESSION)).toBe(true) @@ -173,7 +162,7 @@ describe('an attempt ends', () => { it('with its child, however the child ended, leaving no entry or timer', () => { const attempt = mint() attempts.track(SESSION, attempt).spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) + attempts.published(SESSION, attempt.attemptId, CHILD) attempts.childEnded(SESSION, { ...CHILD, generation: 'generation-0' }) expect(attempts.isOpen(SESSION)).toBe(true) @@ -187,7 +176,7 @@ describe('an attempt ends', () => { it('once an expiry has stopped its published child', () => { const attempt = mint() attempts.track(SESSION, attempt).spawned() - attempts.published(SESSION, attempt.attemptId, { ...CHILD, phase: 'starting' }) + attempts.published(SESSION, attempt.attemptId, CHILD) vi.advanceTimersByTime(SILENCE_MS) @@ -207,7 +196,7 @@ describe('an attempt ends', () => { const late = mint() attempts.track(SESSION, late).spawned() vi.advanceTimersByTime(SILENCE_MS) - attempts.published(SESSION, late.attemptId, { ...CHILD, phase: 'starting' }) + attempts.published(SESSION, late.attemptId, CHILD) expect(expired.map((startup) => startup.child)).toEqual([null, null, CHILD]) expect(attempts.isOpen(SESSION)).toBe(false) @@ -216,10 +205,11 @@ describe('an attempt ends', () => { expect(settled.map((startup) => startup.outcome)).toEqual(['silent', 'silent']) }) - it('with an acquire that answered ready, or an attach that failed', () => { + it('with a child that proved its start, or an attach that failed', () => { const ready = mint() attempts.track(SESSION, ready).spawned() - attempts.published(SESSION, ready.attemptId, { ...CHILD, phase: 'ready' }) + attempts.published(SESSION, ready.attemptId, CHILD) + attempts.ready(SESSION, CHILD) const failed = mint() attempts.track('session-beta', failed).spawned() attempts.abandon('session-beta', failed.attemptId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts index 4ed090ecd51b..800cdb6dde5d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.ts @@ -160,16 +160,12 @@ export class StructuredAgentSessionStartupAttempts { published( sessionId: string, attemptId: string, - child: StructuredAgentSessionProviderChildIdentity & { phase: 'starting' | 'ready' } + child: StructuredAgentSessionProviderChildIdentity ): void { const tracked = this.open.get(sessionId) if (tracked?.attempt.attemptId !== attemptId) { return } - if (child.phase === 'ready') { - this.settle(sessionId, tracked, 'ready') - return - } tracked.child = { generation: child.generation, fence: child.fence } if (tracked.expired) { this.expire(sessionId, tracked, null) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts index 1b3b2e1a5beb..861a43be916f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts @@ -60,7 +60,6 @@ beforeEach(async () => { acquire: async (input) => { acquires += 1 return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4000 + acquires, 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 3dd914f647fa..dbeacad0982c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -224,7 +224,6 @@ 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-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index fe724185de34..2a2e13e19c2a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -52,6 +52,7 @@ import { import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { isUnansweredHandedOverSubmission } from '../agent-session-journal/journal-unsent-send-hold' import { structuredAgentSessionShownStatus } from './structured-agent-session-shown-work' +import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' /** A send Orca journaled that the provider has neither opened a turn for nor refused. Mirrors the * projection's own unanswered-dispatch rule, which is what makes that window read as `working`. */ @@ -164,7 +165,7 @@ function liveTasks( type WorkingCandidateSession = { journal: AgentSessionJournal /** Only this host generation's own child counts. A restored-for-reading journal has none. */ - child: { fence: number; phase?: 'starting' | 'ready' } | null + child: Pick | null } /** The offer one session is owed, taken right before teardown stops its provider child; null when @@ -175,8 +176,6 @@ export function structuredAgentSessionWorkingAtStop(input: { getRecord: (sessionId: string) => AgentSessionRecord | null /** The session's child records, the same read the status feed publishes. */ childWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined - /** False when the session's live child never answered its start; the exit reads the same fact. */ - startAnswered?: (sessionId: string) => boolean | undefined trigger: AgentSessionResumeTrigger /** Stable teardown identity for continuation deduplication, not launch ancestry. */ teardownId: string @@ -190,8 +189,7 @@ export function structuredAgentSessionWorkingAtStop(input: { const current = agentSessionCurrentContextRows(snapshot.items, snapshot.submissions) // A queued message reached no agent, and one handed to a start that never answered ran nowhere: // neither is work to resume. The next open or the exit keeps it as unsent. - const unanswered = - session.child.phase === 'starting' || input.startAnswered?.(sessionId) === false + const unanswered = session.child.phase === 'starting' const handedOver = current.submissions.filter( (submission) => !isQueuedAgentJournalSubmission(submission) && diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index fca0340c41ca..a6b8999e1f28 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -119,7 +119,6 @@ beforeEach(async () => { optionRevision: -1 }) return { - providerChildPhase: 'starting' as const, acquisitionGeneration, process: { hostId: 'local', diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index ce3d74b8fd04..260d0fcfce28 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -100,8 +100,7 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { void child.start(launch, () => this.starts.retainFailed(id, child.connection)) return { process, - acquisitionGeneration: session.generation, - providerChildPhase: 'starting' + acquisitionGeneration: session.generation } } catch (error) { if (!session) { @@ -224,9 +223,6 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { } readOptions: NonNullable = async (input) => this.session(input.sessionId, input.fence).readOptions() - readOptionRestoreFailures(id: string): readonly string[] { - return this.sessions.get(id)?.startup.skipped ?? [] - } readCommands = (id: string) => this.sessions.get(id)?.startup.commands holdsDispatch(id: string): boolean { return this.sessions.get(id)?.turns.holdsDispatch ?? false diff --git a/src/main/pi/rpc-session-startup.ts b/src/main/pi/rpc-session-startup.ts index d24f5d5691ae..308f4bcb0ad2 100644 --- a/src/main/pi/rpc-session-startup.ts +++ b/src/main/pi/rpc-session-startup.ts @@ -29,7 +29,6 @@ const START_REQUEST = { timeoutMs: STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } export class PiRpcSessionStartup { started = false answered = false - skipped: readonly string[] = [] commands?: AgentSessionSlashCommand[] /** What the start listed; a signed-out Pi lists no model. */ options?: AgentSessionOptionsResult @@ -107,7 +106,6 @@ export class PiRpcSessionStartup { unpickedSessionConfiguredChoice({ resolvesConfig: this.resolvesConfig, picked, ...options }) ).catalogListing } - this.skipped = skipped this.started = true this.deps.onLifecycle({ type: 'started', diff --git a/src/main/runtime/agent-session-record-options.test.ts b/src/main/runtime/agent-session-record-options.test.ts index bef2bc424efa..a0fa4451015e 100644 --- a/src/main/runtime/agent-session-record-options.test.ts +++ b/src/main/runtime/agent-session-record-options.test.ts @@ -3,7 +3,8 @@ import { readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it } from 'vitest' -import { readNativeSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' +import { nativeSessionOptionsFromReport } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' +import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' import { openTestAgentSessionRecordStore, seedTestAgentSessionRecordStore @@ -25,43 +26,24 @@ afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) -it('fails option hydration before ownership can be proved', async () => { - await expect( - readNativeSessionOptions({ - adapter: { - readOptions: async () => { - throw new Error('model list unavailable') - } - }, - sessionId: SESSION, - fence: 2 - }) - ).rejects.toThrow('model list unavailable') -}) - -it('drops provider-rejected persisted options before the next owner proof', async () => { - await expect( - readNativeSessionOptions({ - adapter: { - readOptions: async () => ({ models: [], current: { model: 'provider-model' } }), - readOptionRestoreFailures: () => ['permissionMode'] - }, - sessionId: SESSION, - fence: 2, +it('drops provider-rejected persisted options from what a started child reports', () => { + expect( + nativeSessionOptionsFromReport({ + reported: { model: 'provider-model' }, + restoreSkipped: ['permissionMode'], priorOptions: { permissionMode: 'retired-mode', other: 'keep' } }) - ).resolves.toEqual({ model: 'provider-model', other: 'keep' }) + ).toEqual({ model: 'provider-model', other: 'keep' }) }) -it('omits empty model values from other option producers', async () => { - await expect( - readNativeSessionOptions({ - adapter: { readOptions: async () => ({ models: [], current: { model: '', effort: 'off' } }) }, - sessionId: SESSION, - fence: 2, +it('omits empty model values from other option producers', () => { + expect( + nativeSessionOptionsFromReport({ + reported: { model: '', effort: 'off' }, + restoreSkipped: [], priorOptions: { model: 'old-model', other: 'keep' } }) - ).resolves.toEqual({ effort: 'off', other: 'keep' }) + ).toEqual({ effort: 'off', other: 'keep' }) }) it('durably replaces model-less ACP options when a later report reaches the option writer', async () => { @@ -100,15 +82,12 @@ it('durably replaces model-less ACP options when a later report reaches the opti options: [{ value: 'reported-model', name: 'Reported Model' }] } ]) - const options = await readNativeSessionOptions({ - adapter: { readOptions: async () => reader.read() }, - sessionId: record.sessionId, - fence: record.lease.runtimeFence, - priorOptions: store.getRecord(record.sessionId)?.options + const prior = store.getRecord(record.sessionId)?.options + const options = nativeSessionOptionsFromReport({ + reported: reader.read().current, + restoreSkipped: [], + ...(prior ? { priorOptions: prior } : {}) }) - if (!options) { - throw new Error('The later ACP report must provide options') - } await store.replaceSessionOptions({ sessionId: record.sessionId, fence: record.lease.runtimeFence, @@ -122,7 +101,7 @@ it('durably replaces model-less ACP options when a later report reaches the opti }) }) -it('persists resumed provider options atomically with owner proof', async () => { +it('makes the process the owner before its provider answers with a handle, and records it after', async () => { const store = await openTestAgentSessionRecordStore(directory) const reserved = await store.reserveOwner({ sessionId: SESSION, @@ -158,30 +137,34 @@ it('persists resumed provider options atomically with owner proof', async () => }, now: NOW }) - const options = await readNativeSessionOptions({ - adapter: { - readOptions: async () => ({ - models: [], - current: { model: 'gpt-tui', effort: 'low' } - }) - }, - sessionId: SESSION, - fence - }) - await store.proveOwner({ + await store.proveOwner({ sessionId: SESSION, fence, now: NOW }) + await store.replaceSessionOptions({ sessionId: SESSION, fence, - link: { - linkId: 'codex-options-1', - handle: codexProviderHandle('thread-options'), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - }, - now: NOW, - ...(options ? { options } : {}) + options: { model: 'gpt-tui' }, + now: NOW }) + // A crash here leaves a record the next run reads back: live, its handle still owed. + const starting = (await openTestAgentSessionRecordStore(directory)).getRecord(SESSION) + expect(starting?.lease).toMatchObject({ claimStatus: 'live', provenHandleLinkId: null }) + expect(starting?.providerHandleChain).toEqual([]) + expect(starting?.options).toEqual({ model: 'gpt-tui' }) - const reopened = await openTestAgentSessionRecordStore(directory) - expect(reopened.getRecord(SESSION)?.options).toEqual({ model: 'gpt-tui', effort: 'low' }) + await store.transitionHandoff(SESSION, (record) => + recordAgentSessionProviderHandle({ + record, + fence, + link: { + linkId: 'codex-options-1', + handle: codexProviderHandle('thread-options'), + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) + ) + const started = (await openTestAgentSessionRecordStore(directory)).getRecord(SESSION) + expect(started?.lease.provenHandleLinkId).toBe('codex-options-1') + expect(started?.providerHandleChain.map((link) => link.linkId)).toEqual(['codex-options-1']) }) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 008b9af2bec2..80736c8ab341 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -210,20 +210,16 @@ export class AgentSessionRecordStore { link?: AgentSessionProviderHandleLink now: number leaseTtlMs?: number - options?: Readonly> }): Promise { - return this.mutate(args.sessionId, (record) => { - const proved = proveAgentSessionOwner({ + return this.mutate(args.sessionId, (record) => + proveAgentSessionOwner({ record, fence: args.fence, ...(args.link ? { link: args.link } : {}), now: args.now, leaseTtlMs: args.leaseTtlMs ?? AGENT_SESSION_LEASE_TTL_MS }) - return args.options - ? replaceAgentSessionRecordOptions(proved, { ...args, options: args.options }) - : proved - }) + ) } /** Settle the failed attach and its reservation in one durable transaction. */ diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index df6cc3b580d5..001223f8f43f 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -706,7 +706,6 @@ describe('cross-version structured agent sessions', () => { starts += 1 await startGate return { - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index bc02eefb2cba..91e5f6f7c1ac 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -47,7 +47,6 @@ function openHost(): void { adapter: startsWhenPublished( { acquire: async ({ fence, spawnToken }) => ({ - providerChildPhase: 'starting' as const, process: { hostId: 'local', pid: 4242, From 87257403bd6d534a038cddc6c89d7a0832502367 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:05:37 -0700 Subject: [PATCH 2/9] Require provider spawn and host-awaited open --- .../acp-structured-adapter.test-support.ts | 34 ++--- src/main/acp/acp-structured-auth.test.ts | 2 +- ...cp-structured-background-task-stop.test.ts | 2 +- .../acp/acp-structured-broken-stream.test.ts | 4 +- .../acp/acp-structured-child-stop.test.ts | 6 +- .../acp/acp-structured-compaction.test.ts | 2 +- ...tructured-grok-subagent-recordings.test.ts | 2 +- src/main/acp/acp-structured-handshake.ts | 16 +-- .../acp-structured-host-start-aborts.test.ts | 2 +- .../acp/acp-structured-image-prompts.test.ts | 6 +- .../acp/acp-structured-launch-resolution.ts | 2 +- .../acp/acp-structured-omp-recordings.test.ts | 4 +- ...acp-structured-opencode-recordings.test.ts | 2 +- .../acp/acp-structured-prompt-failure.test.ts | 2 +- .../acp-structured-publish-at-spawn.test.ts | 16 +-- .../acp-structured-request-admission.test.ts | 6 +- ...ructured-session-adapter-lifecycle.test.ts | 53 ++++---- ...uctured-session-adapter-recordings.test.ts | 2 +- ...-structured-session-adapter-resume.test.ts | 34 ++--- ...tructured-session-adapter-teardown.test.ts | 4 +- .../acp-structured-session-adapter.test.ts | 52 ++++---- .../acp/acp-structured-session-adapter.ts | 17 +-- src/main/acp/acp-structured-session.ts | 1 + src/main/acp/acp-structured-sign-in.test.ts | 8 +- ...red-acquire.ts => acp-structured-spawn.ts} | 32 ++--- src/main/acp/acp-structured-steer.test.ts | 14 +-- .../acp/acp-text-message-ownership.test.ts | 2 +- .../claude-adapter-capture-replay.test.ts | 2 +- .../claude-api-retry-idle-sweep.test.ts | 7 +- ...hild-work-producer-harness.test-fixture.ts | 2 +- src/main/claude/claude-close-turn-end.test.ts | 2 +- .../claude/claude-command-lifecycle.test.ts | 2 +- .../claude-context-usage-restart.test.ts | 20 +-- .../claude/claude-fold-steer-receipt.test.ts | 2 +- .../claude-structured-acquisition-launch.ts | 4 +- .../claude-structured-applied-options.test.ts | 2 +- ...aude-structured-configured-default.test.ts | 2 +- ...-structured-effort-default-at-rest.test.ts | 2 +- .../claude-structured-in-turn-stop.test.ts | 2 +- ...e-structured-live-provider-process.test.ts | 2 +- ...claude-structured-prompt-ownership.test.ts | 4 +- .../claude-structured-queued-stop.test.ts | 2 +- .../claude-structured-real-cli-fold.test.ts | 2 +- ...claude-structured-real-cli-visuals.test.ts | 2 +- .../claude/claude-structured-real-cli.test.ts | 30 ++--- .../claude-structured-requested-stop.test.ts | 2 +- .../claude-structured-resume-point.test.ts | 6 +- .../claude-structured-retired-model.test.ts | 4 +- ...ed-session-acquisition-processless.test.ts | 2 +- .../claude-structured-session-acquisition.ts | 8 +- .../claude-structured-session-adapter.test.ts | 41 +++--- .../claude-structured-session-adapter.ts | 12 +- .../claude-structured-session-close.test.ts | 14 +-- ...claude-structured-session-commands.test.ts | 6 +- ...aude-structured-session-exit-blame.test.ts | 2 +- ...structured-session-reading-control.test.ts | 12 +- ...claude-structured-session-recovery.test.ts | 24 ++-- .../claude-structured-session-startup.test.ts | 16 +-- .../claude/claude-structured-session-state.ts | 2 +- .../claude-structured-session-test-support.ts | 8 +- .../claude-structured-start-fast-mode.test.ts | 2 +- ...structured-startup-settled-test-support.ts | 2 +- ...laude-structured-turn-resume-point.test.ts | 2 +- ...claude-subagent-permission-request.test.ts | 2 +- ...aude-subagent-resume-after-restart.test.ts | 26 ++-- src/main/claude/claude-turn-ownership.test.ts | 2 +- .../codex-provider-retry-idle-sweep.test.ts | 7 +- ...-structured-acquisition-exit-proof.test.ts | 8 +- .../codex-structured-launch-resolution.ts | 2 +- .../codex-structured-publish-at-spawn.test.ts | 20 +-- .../codex-structured-question-order.test.ts | 8 +- .../codex-structured-service-tier.test.ts | 20 +-- ...odex-structured-session-adapter-fixture.ts | 8 +- .../codex-structured-session-adapter.test.ts | 18 +-- .../codex/codex-structured-session-adapter.ts | 17 +-- .../codex-structured-session-close.test.ts | 9 +- ...structured-session-options-catalog.test.ts | 4 +- .../codex-structured-session-shutdown.test.ts | 4 +- ...e.ts => codex-structured-session-spawn.ts} | 36 +++--- .../codex/codex-structured-session-start.ts | 4 +- .../codex-trust-config-mutation-queue.test.ts | 2 +- ...-agent-session-accept-then-deliver.test.ts | 114 ++++++++--------- ...-agent-session-acquisition-options.test.ts | 78 ++++-------- ...-session-acquisition-runtime-stamp.test.ts | 8 +- .../structured-agent-session-acquisition.ts | 45 ++----- ...nt-session-adapter-router-registry.test.ts | 16 ++- ...tured-agent-session-adapter-router.test.ts | 44 ++++--- ...structured-agent-session-adapter-router.ts | 17 +-- .../structured-agent-session-adapter.test.ts | 17 ++- .../structured-agent-session-adapter.ts | 70 +++-------- ...tured-agent-session-adopted-import.test.ts | 22 ++-- ...ured-agent-session-append-delivery.test.ts | 13 +- ...red-agent-session-at-rest-commands.test.ts | 8 +- .../structured-agent-session-attach-flow.ts | 14 +-- ...ured-agent-session-attach-orchestration.ts | 28 ++--- ...-agent-session-claude-option-queue.test.ts | 8 +- ...red-agent-session-claude-root-exit.test.ts | 4 +- ...d-agent-session-close-aborts-start.test.ts | 8 +- ...ctured-agent-session-close-verdict.test.ts | 8 +- ...-agent-session-codex-stop-note-end.test.ts | 6 +- ...tured-agent-session-codex-stop-row.test.ts | 6 +- ...-agent-session-command-publication.test.ts | 2 +- ...d-agent-session-conversation-close.test.ts | 8 +- ...red-agent-session-conversation-lifetime.ts | 2 +- ...uctured-agent-session-conversation-open.ts | 2 +- ...ed-agent-session-conversation-stop.test.ts | 16 +-- ...ured-agent-session-crash-mid-start.test.ts | 2 +- ...tured-agent-session-crash-turn-end.test.ts | 38 ++++-- ...ured-agent-session-create-adoption.test.ts | 18 +-- .../structured-agent-session-create.test.ts | 48 ++++--- .../structured-agent-session-delivery-loop.ts | 33 ++--- ...ructured-agent-session-drivability.test.ts | 26 ++-- ...structured-agent-session-event-recovery.ts | 8 +- ...ession-failed-create-owner-verdict.test.ts | 28 +++-- ...session-failed-create-sink-release.test.ts | 21 ++-- ...ctured-agent-session-forget-status.test.ts | 4 +- ...tured-agent-session-grouped-prompt.test.ts | 14 ++- ...d-agent-session-held-start.test-support.ts | 76 +++++------- .../structured-agent-session-host-delivery.ts | 6 +- ...nt-session-host-held-child-renewal.test.ts | 7 +- ...structured-agent-session-host-mutations.ts | 6 +- ...ctured-agent-session-host-runtime-state.ts | 6 +- ...ctured-agent-session-host-teardown.test.ts | 6 +- .../structured-agent-session-host-teardown.ts | 4 +- ...ed-agent-session-host-test-abandon.test.ts | 8 +- ...uctured-agent-session-host-test-harness.ts | 16 ++- .../structured-agent-session-host-types.ts | 2 +- .../structured-agent-session-host.test.ts | 40 +++--- .../structured-agent-session-host.ts | 4 +- ...tructured-agent-session-idle-sweep.test.ts | 4 +- ...gent-session-instant-start.test-support.ts | 40 ++---- ...t-session-interrupted-continuation.test.ts | 12 +- ...ured-agent-session-late-settlement.test.ts | 8 +- ...t-session-launch-send-after-create.test.ts | 18 +-- ...gent-session-legacy-handoff-record.test.ts | 20 +-- ...ssion-main-agent-working-agreement.test.ts | 7 +- ...ructured-agent-session-mutation-context.ts | 4 +- ...d-agent-session-newer-content-host.test.ts | 12 +- ...ed-agent-session-option-settlement.test.ts | 29 ++--- ...ured-agent-session-permission-mode.test.ts | 10 +- ...ent-session-pre-spawn-first-answer.test.ts | 7 +- ...nt-session-processless-reservation.test.ts | 65 ++++++---- ...ent-session-prompt-answer-receipts.test.ts | 8 +- ...agent-session-prompt-cancel-repeat.test.ts | 6 +- ...gent-session-provider-child-record.test.ts | 102 ++++++++------- ...ed-agent-session-provider-open-contract.ts | 2 +- ...ctured-agent-session-provider-open.test.ts | 9 +- .../structured-agent-session-provider-open.ts | 16 +-- ...red-agent-session-provider-restore.test.ts | 8 +- ...t-session-provider-started-catalog.test.ts | 29 +++-- ...red-agent-session-provider-started.test.ts | 4 +- ...ructured-agent-session-provider-started.ts | 22 ++-- ...ructured-agent-session-provider-support.ts | 2 +- ...tructured-agent-session-provider-waits.ts} | 12 +- ...ueued-message-rig-provider.test-fixture.ts | 19 ++- ...session-queued-message-rig.test-fixture.ts | 20 +-- ...gent-session-ready-operation-order.test.ts | 6 +- ...tured-agent-session-recovery-exits.test.ts | 38 +++--- ...ctured-agent-session-refusal-retry.test.ts | 8 +- ...gent-session-registered-definition.test.ts | 12 +- ...uctured-agent-session-repeat-press.test.ts | 7 +- ...ctured-agent-session-repeated-stop.test.ts | 6 +- ...agent-session-replaced-server-turn.test.ts | 4 +- ...ctured-agent-session-resend-answer.test.ts | 8 +- .../structured-agent-session-rest-test-rig.ts | 24 ++-- ...red-agent-session-restart-audience.test.ts | 8 +- ...d-agent-session-restart-child-work.test.ts | 4 +- ...ent-session-restart-failure-filing.test.ts | 4 +- ...ssion-restart-interruption-test-harness.ts | 8 +- ...ssion-restart-offer-after-reattach.test.ts | 4 +- ...gent-session-restart-offer-endings.test.ts | 4 +- ...t-session-restart-offer-newer-orca.test.ts | 22 ++-- ...t-session-restart-offer-withdrawal.test.ts | 36 +++--- ...ed-agent-session-restart-ownership.test.ts | 26 ++-- ...nt-session-restart-provider-output.test.ts | 2 +- ...nt-session-restart-recovery-output.test.ts | 4 +- ...nt-session-restart-resume-progress.test.ts | 12 +- ...session-restart-status-publication.test.ts | 8 +- ...nt-session-restart-stream-duration.test.ts | 6 +- ...red-agent-session-restart-teardown.test.ts | 16 +-- ...d-agent-session-restart-undrivable.test.ts | 4 +- ...tured-agent-session-rewind-at-rest.test.ts | 7 +- ...red-agent-session-rewind-live-send.test.ts | 7 +- .../structured-agent-session-rewind.test.ts | 11 +- ...agent-session-send-open-stale-turn.test.ts | 14 +-- ...red-agent-session-send-preparation.test.ts | 101 ++++++++------- ...session-send-restarts-failed-start.test.ts | 52 ++++---- ...agent-session-settled-attach-retry.test.ts | 22 ++-- ...red-agent-session-shutdown-cut-row.test.ts | 4 +- ...ured-agent-session-stale-turn-open.test.ts | 8 +- ...d-agent-session-stale-turn-verdict.test.ts | 2 +- ...uctured-agent-session-start-failure-row.ts | 21 ++++ ...agent-session-start-failure-writer.test.ts | 3 +- ...-agent-session-startup-attempt-contract.ts | 4 +- ...agent-session-startup-attempt-host.test.ts | 117 +++++++++--------- ...sion-startup-attempt-registrations.test.ts | 23 ++-- ...ured-agent-session-startup-attempt.test.ts | 6 +- ...tructured-agent-session-startup-attempt.ts | 16 +-- ...nt-session-startup-permission-mode.test.ts | 40 +++--- ...ctured-agent-session-startup-picks.test.ts | 25 ++-- ...red-agent-session-stop-never-waits.test.ts | 12 +- ...gent-session-stop-note-opened-turn.test.ts | 7 +- ...t-session-stop-note-withdrawn-send.test.ts | 7 +- ...ssion-subscriber-journal-retention.test.ts | 6 +- ...red-agent-session-surface-lifetime.test.ts | 45 ++++--- .../structured-agent-session-turns.test.ts | 14 ++- .../structured-agent-session-turns.ts | 6 +- ...t-session-wedged-profile-migration.test.ts | 51 ++++---- ...ed-conversation-command-child-work.test.ts | 12 +- .../structured-conversation-command.test.ts | 31 ++--- ...structured-conversation-compaction.test.ts | 18 +-- ...ructured-chat-naming-command-first.test.ts | 7 +- ...structured-chat-naming-status-hook.test.ts | 9 +- ...ad-managed-idle-exit-chat-provider.test.ts | 2 +- src/main/pi/rpc-configured-default.test.ts | 12 +- src/main/pi/rpc-location.test.ts | 2 +- .../pi/rpc-session-adapter.test-fixture.ts | 12 +- src/main/pi/rpc-session-adapter.test.ts | 21 ++-- src/main/pi/rpc-session-adapter.ts | 6 +- src/main/pi/rpc-session-startup.test.ts | 18 ++- src/main/pi/rpc-session-startup.ts | 4 +- src/main/pi/rpc-session.ts | 4 +- ...ured-agent-session-adoption-replay.test.ts | 14 ++- .../structured-agent-session-at-rest.test.ts | 34 ++--- ...tured-agent-session-create-message.test.ts | 34 ++--- .../structured-agent-session-hold.test.ts | 28 +++-- ...uctured-agent-session-options-read.test.ts | 29 ++--- .../structured-agent-session-options-read.ts | 8 +- ...nt-session-options-workspace-scope.test.ts | 4 +- ...session-restart-unregistered-agent.test.ts | 4 +- .../structured-chat-tab-table.test-fixture.ts | 8 +- .../structured-agent-launch-parity.test.ts | 6 +- .../structured-agent-runtime-registrations.ts | 6 +- ...ured-agent-session-failure-logging.test.ts | 6 +- 234 files changed, 1802 insertions(+), 1709 deletions(-) rename src/main/acp/{acp-structured-acquire.ts => acp-structured-spawn.ts} (92%) rename src/main/codex/{codex-structured-session-acquire.ts => codex-structured-session-spawn.ts} (89%) rename src/main/native-chat/agent-session-wire/{structured-agent-session-acquire-aborts.ts => structured-agent-session-provider-waits.ts} (69%) diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index 7049381ae810..765864641fea 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -6,10 +6,8 @@ export { FakeAcpChild, PID } from './acp-scripted-connection.test-fixture' import { vi } from 'vitest' import { z } from 'zod' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' -import type { - StructuredAgentSessionLifecycleEvent, - StructuredAgentSessionStartedEvent -} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import { openProviderTimelineRig, SESSION, @@ -56,17 +54,17 @@ export type AcpAdapterRig = { spawned: string[] lifecycle: StructuredAgentSessionLifecycleEvent[] readonly ended: Extract[] - started(): StructuredAgentSessionStartedEvent + started(): StructuredAgentSessionProviderOpenResult openFailure(): unknown settled: Parameters>[0][] - acquire(options?: { + spawn(options?: { fence?: number onSpawned?: () => Promise signal?: AbortSignal waitForStart?: boolean options?: Readonly> optionRevision?: () => number - }): ReturnType + }): ReturnType /** Frames Orca wrote to the agent with this method. */ sent(method: string): FakeFrame[] /** Waits for the `index`th frame Orca writes with this method. */ @@ -90,6 +88,7 @@ export async function openAcpAdapterRig( const lifecycle: StructuredAgentSessionLifecycleEvent[] = [] const settled: AcpAdapterRig['settled'] = [] let openFailure: unknown + let opened: StructuredAgentSessionProviderOpenResult | null = null const startListeners = new Set<() => void>() const spec = options.spec ?? GROK const resolveLaunch: AcpStructuredSessionAdapterDeps['resolveLaunch'] = @@ -174,17 +173,17 @@ export async function openAcpAdapterRig( return lifecycle.filter((event) => event.type === 'ended') }, started: () => { - const event = lifecycle.findLast((entry) => entry.type === 'started') - if (event?.type !== 'started') { + if (!opened) { throw new Error('ACP session has not started') } - return event + return opened }, openFailure: () => openFailure, settled, - acquire: async (acquireOptions = {}) => { + spawn: async (acquireOptions = {}) => { openFailure = undefined - const acquisition = await adapter.acquire({ + opened = null + const acquisition = await adapter.spawn({ identity: { sessionId: SESSION, workspaceId: 'workspace-1', @@ -205,19 +204,12 @@ export async function openAcpAdapterRig( }) if (acquireOptions.waitForStart !== false) { try { - const opened = await adapter.open({ + opened = await adapter.open({ sessionId: SESSION, fence: acquireOptions.fence ?? 1, - acquisitionGeneration: acquisition.acquisitionGeneration ?? null, + acquisitionGeneration: acquisition.acquisitionGeneration, signal: acquireOptions.signal ?? new AbortController().signal }) - lifecycle.push({ - ...opened, - type: 'started', - sessionId: SESSION, - fence: acquireOptions.fence ?? 1, - acquisitionGeneration: acquisition.acquisitionGeneration ?? '' - }) } catch (error) { openFailure = error await adapter.closeSession(SESSION) diff --git a/src/main/acp/acp-structured-auth.test.ts b/src/main/acp/acp-structured-auth.test.ts index 2d83832ea434..caa6e42f5462 100644 --- a/src/main/acp/acp-structured-auth.test.ts +++ b/src/main/acp/acp-structured-auth.test.ts @@ -59,7 +59,7 @@ async function signInWith(options: { initialize: { authMethods: options.authMethods }, script: requiresSignIn }) - await rig.acquire() + await rig.spawn() const methods = rig.sent('authenticate').map((frame) => METHOD.parse(frame.params).methodId) return { rig, acquired: rig.openFailure() ?? rig.started(), methods } } diff --git a/src/main/acp/acp-structured-background-task-stop.test.ts b/src/main/acp/acp-structured-background-task-stop.test.ts index 68d1b188a515..7835daa64633 100644 --- a/src/main/acp/acp-structured-background-task-stop.test.ts +++ b/src/main/acp/acp-structured-background-task-stop.test.ts @@ -277,7 +277,7 @@ describe('Grok kill route check', () => { deps: { onChildWorkEvidence: (_session, edges) => evidence.push(...edges) } }) cleanup.push(() => rig.adapter.closeAll()) - await rig.acquire() + await rig.spawn() rig.child().agent.notify('_x.ai/task_backgrounded', { sessionId: PROVIDER_SESSION, update: { sessionUpdate: 'task_backgrounded', task_id: TASK, command: 'pnpm dev' } diff --git a/src/main/acp/acp-structured-broken-stream.test.ts b/src/main/acp/acp-structured-broken-stream.test.ts index 0f2bab38d3fe..cb6cc3e99185 100644 --- a/src/main/acp/acp-structured-broken-stream.test.ts +++ b/src/main/acp/acp-structured-broken-stream.test.ts @@ -101,7 +101,7 @@ describe('ACP agent that closes its stdout but keeps running', () => { onTestFinished(async () => { await rig.adapter.closeAll().catch(() => {}) }) - await rig.acquire() + await rig.spawn() await sendHello(rig, 'broken') const turns = async () => (await rig.rig.rows()).flatMap((row) => readAgentJournalTurn(row.body) ?? []) @@ -151,7 +151,7 @@ describe('ACP agent that exits while a process it started holds its stdout open' process.kill(held) } }) - await rig.acquire() + await rig.spawn() await sendHello(rig, 'held') await vi.waitFor(() => expect(rig.ended).toHaveLength(1), { timeout: 10_000, interval: 20 }) expect(rig.ended[0]).toMatchObject({ type: 'ended', cause: 'unexpected-exit' }) diff --git a/src/main/acp/acp-structured-child-stop.test.ts b/src/main/acp/acp-structured-child-stop.test.ts index 8a5d9b51c97d..7221782d59e1 100644 --- a/src/main/acp/acp-structured-child-stop.test.ts +++ b/src/main/acp/acp-structured-child-stop.test.ts @@ -62,7 +62,7 @@ describe('Grok targeted child control contract', () => { } }) cleanup.push(() => rig.adapter.closeAll()) - await rig.acquire() + await rig.spawn() expect(rig.adapter.backgroundTaskStops(SESSION)).toEqual({ supportsTaskStop: false, supportsStopAll: false @@ -79,7 +79,7 @@ describe('Grok targeted child control contract', () => { it('enables only the proved route and checks the acquisition fence before addressing a child', async () => { const rig = await openAcpAdapterRig() cleanup.push(() => rig.adapter.closeAll()) - await rig.acquire() + await rig.spawn() expect(rig.adapter.backgroundTaskStops(SESSION)).toEqual({ supportsTaskStop: true, supportsStopAll: false @@ -100,7 +100,7 @@ describe('Grok targeted child control contract', () => { } }) cleanup.push(() => rig.adapter.closeAll()) - await rig.acquire() + await rig.spawn() expect(rig.adapter.backgroundTaskStops(SESSION)?.supportsTaskStop).toBe(false) expect(rig.child().closed).toBe(false) }) diff --git a/src/main/acp/acp-structured-compaction.test.ts b/src/main/acp/acp-structured-compaction.test.ts index e813539cc022..33ebd08f5962 100644 --- a/src/main/acp/acp-structured-compaction.test.ts +++ b/src/main/acp/acp-structured-compaction.test.ts @@ -46,7 +46,7 @@ async function commandRun(rig: AcpAdapterRig): Promise replay.attach(agent), deps: { onChildWorkEvidence: (_sessionId, batch) => evidence.push(...batch) } }) - await rig.acquire() + await rig.spawn() return { rig, replay, evidence } } diff --git a/src/main/acp/acp-structured-handshake.ts b/src/main/acp/acp-structured-handshake.ts index 22555f06d1ef..ca41ec199b0c 100644 --- a/src/main/acp/acp-structured-handshake.ts +++ b/src/main/acp/acp-structured-handshake.ts @@ -5,7 +5,7 @@ // it sends is written except context usage. import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' -import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionSpawnInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' import { ACP_REOPEN_FAILED, @@ -28,7 +28,7 @@ import { } from './acp-structured-child' type AcpHandshakeInput = { - acquire: StructuredAgentSessionAcquireInput + spawn: StructuredAgentSessionSpawnInput deps: AcpStructuredSessionAdapterDeps generation: string launch: AcpStructuredLaunch @@ -48,13 +48,13 @@ type AcpHandshakeInput = { export async function initializeAcpStructuredSession( input: AcpHandshakeInput ): Promise { - const { acquire, deps, launch, connection, options, prompts, child, onSettled } = input + const { spawn, deps, launch, connection, options, prompts, child, onSettled } = input const { spec } = deps - const sessionId = acquire.identity.sessionId + const sessionId = spawn.identity.sessionId const now = deps.now ?? Date.now const assertStarting = () => assertAcpStartingSession(child, input.abandoned) const agentName = acpAgentName(spec.agent) - const optionRevision = acquire.optionRevision?.() ?? 0 + const optionRevision = spawn.optionRevision?.() ?? 0 assertStarting() const initialized = await connection.initialize() assertStarting() @@ -106,10 +106,10 @@ export async function initializeAcpStructuredSession( const restoreSkipped: string[] = [] const link: AgentSessionProviderHandleLink = { linkId: - deps.mintLinkId?.() ?? `${spec.agent}-${acquire.fence}-${started.sessionId}`.slice(0, 128), + deps.mintLinkId?.() ?? `${spec.agent}-${spawn.fence}-${started.sessionId}`.slice(0, 128), handle: { transport: ACP_HANDLE_TRANSPORT, agent: spec.agent, nativeId: started.sessionId }, origin: started.kind === 'new' ? 'created' : 'resumed', - mintedAtFence: acquire.fence, + mintedAtFence: spawn.fence, observedAt: now(), ...takeover } @@ -127,7 +127,7 @@ export async function initializeAcpStructuredSession( restoreSkipped }) const reading = liveLane - const unbind = acquire.events?.bindReadingControl?.({ + const unbind = spawn.events?.bindReadingControl?.({ pauseReading: () => connection.pauseReading(), resumeReading: () => { connection.resumeReading() diff --git a/src/main/acp/acp-structured-host-start-aborts.test.ts b/src/main/acp/acp-structured-host-start-aborts.test.ts index 28ac9d09fd4c..a178d8f1afc4 100644 --- a/src/main/acp/acp-structured-host-start-aborts.test.ts +++ b/src/main/acp/acp-structured-host-start-aborts.test.ts @@ -29,7 +29,7 @@ const within = (promise: Promise, ms: number): Promise /** Runs `atEnd` once, right before the host ends an attach's controller: after its commit. */ function beforeAttachEnds(host: StructuredAgentSessionHost, atEnd: () => void): void { - const aborts = host.collaboratorsForTests().runtimeState.acquireAborts + const aborts = host.collaboratorsForTests().runtimeState.providerWaits const begin = aborts.begin.bind(aborts) let fired = false aborts.begin = (sessionId) => { diff --git a/src/main/acp/acp-structured-image-prompts.test.ts b/src/main/acp/acp-structured-image-prompts.test.ts index c793adebf66a..e9ac2055bc8b 100644 --- a/src/main/acp/acp-structured-image-prompts.test.ts +++ b/src/main/acp/acp-structured-image-prompts.test.ts @@ -32,7 +32,7 @@ describe('ACP image prompts', () => { spec: acpLaunchSpecFor('opencode')!, initialize: { agentCapabilities: { loadSession: true, promptCapabilities: { image: true } } } }) - await rig.acquire() + await rig.spawn() await expect( rig.adapter.dispatch({ sessionId: SESSION, clientMessageId: 'm1', body: withImage, fence: 1 }) ).resolves.toEqual({ state: 'admitted' }) @@ -49,7 +49,7 @@ describe('ACP image prompts', () => { it('refuses the message, sending nothing, when the agent did not advertise images', async () => { const rig = await openAcpAdapterRig({ spec: acpLaunchSpecFor('opencode')! }) - await rig.acquire() + await rig.spawn() await expect( rig.adapter.dispatch({ sessionId: SESSION, clientMessageId: 'm1', body: withImage, fence: 1 }) ).resolves.toMatchObject({ state: 'rejected', rejection: { kind: 'attachmentInvalid' } }) @@ -61,7 +61,7 @@ describe('ACP image prompts', () => { const rig = await openAcpAdapterRig({ initialize: { agentCapabilities: { loadSession: true, promptCapabilities: { image: true } } } }) - await rig.acquire() + await rig.spawn() await expect( rig.adapter.dispatch({ sessionId: SESSION, clientMessageId: 'm1', body: withImage, fence: 1 }) ).resolves.toMatchObject({ state: 'rejected' }) diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index 2f820c0c4196..4baa52be014c 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -1,7 +1,7 @@ // How a durable session record becomes an ACP agent launch, on the machine this runtime runs on. // // Every input is read back from the record the store made durable, never from the call that -// triggered the acquire: the working directory, the account home, and the provider session a +// triggered the spawn: the working directory, the account home, and the provider session a // resume names are the ones this session proved. import { delimiter } from 'node:path' diff --git a/src/main/acp/acp-structured-omp-recordings.test.ts b/src/main/acp/acp-structured-omp-recordings.test.ts index 62666cc9ba9e..8ffdb4ed94fd 100644 --- a/src/main/acp/acp-structured-omp-recordings.test.ts +++ b/src/main/acp/acp-structured-omp-recordings.test.ts @@ -34,7 +34,7 @@ async function replaying(name: string) { spec: acpLaunchSpecFor('omp')!, script: (scripted) => replay.attach(scripted) }) - await rig.acquire() + await rig.spawn() await rig.adapter.dispatch({ sessionId: SESSION, clientMessageId: 'send-1', body: ask, fence: 1 }) return { rig, replay } } @@ -77,7 +77,7 @@ describe('OMP recordings through the adapter', () => { spec: acpLaunchSpecFor('omp')!, script: (scripted) => replay.attach(scripted) }) - await rig.acquire() + await rig.spawn() await rig.adapter.dispatch({ sessionId: SESSION, clientMessageId: 'send-1', diff --git a/src/main/acp/acp-structured-opencode-recordings.test.ts b/src/main/acp/acp-structured-opencode-recordings.test.ts index 6adf5d1d910e..c5bb2c54b0f9 100644 --- a/src/main/acp/acp-structured-opencode-recordings.test.ts +++ b/src/main/acp/acp-structured-opencode-recordings.test.ts @@ -33,7 +33,7 @@ async function replaying(name: string) { spec: acpLaunchSpecFor('opencode')!, script: (scripted) => replay.attach(scripted) }) - await rig.acquire() + await rig.spawn() await rig.adapter.dispatch({ sessionId: SESSION, clientMessageId: 'send-1', body: ask, fence: 1 }) return { rig, replay } } diff --git a/src/main/acp/acp-structured-prompt-failure.test.ts b/src/main/acp/acp-structured-prompt-failure.test.ts index 44c452504803..f4a7f7bb313c 100644 --- a/src/main/acp/acp-structured-prompt-failure.test.ts +++ b/src/main/acp/acp-structured-prompt-failure.test.ts @@ -42,7 +42,7 @@ async function failFirstPrompt( agent.fail(frame, error.code, error.message, error.data) }) }) - await rig.acquire() + await rig.spawn() await sendHello(rig, 'first') await rig.settle() const failures = (await rig.rig.rows()).flatMap(({ body }) => diff --git a/src/main/acp/acp-structured-publish-at-spawn.test.ts b/src/main/acp/acp-structured-publish-at-spawn.test.ts index a97d1cb0fb7e..a03f4bacaae2 100644 --- a/src/main/acp/acp-structured-publish-at-spawn.test.ts +++ b/src/main/acp/acp-structured-publish-at-spawn.test.ts @@ -35,7 +35,7 @@ describe('ACP child published before its handshake', () => { }) } }) - const acquired = await rig.acquire({ + const acquired = await rig.spawn({ waitForStart: false, options: { effort: 'low', model: 'grok-4.6' }, optionRevision: () => 7 @@ -83,12 +83,12 @@ describe('ACP child published before its handshake', () => { frame = request }) }) - const acquired = await rig.acquire({ waitForStart: false }) + const acquired = await rig.spawn({ waitForStart: false }) const opening = rig.adapter .open({ sessionId: SESSION, fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) .catch((error: unknown) => error) @@ -119,7 +119,7 @@ describe('ACP child published before its handshake', () => { const rig = await openAcpAdapterRig({ script: (agent) => agent.on(answered ? 'session/new' : 'initialize', () => {}) }) - await rig.acquire({ waitForStart: false }) + await rig.spawn({ waitForStart: false }) const opening = rig.adapter .open({ sessionId: SESSION, @@ -144,12 +144,12 @@ describe('ACP child published before its handshake', () => { script: (agent) => agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required')) }) - const acquired = await rig.acquire({ waitForStart: false }) + const acquired = await rig.spawn({ waitForStart: false }) await expect( rig.adapter.open({ sessionId: SESSION, fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) ).rejects.toMatchObject({ reason: 'notSignedIn' }) @@ -165,7 +165,7 @@ describe('ACP child published before its handshake', () => { agent.fail(frame, -32602, 'model unavailable') ) }) - await rig.acquire({ options: { model: 'retired-model' } }) + await rig.spawn({ options: { model: 'retired-model' } }) expect(rig.started()).toMatchObject({ restoreSkippedOptions: [], reportedOptions: { model: 'grok-4.7', effort: 'high' } @@ -196,7 +196,7 @@ describe('ACP child published before its handshake', () => { } } }) - const acquired = await rig.acquire() + const acquired = await rig.spawn() expect(acquired.link).toBeUndefined() expect(rig.started().link).toMatchObject({ handle: { nativeId: 'saved-session' }, diff --git a/src/main/acp/acp-structured-request-admission.test.ts b/src/main/acp/acp-structured-request-admission.test.ts index 3bed54f87c9c..37078987c5d0 100644 --- a/src/main/acp/acp-structured-request-admission.test.ts +++ b/src/main/acp/acp-structured-request-admission.test.ts @@ -32,7 +32,7 @@ const permission = (toolCallId: string) => ({ async function runningPrompt() { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await sendHello(rig, 'first') await rig.frame('session/prompt') rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) @@ -87,7 +87,7 @@ describe('Grok requests and the turn they belong to', () => { it('refuses a question during a turn Grok began itself, opening no card, yet shows its plan', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() const { agent } = rig.child() agent.notify('session/update', replyChunk('task-completed-background-1', 'Build finished; now')) await rig.settle() @@ -111,7 +111,7 @@ describe('Grok requests and the turn they belong to', () => { it('refuses a question when no turn is open', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() const { agent } = rig.child() expect(await agent.request(5, 'x.ai/ask_user_question', question('call-5'))).toMatchObject({ result: { outcome: 'cancelled' } diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index 1eb12f613951..46a658c6a20b 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -30,7 +30,7 @@ const turnIdOf = (promptId: string) => providerTurnId(promptId, PROVIDER_SESSION describe('ACP Stop names a turn', () => { it('a Stop naming a turn that has ended stops nothing running now', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await sendHello(rig, 'old') const old = await rig.frame('session/prompt') rig.child().agent.notify('session/update', replyChunk('prompt:old', 'done')) @@ -61,7 +61,7 @@ describe('ACP Stop names a turn', () => { it('a Stop naming an ended turn during the gap before the next prompt opens stops that prompt', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await sendHello(rig, 'old') const old = await rig.frame('session/prompt') rig.child().agent.notify('session/update', replyChunk('prompt:old', 'done')) @@ -84,7 +84,7 @@ describe('ACP Stop names a turn', () => { it('a Stop naming the turn running now stops it and withdraws the steer waiting on its cancel', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await sendHello(rig, 'new') const current = await rig.frame('session/prompt') rig.child().agent.notify('session/update', replyChunk('prompt:new', 'working')) @@ -117,7 +117,7 @@ describe('ACP Stop names a turn', () => { describe('ACP Stop of a turn Grok began itself', () => { it('cancels a turn opened by a background completion; the Stop waits for it to end', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() const { agent } = rig.child() agent.notify('session/update', replyChunk('task-completed-background-1', 'Working on it')) await rig.settle() @@ -144,7 +144,7 @@ describe('ACP Stop of a turn Grok began itself', () => { it("ends the Stop's wait at its grace when Grok never ends its own turn", async () => { const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 20 } }) - await rig.acquire() + await rig.spawn() rig.child().agent.notify('session/update', replyChunk('task-completed-background-1', 'Busy')) await rig.settle() await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ @@ -159,7 +159,7 @@ describe('ACP Stop of a turn Grok began itself', () => { describe('ACP connection loss', () => { it('settles a send the agent never took as unknown when its stdin breaks, stops the child and tells the host', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await sendHello(rig, 'lost') await rig.frame('session/prompt') await sendHello(rig, 'held') @@ -184,7 +184,7 @@ describe('ACP connection loss', () => { it("ends a crash whose stdout closed before its exit at that exit, with Grok's last words", async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() rig.child().stderr = 'panic: out of memory' rig.child().agent.stdout.end() await rig.settle() @@ -201,7 +201,7 @@ describe('ACP connection loss', () => { it('keeps a crash a crash when a stop Orca asks for lands before its unproven exit', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() const child = rig.child() child.proveClose = async () => false child.stderr = 'panic: late' @@ -222,7 +222,7 @@ describe('ACP connection loss', () => { rejection: { kind: 'providerExited' } }) // The next start's stop of the old child is one Orca asked for; it does not rename the crash. - await expect(rig.acquire()).rejects.toMatchObject({ + await expect(rig.spawn()).rejects.toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) child.exit() @@ -236,7 +236,7 @@ describe('ACP connection loss', () => { it('leaves a running turn unverifiable, never completed, when its stdin breaks mid-turn', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await sendHello(rig, 'lost') await rig.frame('session/prompt') rig.child().agent.notify('session/update', replyChunk('prompt:lost', 'partial')) @@ -250,7 +250,7 @@ describe('ACP connection loss', () => { it('ends the turn as failed on an answer Orca cannot read, and sends the next message', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await sendHello(rig, 'garbled') const prompt = await rig.frame('session/prompt') rig.child().agent.notify('session/update', replyChunk('prompt:garbled', 'partial')) @@ -277,7 +277,7 @@ describe('ACP connection loss', () => { it('leaves Grok running when it ends its stdout but can still be written to', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await sendHello(rig, 'quiet') await rig.frame('session/prompt') rig.child().agent.notify('session/update', replyChunk('prompt:quiet', 'partial')) @@ -298,7 +298,7 @@ describe('ACP refusals', () => { script: (agent) => agent.on('session/prompt', (frame) => agent.fail(frame, -32602, 'unsupported input')) }) - await rig.acquire() + await rig.spawn() await sendHello(rig, 'refused') await waitFor(() => expect(rig.settled).toMatchObject([ @@ -314,7 +314,7 @@ describe('ACP refusals', () => { it('ends a started turn as failed when the agent answers its prompt with an error', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await sendHello(rig, 'failing') const prompt = await rig.frame('session/prompt') rig.child().agent.notify('session/update', replyChunk('prompt:failing', 'partial')) @@ -334,12 +334,12 @@ describe('ACP refusals', () => { describe('ACP startup that never answers', () => { it('lets a close stop the published child while initialize is unanswered', async () => { const rig = await openAcpAdapterRig({ script: (agent) => agent.on('initialize', () => {}) }) - const acquired = await rig.acquire({ waitForStart: false }) + const acquired = await rig.spawn({ waitForStart: false }) const opening = rig.adapter .open({ sessionId: SESSION, fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) .catch((error: unknown) => error) @@ -348,13 +348,12 @@ describe('ACP startup that never answers', () => { expect(await opening).toBeInstanceOf(Error) expect(rig.child().exited).toBe(true) expect(rig.ended).toMatchObject([{ cause: 'requested-close' }]) - expect(rig.lifecycle.some((event) => event.type === 'started')).toBe(false) }) it('leaves a published child alone when the acquisition signal aborts later', async () => { const rig = await openAcpAdapterRig() const start = new AbortController() - await rig.acquire({ signal: start.signal }) + await rig.spawn({ signal: start.signal }) start.abort(new Error('closed while starting')) await rig.settle() expect(rig.child().closes).toBe(0) @@ -363,12 +362,12 @@ describe('ACP startup that never answers', () => { it('does not time out a published handshake, and closes it through the session', async () => { const rig = await openAcpAdapterRig({ script: (agent) => agent.on('session/new', () => {}) }) - const acquired = await rig.acquire({ waitForStart: false }) + const acquired = await rig.spawn({ waitForStart: false }) const opening = rig.adapter .open({ sessionId: SESSION, fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) .catch((error: unknown) => error) @@ -387,12 +386,12 @@ describe('ACP startup that never answers', () => { it('keeps a failed startup child until its exit is proven, and spawns no other meanwhile', async () => { const rig = await openAcpAdapterRig({ script: (agent) => agent.on('initialize', () => {}) }) - const acquired = await rig.acquire({ waitForStart: false }) + const acquired = await rig.spawn({ waitForStart: false }) const opening = rig.adapter .open({ sessionId: SESSION, fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) .catch((error: unknown) => error) @@ -405,7 +404,7 @@ describe('ACP startup that never answers', () => { expect(rig.ended).toEqual([]) expect(await rig.adapter.closeSession(SESSION)).toBe(false) await expect(rig.adapter.closeAll()).rejects.toThrow('could not be proven stopped') - await expect(rig.acquire()).rejects.toMatchObject({ + await expect(rig.spawn()).rejects.toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) expect(rig.spawned.filter((step) => step === 'spawn')).toHaveLength(1) @@ -436,7 +435,7 @@ describe('ACP startup that never answers', () => { } }) const start = new AbortController() - const failed = rig.acquire({ signal: start.signal }).catch((error: unknown) => error) + const failed = rig.spawn({ signal: start.signal }).catch((error: unknown) => error) await tick() start.abort() releaseLaunch() @@ -452,7 +451,7 @@ describe('ACP startup that never answers', () => { } } }) - expect(await rig.acquire().catch((error: unknown) => error)).toMatchObject({ + expect(await rig.spawn().catch((error: unknown) => error)).toMatchObject({ name: 'AgentSessionPreSpawnError', reason: 'launchFolderMissing' }) @@ -470,7 +469,7 @@ describe('ACP session release', () => { it('keeps nothing of a chat once its child is proven closed', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() expect(rig.adapter.backgroundTaskStops(SESSION)).toBeDefined() await rig.adapter.closeSession(SESSION) // Both answer from the entry the adapter holds for a chat; none is left. @@ -480,7 +479,7 @@ describe('ACP session release', () => { it('keeps nothing of a chat whose child exited on its own', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() rig.child().exit() await waitFor(() => expect(rig.ended).toHaveLength(1)) expect(rig.adapter.backgroundTaskStops(SESSION)).toBeUndefined() diff --git a/src/main/acp/acp-structured-session-adapter-recordings.test.ts b/src/main/acp/acp-structured-session-adapter-recordings.test.ts index 8b29e3fbb220..3796964a9992 100644 --- a/src/main/acp/acp-structured-session-adapter-recordings.test.ts +++ b/src/main/acp/acp-structured-session-adapter-recordings.test.ts @@ -29,7 +29,7 @@ const ask: AgentJournalMessageItem = { async function replaying(name: string): Promise<{ rig: AcpAdapterRig; replay: GrokFixtureReplay }> { const replay = new GrokFixtureReplay(await readAcpFixture(name)) const rig = await openAcpAdapterRig({ script: (agent) => replay.attach(agent) }) - await rig.acquire() + await rig.spawn() return { rig, replay } } 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 b263c7ce2e92..86f5f67d1ca3 100644 --- a/src/main/acp/acp-structured-session-adapter-resume.test.ts +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -111,7 +111,7 @@ describe('the reattach window', () => { it('writes nothing Grok sends while it loads, even unmarked as replay', async () => { const rig = await openAcpAdapterRig(resumesUnmarked) - await rig.acquire() + await rig.spawn() await rig.settle() expect(await rig.rig.rows()).toEqual([]) }) @@ -126,7 +126,7 @@ describe('the reattach window', () => { agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) }) }) - await rig.acquire() + await rig.spawn() await rig.settle() await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ cancelled: false, @@ -153,7 +153,7 @@ describe('the context meter across a reopen', () => { agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS, models }) ) }) - await rig.acquire() + await rig.spawn() await sendHello(rig, 'm1') const prompt = await rig.frame('session/prompt') const { agent } = rig.child() @@ -165,7 +165,7 @@ describe('the context meter across a reopen', () => { agent.reply(prompt, { stopReason: 'end_turn' }) await rig.settle() await rig.adapter.closeSession(SESSION) - await rig.acquire({ fence: 2 }) + await rig.spawn({ fence: 2 }) await rig.settle() const usage = (await rig.rig.rows()) .flatMap((row) => readAgentJournalTurn(row.body) ?? []) @@ -188,11 +188,11 @@ describe('reattaching a Grok chat the journal holds', () => { agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) }) }) - await rig.acquire() + await rig.spawn() await exchange(rig, 'hi', true) const before = (await rig.rig.rows()).map((row) => row.itemId) await rig.adapter.closeSession(SESSION) - await rig.acquire({ fence: 2 }) + await rig.spawn({ fence: 2 }) await rig.settle() expect(rig.sent('session/load')).toHaveLength(1) expect(rig.sent('session/resume')).toHaveLength(0) @@ -214,11 +214,11 @@ describe('reattaching a Grok chat the journal holds', () => { agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) }) }) - await rig.acquire() + await rig.spawn() await exchange(rig, 'hi', true) const before = await rig.rig.rows() await rig.adapter.closeSession(SESSION) - await rig.acquire({ fence: 2 }) + await rig.spawn({ fence: 2 }) await rig.settle() expect(rig.sent('session/resume')).toHaveLength(0) expect((await rig.rig.rows()).map((row) => row.itemId)).toEqual(before.map((row) => row.itemId)) @@ -234,10 +234,10 @@ describe('reattaching a Grok chat the journal holds', () => { agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) }) }) - await rig.acquire() + await rig.spawn() await exchange(rig, 'complete', false) await rig.adapter.closeSession(SESSION) - await rig.acquire({ fence: 2 }) + await rig.spawn({ fence: 2 }) await rig.settle() const rows = await rig.rig.rows() expect(await texts(rig)).toEqual(['hello', 'complete']) @@ -256,13 +256,13 @@ describe('reattaching a Grok chat the journal holds', () => { agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) }) }) - await rig.acquire() + await rig.spawn() await exchange(rig, 'hi', true) await rig.adapter.closeSession(SESSION) const before = await rig.rig.rows() // The host's order: a fresh sink per start, bound to the journal once the start succeeded. const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging(SESSION)) - await rig.adapter.acquire({ + await rig.adapter.spawn({ identity: { sessionId: SESSION, workspaceId: 'workspace-1', @@ -295,7 +295,7 @@ describe('reattaching a Grok chat the journal holds', () => { script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Resource not found')) }) - await rig.acquire() + await rig.spawn() expect(rig.started().link).toMatchObject({ origin: 'created', handle: { nativeId: PROVIDER_SESSION }, @@ -327,7 +327,7 @@ describe('reattaching a Grok chat the journal holds', () => { initialize: RESUMES, script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, code, message)) }) - await rig.acquire() + await rig.spawn() expect(rig.started().link).toMatchObject({ origin: 'created', handle: { nativeId: PROVIDER_SESSION }, @@ -358,7 +358,7 @@ describe('reattaching a Grok chat the journal holds', () => { script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, -32000, 'Authentication required')) }) - await rig.acquire() + await rig.spawn() expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' }) expect(rig.sent('session/new')).toEqual([]) }) @@ -383,7 +383,7 @@ describe('reattaching a Grok chat the journal holds', () => { agent.fail(frame, -32603, 'session file is corrupt') }) }) - await rig.acquire({ signal: start.signal }) + await rig.spawn({ signal: start.signal }) expect(rig.ended).toMatchObject([{ cause: 'requested-close' }]) expect(rig.sent('session/new')).toEqual([]) expect(await notRestoredRows(rig)).toEqual([]) @@ -407,7 +407,7 @@ describe('reattaching a Grok chat the journal holds', () => { agent.reply(frame, { configOptions: [] }) }) }) - await rig.acquire() + await rig.spawn() await rig.settle() expect(rig.adapter.readCommands(SESSION)).toEqual([ { name: 'compact', kind: 'command', description: 'Compress' } diff --git a/src/main/acp/acp-structured-session-adapter-teardown.test.ts b/src/main/acp/acp-structured-session-adapter-teardown.test.ts index fab34e911cde..63f28ec12358 100644 --- a/src/main/acp/acp-structured-session-adapter-teardown.test.ts +++ b/src/main/acp/acp-structured-session-adapter-teardown.test.ts @@ -48,7 +48,7 @@ function answerCancel(child: FakeAcpChild, prompt: FakeFrame, turn: { ended: boo } async function runningPrompt(rig: AcpAdapterRig): Promise { - await rig.acquire() + await rig.spawn() await sendHello(rig, 'send-1') return rig.frame('session/prompt') } @@ -119,7 +119,7 @@ describe('ACP teardown of a running turn', () => { describe('ACP teardown with nothing to cancel', () => { it('an idle close writes no cancel and ends the child at once', async () => { const rig = await openAcpAdapterRig({ deps: { stopGraceMs: 60_000 } }) - await rig.acquire() + await rig.spawn() await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) expect(framesOf(rig.child(), 'session/cancel')).toHaveLength(0) expect(rig.child().closes).toBe(1) diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index 54c542649140..7290ca8db755 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -42,10 +42,10 @@ function chunk(promptId: string, text: string) { } } -describe('ACP structured session adapter: acquire', () => { +describe('ACP structured session adapter: spawn', () => { it('records the child before the handshake and starts a new session with client fs and terminals off', async () => { const rig = await openAcpAdapterRig() - const acquired = await rig.acquire() + const acquired = await rig.spawn() expect(rig.spawned).toEqual(['spawn', 'onSpawned', 'initialize']) expect(acquired.process).toMatchObject({ pid: PID, spawnToken: 'spawn-1', hostId: 'local' }) expect(rig.started().link).toMatchObject({ @@ -84,7 +84,7 @@ describe('ACP structured session adapter: acquire', () => { }) } }) - await rig.acquire() + await rig.spawn() const launch = rig.child().launch expect(launch.envToDelete).toEqual(expect.arrayContaining([...STRUCTURED_CHILD_ENV_TO_DELETE])) expect(launch.envToDelete).toEqual( @@ -109,7 +109,7 @@ describe('ACP structured session adapter: acquire', () => { script: (agent) => agent.on('session/load', (frame) => agent.fail(frame, -32002, 'Session not found')) }) - await rig.acquire({ fence: 2 }) + await rig.spawn({ fence: 2 }) expect(rig.started().link).toMatchObject({ origin: 'created', supersedesKey: 'old-key' }) }) @@ -118,7 +118,7 @@ describe('ACP structured session adapter: acquire', () => { script: (agent) => agent.on('session/new', (frame) => agent.fail(frame, -32000, 'Authentication required')) }) - await rig.acquire() + await rig.spawn() expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' }) expect(rig.ended).toMatchObject([{ cause: 'requested-close' }]) expect(rig.child().closes).toBe(1) @@ -134,7 +134,7 @@ it('reports an agent that exits while starting with its own last words', async ( child.exit() }) }) - await rig.acquire() + await rig.spawn() expect(rig.ended).toMatchObject([ { startupUnproven: true, failure: { detail: { text: 'grok: config.toml is invalid' } } } ]) @@ -151,7 +151,7 @@ it('reads those last words when the agent ends its stdout before its exit is see setTimeout(() => child.exit(), 5) }) }) - await rig.acquire() + await rig.spawn() expect(rig.ended).toMatchObject([ { startupUnproven: true, failure: { detail: { text: 'grok: config.toml is invalid' } } } ]) @@ -160,7 +160,7 @@ it('reads those last words when the agent ends its stdout before its exit is see describe('ACP structured session adapter: turns', () => { it('applies a sent message’s model and effort before its prompt', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await rig.adapter.dispatch({ sessionId: SESSION, fence: 1, @@ -182,7 +182,7 @@ describe('ACP structured session adapter: turns', () => { it('restores the opened model and effort when the next message omits both choices', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await rig.adapter.dispatch({ sessionId: SESSION, fence: 1, @@ -207,7 +207,7 @@ describe('ACP structured session adapter: turns', () => { it('keeps legacy sends on the current selection when execution options are absent', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await rig.adapter.dispatch({ sessionId: SESSION, fence: 1, @@ -227,7 +227,7 @@ describe('ACP structured session adapter: turns', () => { it('refuses an omitted effort whose new model default cannot be confirmed', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await rig.adapter.dispatch({ sessionId: SESSION, fence: 1, @@ -254,7 +254,7 @@ describe('ACP structured session adapter: turns', () => { agent.fail(frame, -32602, 'Model unavailable') ) }) - await rig.acquire() + await rig.spawn() await expect( rig.adapter.dispatch({ sessionId: SESSION, @@ -271,7 +271,7 @@ describe('ACP structured session adapter: turns', () => { const rig = await openAcpAdapterRig({ deps: { spec: { ...GROK, dialect: GENERIC_ACP_DIALECT } } }) - await rig.acquire() + await rig.spawn() await send(rig, 'send-1') const prompt = await rig.frame('session/prompt') expect(prompt.params).not.toHaveProperty('_meta') @@ -279,7 +279,7 @@ describe('ACP structured session adapter: turns', () => { it('sends a prompt under an injected id and settles it on the agent first event', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() expect(await send(rig, 'send-1')).toEqual({ state: 'admitted' }) const prompt = await rig.frame('session/prompt') expect(prompt.params).toMatchObject({ @@ -305,7 +305,7 @@ describe('ACP structured session adapter: turns', () => { it('stops a running turn: open permissions answer cancelled, session/cancel goes out', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await send(rig, 'send-1') const prompt = await rig.frame('session/prompt') const { agent } = rig.child() @@ -329,7 +329,7 @@ describe('ACP structured session adapter: turns', () => { it('answers a Stop with no turn running as one the agent was not running', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 })).resolves.toEqual({ cancelled: false, refusal: { turnNotRunning: true } @@ -340,7 +340,7 @@ describe('ACP structured session adapter: turns', () => { describe('ACP structured session adapter: approvals', () => { it('commits the journal answer before replying with the option the person chose', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await send(rig, 'send-1') await rig.frame('session/prompt') const { agent } = rig.child() @@ -392,7 +392,7 @@ describe('ACP structured session adapter: approvals', () => { it('answers cancelled a permission Grok asks during a turn it began itself, with no card', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() const { agent } = rig.child() agent.notify('session/update', chunk('task-completed-background-1', 'Build finished; now')) await rig.settle() @@ -409,7 +409,7 @@ describe('ACP structured session adapter: approvals', () => { describe('ACP structured session adapter: requests a Stop cancels', () => { it("answers an open question with Grok's own cancelled reply once a Stop cancels it", async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await send(rig, 'send-1') const prompt = await rig.frame('session/prompt') const { agent } = rig.child() @@ -431,7 +431,7 @@ describe('ACP structured session adapter: requests a Stop cancels', () => { it("shows Grok's plan in the plan row and answers its plan-mode exit at once, with no card", async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await send(rig, 'send-1') await rig.frame('session/prompt') const { agent } = rig.child() @@ -455,7 +455,7 @@ describe('ACP structured session adapter: requests a Stop cancels', () => { it('sends a permission answer whose save was under way when the Stop landed', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await send(rig, 'send-1') const prompt = await rig.frame('session/prompt') const { agent } = rig.child() @@ -494,7 +494,7 @@ describe('ACP structured session adapter: options and commands', () => { script: (agent) => agent.on('session/set_config_option', (frame) => agent.reply(frame, { configOptions: [] })) }) - await rig.acquire() + await rig.spawn() const options = await rig.adapter.readOptions({ sessionId: SESSION, fence: 1 }) expect(options.current).toMatchObject({ model: 'grok-4.7', effort: 'high' }) expect(options.models.map((model) => model.id)).toEqual(['grok-4.7', 'grok-4.6']) @@ -511,7 +511,7 @@ describe('ACP structured session adapter: options and commands', () => { it('reports the agent own slash commands', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() expect(rig.adapter.readCommands(SESSION)).toBeUndefined() rig.child().agent.notify('session/update', { sessionId: PROVIDER_SESSION, @@ -535,7 +535,7 @@ describe('ACP structured session adapter: options and commands', () => { describe('ACP structured session adapter: close and exit', () => { it('reports a close whose root exited but whose process tree was not proven gone', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() rig.child().processTreeUnproven = true await expect(rig.adapter.closeSession(SESSION)).rejects.toBeInstanceOf( AgentSessionAcquisitionRootExitObservedError @@ -545,7 +545,7 @@ describe('ACP structured session adapter: close and exit', () => { it('closes with proof and reports a requested close', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await expect(rig.adapter.closeSession(SESSION)).resolves.toBe(true) expect(rig.ended).toMatchObject([ { type: 'ended', sessionId: SESSION, cause: 'requested-close', fence: 1 } @@ -556,7 +556,7 @@ describe('ACP structured session adapter: close and exit', () => { it('ends the session on an unexpected exit: held sends rejected, the running one unknown', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await send(rig, 'send-1') await rig.frame('session/prompt') await send(rig, 'send-2') diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index 597b526f2fd0..7db3394a6697 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -14,14 +14,14 @@ import { isAgentSessionPreSpawnError, type AgentSessionAcquisition, type AgentSessionDispatchOutcome, - type StructuredAgentSessionAcquireInput, + type StructuredAgentSessionSpawnInput, type StructuredAgentSessionAdapter, type StructuredAgentSessionSetOptionInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' import { withObservedProviderExit } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' import { acpAgentName } from './acp-structured-agent-definitions' -import { acquireAcpStructuredSession } from './acp-structured-acquire' +import { spawnAcpStructuredSession } from './acp-structured-spawn' import type { AcpStructuredSession } from './acp-structured-session' import type { ProviderStartAttempt } from '../provider-process/provider-acquisition-starts' import { waitForAcpExit, type AcpStructuredConnection } from './acp-structured-connection' @@ -60,7 +60,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte supportsLocation = (location: AgentSessionExecutionLocation): boolean => supportsSupervisedProviderChildLocation(location, this.deps.isWindowsProcessStartTimeAvailable) - async acquire(input: StructuredAgentSessionAcquireInput): Promise { + async spawn(input: StructuredAgentSessionSpawnInput): Promise { const sessionId = input.identity.sessionId const attempt = this.children.starts.begin(input.signal) try { @@ -78,14 +78,14 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } private async start( - input: StructuredAgentSessionAcquireInput, + input: StructuredAgentSessionSpawnInput, attempt: ProviderStartAttempt ): Promise { const sessionId = input.identity.sessionId const generation = this.deps.mintGeneration?.() ?? randomUUID() try { - const { acquisition, session } = await acquireAcpStructuredSession({ - acquire: input, + const { acquisition, session } = await spawnAcpStructuredSession({ + spawn: input, deps: this.deps, generation, abandoned: () => attempt.signal.aborted, @@ -257,11 +257,6 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte readOptions = async (input: { sessionId: string; fence: number }) => readAcpLiveOptions(this.live(input.sessionId)) - readOptionRestoreFailures = (sessionId: string): readonly string[] => { - const session = this.children.sessions.get(sessionId) - return session && isAcpReadySession(session) ? session.restoreSkipped : [] - } - readCommands = (sessionId: string) => { const session = this.children.sessions.get(sessionId) return session && isAcpReadySession(session) ? session.options.readCommands() : undefined diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts index b0cde0e1a2ae..c78b9b7581fd 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -29,6 +29,7 @@ export type AcpStructuredSession = { prompts: AcpStructuredPrompts options: AcpStructuredOptions turns: AcpStructuredTurns + restoreSkipped: readonly string[] /** Orca asked this child to stop; its exit is then a requested close. */ closeRequested: boolean /** Why nothing this child says reaches the journal any more; null while it does. */ diff --git a/src/main/acp/acp-structured-sign-in.test.ts b/src/main/acp/acp-structured-sign-in.test.ts index 1be21fab0b06..11f70703e663 100644 --- a/src/main/acp/acp-structured-sign-in.test.ts +++ b/src/main/acp/acp-structured-sign-in.test.ts @@ -39,7 +39,7 @@ describe('ACP sign-out at the first prompt', () => { ) }) }) - await rig.acquire() + await rig.spawn() await sendHello(rig, 'first') await rig.settle() if (midTurn || spec.dialect.injectedPromptIdentity !== true) { @@ -83,7 +83,7 @@ it.each(ACP_LAUNCH_SPECS)( script: (agent) => agent.on('session/new', (frame) => agent.fail(frame, -32000, detail)) }) // The host receives the refusal from open and then closes the child. - await rig.acquire() + await rig.spawn() expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' }) expect(providerDiagnosticOf(rig.openFailure())).toEqual({ text: detail, audience: 'person' }) const fact = agentSessionFailureFact('notSignedIn', { @@ -105,7 +105,7 @@ it.each(ACP_LAUNCH_SPECS)( agent.fail(frame, -32000, detail) }) }) - await rig.acquire() + await rig.spawn() expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' }) expect(providerDiagnosticOf(rig.openFailure())).toEqual({ text: detail, audience: 'person' }) } @@ -136,7 +136,7 @@ it.each([ agent.fail(frame, -32603, 'Internal error', { details: detail }) ) }) - await rig.acquire() + await rig.spawn() await sendHello(rig, 'first') await rig.settle() const row = (await rig.rig.rows()).find( diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-spawn.ts similarity index 92% rename from src/main/acp/acp-structured-acquire.ts rename to src/main/acp/acp-structured-spawn.ts index 308c75610068..105b2e0911dd 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-spawn.ts @@ -9,7 +9,7 @@ import { initializeAcpStructuredSession } from './acp-structured-handshake' import { AgentSessionPreSpawnError, type AgentSessionAcquisition, - type StructuredAgentSessionAcquireInput + type StructuredAgentSessionSpawnInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' import { providerSpawnedProcessIdentity } from '../provider-process/provider-spawned-process-identity' @@ -35,13 +35,13 @@ import { createStructuredAgentSessionLogger } from '../native-chat/agent-session /** Frames an agent may send before its session exists; past this they are dropped. */ const MAX_EARLY_FRAMES = 2_048 -export async function acquireAcpStructuredSession(input: { - acquire: StructuredAgentSessionAcquireInput +export async function spawnAcpStructuredSession(input: { + spawn: StructuredAgentSessionSpawnInput deps: AcpStructuredSessionAdapterDeps generation: string - /** The acquire was aborted; checked until the spawn, after which `track` hands it the connection. */ + /** The spawn was aborted; checked until the spawn, after which `track` hands it the connection. */ abandoned: () => boolean - /** Registers the connection so a close during the acquire can stop it. */ + /** Registers the connection so a close during the spawn can stop it. */ track: (connection: AcpStructuredConnection) => void /** The process's exit, observed while or after the session exists. */ onExit: (session: AcpStructuredChild | null) => void @@ -54,11 +54,11 @@ export async function acquireAcpStructuredSession(input: { acquisition: AgentSessionAcquisition session: AcpStructuredChild }> { - const { acquire, deps, generation } = input + const { spawn, deps, generation } = input const { spec } = deps - const sessionId = acquire.identity.sessionId + const sessionId = spawn.identity.sessionId const now = deps.now ?? Date.now - const events = acquire.events + const events = spawn.events const sink = events ? providerTimelineSink(events) : null if (!sink) { throw new AgentSessionPreSpawnError(new Error(`${spec.agent} chats need a journal sink`)) @@ -72,7 +72,7 @@ export async function acquireAcpStructuredSession(input: { } closedBeforeSpawn() const launch: AcpStructuredLaunch = await deps - .resolveLaunch({ identity: acquire.identity, spawnToken: acquire.spawnToken }) + .resolveLaunch({ identity: spawn.identity, spawnToken: spawn.spawnToken }) .catch((error: unknown) => { throw new AgentSessionPreSpawnError(error) }) @@ -112,7 +112,7 @@ export async function acquireAcpStructuredSession(input: { }, { clientInfo: { name: 'orca', version: '1' }, - ...(acquire.onOutput ? { onOutput: acquire.onOutput } : {}), + ...(spawn.onOutput ? { onOutput: spawn.onOutput } : {}), onPermission: (request, context) => { if (!isAcpReadySession(session) || !session.turns?.acceptsRequests) { // No prompt of Orca's runs (a turn the agent began itself included), or a Stop or steer @@ -160,7 +160,7 @@ export async function acquireAcpStructuredSession(input: { input.track(connection) connection.subscribe((event: AcpSessionEvent) => whenLane(() => { - const optionRevision = acquire.optionRevision?.() ?? 0 + const optionRevision = spawn.optionRevision?.() ?? 0 if (slot.lane) { routeAcpSessionEvent({ lane: slot.lane, options }, event, now(), slot.reattaching) } @@ -173,7 +173,7 @@ export async function acquireAcpStructuredSession(input: { deps.onEvent?.({ type: 'options-reported', sessionId, - fence: acquire.fence, + fence: spawn.fence, acquisitionGeneration: generation, reportedOptions: options.read().current, restoreSkippedOptions: [], @@ -183,7 +183,7 @@ export async function acquireAcpStructuredSession(input: { }) ) const identity = providerSpawnedProcessIdentity( - acquire, + spawn, `${spec.agent} ACP agent`, deps.readProcessStartTime ) @@ -229,7 +229,7 @@ export async function acquireAcpStructuredSession(input: { const process = await identity.read(pid) const child: AcpStructuredChild = { sessionId, - fence: acquire.fence, + fence: spawn.fence, acquisitionGeneration: generation, spec, connection, @@ -245,7 +245,7 @@ export async function acquireAcpStructuredSession(input: { exitObservedAt: null, open: (signal) => initializeAcpStructuredSession({ - acquire, + spawn, deps, generation, launch, @@ -273,7 +273,7 @@ export async function acquireAcpStructuredSession(input: { session = child assertAcpStartingSession(child, input.abandoned) return { - acquisition: { process, acquisitionGeneration: generation, providerChildPhase: 'starting' }, + acquisition: { process, acquisitionGeneration: generation }, session: child } } diff --git a/src/main/acp/acp-structured-steer.test.ts b/src/main/acp/acp-structured-steer.test.ts index bcd27f99c0c3..d7f618bc08a5 100644 --- a/src/main/acp/acp-structured-steer.test.ts +++ b/src/main/acp/acp-structured-steer.test.ts @@ -61,7 +61,7 @@ function answersCancels(agent: AcpScriptedAgent): void { describe('a send while a Grok prompt runs', () => { it('cancels the running prompt, then sends as the next one', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await sendHello(rig, 'first') const first = await rig.frame('session/prompt') rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) @@ -84,7 +84,7 @@ describe('a send while a Grok prompt runs', () => { it('runs the last of two quick steers; the one between is sent and cancelled', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await sendHello(rig, 'first') await rig.frame('session/prompt') await rig.settle() @@ -108,7 +108,7 @@ describe('a send while a Grok prompt runs', () => { it('asks again on the next steer when the first cancel was never written', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await sendHello(rig, 'first') await rig.frame('session/prompt') await rig.settle() @@ -134,7 +134,7 @@ describe('a send while a Grok prompt runs', () => { it('withdraws a steer a Stop reaches before its cancel lands', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await sendHello(rig, 'first') const first = await rig.frame('session/prompt') rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) @@ -157,7 +157,7 @@ describe('a send while a Grok prompt runs', () => { it('waits as long as Grok takes to answer a steer cancel, and a Stop then still ends it', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() await sendHello(rig, 'first') await rig.frame('session/prompt') rig.child().agent.notify('session/update', replyChunk('prompt:first', 'working')) @@ -183,7 +183,7 @@ describe('a send while a Grok prompt runs', () => { describe('a send while a turn Grok began itself runs', () => { it('goes to Grok at once, with no cancel of that turn', async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() const { agent } = rig.child() agent.notify('session/update', replyChunk(GROK_TURN, 'Build finished; now')) await rig.settle() @@ -206,7 +206,7 @@ describe('a send while a turn Grok began itself runs', () => { it("cuts Grok's turn on a second send and runs both messages, never ending Grok", async () => { const rig = await openAcpAdapterRig() - await rig.acquire() + await rig.spawn() const { agent } = rig.child() agent.notify('session/update', replyChunk(GROK_TURN, 'Build finished; now')) await rig.settle() diff --git a/src/main/acp/acp-text-message-ownership.test.ts b/src/main/acp/acp-text-message-ownership.test.ts index 746e83e43fb6..c075ff7ac98a 100644 --- a/src/main/acp/acp-text-message-ownership.test.ts +++ b/src/main/acp/acp-text-message-ownership.test.ts @@ -46,7 +46,7 @@ describe.each(['omp', 'opencode', 'grok'] as const)('%s provider message ownersh }) } }) - await rig.acquire() + await rig.spawn() await sendHello(rig, 'send-1') await waitFor(async () => expect(await rig.rig.turns()).toMatchObject([ diff --git a/src/main/claude/claude-adapter-capture-replay.test.ts b/src/main/claude/claude-adapter-capture-replay.test.ts index 14cc590e2b2f..022ee3d0309e 100644 --- a/src/main/claude/claude-adapter-capture-replay.test.ts +++ b/src/main/claude/claude-adapter-capture-replay.test.ts @@ -126,7 +126,7 @@ async function replayCapture(name: string): Promise { onDispatchSettledLate: settled } const adapter = new ClaudeStructuredSessionAdapter(deps) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) // Captured uuids -> the uuids the live dispatches mint during this replay. const uuidMap = new Map() diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index dee88afdb445..2899c39b1fa2 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -58,7 +58,12 @@ beforeEach(async () => { closeSession = vi.fn(async () => true) const store = await openTestAgentSessionRecordStore(root) const adapter: StructuredAgentSessionAdapter = { - acquire: async ({ fence, spawnToken, events }) => { + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: async ({ fence, spawnToken, events }) => { sink = events ?? null return { process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, diff --git a/src/main/claude/claude-child-work-producer-harness.test-fixture.ts b/src/main/claude/claude-child-work-producer-harness.test-fixture.ts index 0b460d91b4a7..6c8fa2961c3a 100644 --- a/src/main/claude/claude-child-work-producer-harness.test-fixture.ts +++ b/src/main/claude/claude-child-work-producer-harness.test-fixture.ts @@ -160,7 +160,7 @@ export async function producer(host?: AgentHookServer) { // Production's journal publication is what republishes the parent's own row. publish: () => deliveries.push({ kind: 'publish', detail: '' }) } - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/claude/claude-close-turn-end.test.ts b/src/main/claude/claude-close-turn-end.test.ts index d115afe61a28..c590de7dc8a7 100644 --- a/src/main/claude/claude-close-turn-end.test.ts +++ b/src/main/claude/claude-close-turn-end.test.ts @@ -39,7 +39,7 @@ async function childInsideTurn() { const events: ClaudeStructuredSessionEvent[] = [] const adapter = adapterFor(claude, {}, events) const { sink, turns } = turnSink() - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) const connection = claude.connections[0]! connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index 88aa6d4a12fb..ae192eec066c 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -133,7 +133,7 @@ async function replayCapture( onDispatchSettledLate: (settlement) => settlements.push(settlement), onSessionIdle: ({ sessionId }) => idles.push(sessionId) }) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/claude/claude-context-usage-restart.test.ts b/src/main/claude/claude-context-usage-restart.test.ts index 33ce088cca0f..22d06a62ef8e 100644 --- a/src/main/claude/claude-context-usage-restart.test.ts +++ b/src/main/claude/claude-context-usage-restart.test.ts @@ -113,7 +113,7 @@ async function openJournal(): Promise { } /** One acquisition: a fresh sink and translator over the session's journal. */ -function acquire(journal: AgentSessionJournal) { +function spawn(journal: AgentSessionJournal) { const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const translator = createClaudeJournalTranslator({ sink: deferred.sink, coalesceMs: 0 }) deferred.bind({ journal, fence: 1, publish: () => {} }) @@ -147,7 +147,7 @@ const ring = (journal: AgentSessionJournal) => describe('context usage across a restart', () => { it('resets the ring at a /compact that is the first thing after a restart, then reads the fresh report', async () => { const journal = await openJournal() - const before = acquire(journal) + const before = spawn(journal) before.translator.handle(initFrame(900)) before.translator.handle(userFrame('turn-a', 1_000)) before.translator.handle(assistantFrame('reply-a', 2_000, 150_000)) @@ -157,7 +157,7 @@ describe('context usage across a restart', () => { before.release() // A new acquisition builds a new translator; it holds no turn of its own. - const after = acquire(journal) + const after = spawn(journal) after.translator.handle(compactBoundary(10_000)) await after.settle() expect(ring(journal)).toBeNull() @@ -178,7 +178,7 @@ describe('context usage across a restart', () => { it('reads the ring from the journal after a restart, and adopts the first response model', async () => { const journal = await openJournal() - const before = acquire(journal) + const before = spawn(journal) before.translator.handle(initFrame(900)) before.translator.handle(userFrame('turn-a', 1_000)) before.translator.handle(assistantFrame('reply-a', 2_000, 150_000)) @@ -186,7 +186,7 @@ describe('context usage across a restart', () => { await before.settle() before.release() - const after = acquire(journal) + const after = spawn(journal) expect(ring(journal)).toMatchObject({ usedTokens: 150_000, windowTokens: 1_000_000 }) after.translator.handle(initFrame(9_900)) after.translator.handle(userFrame('turn-b', 10_000)) @@ -202,7 +202,7 @@ describe('context usage across a restart', () => { it('keeps the last known size of a turn the child crashed in', async () => { const journal = await openJournal() - const crashed = acquire(journal) + const crashed = spawn(journal) crashed.translator.handle(initFrame(900)) crashed.translator.handle(userFrame('turn-a', 1_000)) crashed.translator.handle(assistantFrame('reply-a', 2_000, 20_000)) @@ -228,7 +228,7 @@ describe('context usage across a restart', () => { it('never brings a settled turn back to running with a context write that ran late', async () => { const journal = await openJournal() - const live = acquire(journal) + const live = spawn(journal) live.translator.handle(initFrame(900)) live.translator.handle(userFrame('turn-a', 1_000)) live.translator.handle(assistantFrame('reply-a', 2_000, 20_000)) @@ -258,7 +258,7 @@ describe('context usage across a restart', () => { it("reads a new session's implied window the same from its loaded rows and from the host", async () => { const journal = await openJournal() - const live = acquire(journal) + const live = spawn(journal) live.translator.modelWritten('opus[1m]') live.translator.handle(initFrame(900)) live.translator.handle(userFrame('turn-a', 1_000)) @@ -274,7 +274,7 @@ describe('context usage across a restart', () => { it('keeps the window a restarted journal holds over the one the model name implies', async () => { const journal = await openJournal() - const before = acquire(journal) + const before = spawn(journal) before.translator.handle(initFrame(900)) before.translator.handle(userFrame('turn-a', 1_000)) before.translator.handle(assistantFrame('reply-a', 2_000, 150_000)) @@ -282,7 +282,7 @@ describe('context usage across a restart', () => { await before.settle() before.release() - const after = acquire(journal) + const after = spawn(journal) after.translator.modelWritten('fable[1m]') after.translator.handle(initFrame(9_900)) after.translator.handle(userFrame('turn-b', 10_000)) diff --git a/src/main/claude/claude-fold-steer-receipt.test.ts b/src/main/claude/claude-fold-steer-receipt.test.ts index bc15a52cecd5..9ed86835e4b3 100644 --- a/src/main/claude/claude-fold-steer-receipt.test.ts +++ b/src/main/claude/claude-fold-steer-receipt.test.ts @@ -100,7 +100,7 @@ async function riggedAdapter( onDispatchSettledLate: settled } const adapter = new ClaudeStructuredSessionAdapter(deps) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) await claudeStartupSettled(adapter, 'session-1') return { adapter, diff --git a/src/main/claude/claude-structured-acquisition-launch.ts b/src/main/claude/claude-structured-acquisition-launch.ts index 809a81d70263..cf7277d2b955 100644 --- a/src/main/claude/claude-structured-acquisition-launch.ts +++ b/src/main/claude/claude-structured-acquisition-launch.ts @@ -2,7 +2,7 @@ import { claudeProviderHandle } from '../../shared/agent-session-provider-handle import { AgentSessionAcquisitionExitUnprovenError, AgentSessionPreSpawnError, - type StructuredAgentSessionAcquireInput + type StructuredAgentSessionSpawnInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { stopAgentSessionProviderRoot } from '../native-chat/agent-session-wire/structured-agent-session-provider-exit-proof' import { withAgentSessionCreatePhase } from '../observability/agent-session-instrumentation' @@ -31,7 +31,7 @@ export type ClaudeAcquisitionLaunch = ClaudeStructuredLaunch & { } export async function resolveClaudeAcquisitionLaunch(args: { - input: StructuredAgentSessionAcquireInput + input: StructuredAgentSessionSpawnInput deps: ClaudeStructuredSessionAdapterDeps sessions: Map acquisitions: ClaudeAcquisitionRegistry diff --git a/src/main/claude/claude-structured-applied-options.test.ts b/src/main/claude/claude-structured-applied-options.test.ts index 8f5d8379e657..5afa0f1bb4f4 100644 --- a/src/main/claude/claude-structured-applied-options.test.ts +++ b/src/main/claude/claude-structured-applied-options.test.ts @@ -165,7 +165,7 @@ describe('Claude model before the first turn', () => { const events: ClaudeStructuredSessionEvent[] = [] const adapter = adapterFor(claude, { resumesTranscript: true, continuesChain: true }, events) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/claude/claude-structured-configured-default.test.ts b/src/main/claude/claude-structured-configured-default.test.ts index a003253e408c..5ba7159ea0a7 100644 --- a/src/main/claude/claude-structured-configured-default.test.ts +++ b/src/main/claude/claude-structured-configured-default.test.ts @@ -86,7 +86,7 @@ async function startedChat( persistHandle: async () => {}, onEvent: (event) => events.push(event) }) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(SESSION), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 7cc0a917b4aa..d9cb9691b9ff 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 @@ -115,7 +115,7 @@ async function startChild( now: () => 1_700_000_000_500, persistHandle: async () => {} }) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(SESSION), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/claude/claude-structured-in-turn-stop.test.ts b/src/main/claude/claude-structured-in-turn-stop.test.ts index 93e8e9ee6d6a..2bcfd8c1dd6c 100644 --- a/src/main/claude/claude-structured-in-turn-stop.test.ts +++ b/src/main/claude/claude-structured-in-turn-stop.test.ts @@ -39,7 +39,7 @@ async function runningChat(claude: ReturnType): Promise<{ const bodies = new Map() const journal = createJournalReducerState('session-1', 'epoch-1') const adapter = adapterFor(claude) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/claude/claude-structured-live-provider-process.test.ts b/src/main/claude/claude-structured-live-provider-process.test.ts index a829f0906a22..1e397b74a8e4 100644 --- a/src/main/claude/claude-structured-live-provider-process.test.ts +++ b/src/main/claude/claude-structured-live-provider-process.test.ts @@ -10,7 +10,7 @@ describe('Claude adapter liveness for lease renewal', () => { it('holds its acquisition until the root exit is seen', async () => { const claude = fakeClaude() const adapter = adapterFor(claude) - const { acquisitionGeneration } = await adapter.acquire({ + const { acquisitionGeneration } = await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/claude/claude-structured-prompt-ownership.test.ts b/src/main/claude/claude-structured-prompt-ownership.test.ts index 145d0db71347..762f0dd57283 100644 --- a/src/main/claude/claude-structured-prompt-ownership.test.ts +++ b/src/main/claude/claude-structured-prompt-ownership.test.ts @@ -140,7 +140,7 @@ describe('Claude live prompt ownership', () => { const claude = fakeClaude({ replayUuid: 'turn-1' }) const recorded = lifecycleRecorder() const adapter = adapterFor(claude) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', @@ -177,7 +177,7 @@ describe('Claude live prompt ownership', () => { const claude = fakeClaude({ replayUuid: 'turn-1' }) const recorded = lifecycleRecorder() const adapter = adapterFor(claude) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/claude/claude-structured-queued-stop.test.ts b/src/main/claude/claude-structured-queued-stop.test.ts index 6f2f19e5fab7..dba56b78bbb0 100644 --- a/src/main/claude/claude-structured-queued-stop.test.ts +++ b/src/main/claude/claude-structured-queued-stop.test.ts @@ -68,7 +68,7 @@ async function acquired( const adapter = adapterFor(claude, {}, [], [], undefined, undefined, undefined, (settlement) => settlements.push(settlement) ) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/claude/claude-structured-real-cli-fold.test.ts b/src/main/claude/claude-structured-real-cli-fold.test.ts index 8abcc0de2c9b..871e81ea074c 100644 --- a/src/main/claude/claude-structured-real-cli-fold.test.ts +++ b/src/main/claude/claude-structured-real-cli-fold.test.ts @@ -103,7 +103,7 @@ describe.skipIf(!realClaudeAvailable)('Claude structured real CLI fold', () => { readProcessStartTime: async () => 1 }) try { - await adapter.acquire({ + await adapter.spawn({ identity: identity(providerSessionId), fence: 1, spawnToken: 'real-cli-fold', diff --git a/src/main/claude/claude-structured-real-cli-visuals.test.ts b/src/main/claude/claude-structured-real-cli-visuals.test.ts index 2365b85d7354..66488b242f81 100644 --- a/src/main/claude/claude-structured-real-cli-visuals.test.ts +++ b/src/main/claude/claude-structured-real-cli-visuals.test.ts @@ -78,7 +78,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { const folder = nativeChatVisualsFolderFor(stateDirectory, SESSION_ID) expect(launch.options.additionalDirectories).toEqual([folder]) expect(launch.options.plugins).toHaveLength(1) - await adapter.acquire({ + await adapter.spawn({ identity: { ...identity, providerHandle: claudeProviderHandle(launch.providerSessionId, null) diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index 66b996819014..b7d53dd4b142 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -69,9 +69,9 @@ function realAdapter( return adapter } // These proofs read startup facts, which land after the session is published. - const acquire = adapter.acquire - adapter.acquire = async (input) => { - const acquisition = await acquire(input) + const spawn = adapter.spawn + adapter.spawn = async (input) => { + const acquisition = await spawn(input) await claudeStartupSettled(adapter, input.identity.sessionId) return acquisition } @@ -130,7 +130,7 @@ describe('real CLI fixture authentication isolation', () => { ) try { await expect( - adapter.acquire({ + adapter.spawn({ identity: identity(providerSessionId), fence: 1, spawnToken: 'fixture' @@ -167,7 +167,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { const adapter = realAdapter(providerSessionId, claudeConfigDir, events, cwd) try { - await adapter.acquire({ + await adapter.spawn({ identity: identity(providerSessionId), fence: 1, spawnToken: 'real-cli' @@ -214,7 +214,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { const adapter = realAdapter(providerSessionId, claudeConfigDir, events) try { - await adapter.acquire({ + await adapter.spawn({ identity: identity(providerSessionId), fence: 1, spawnToken: 'real-cli-effort' @@ -259,7 +259,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { let transcriptPath: string | null = null try { - await adapter.acquire({ + await adapter.spawn({ identity: identity(providerSessionId), fence: 1, spawnToken: 'real-cli-transcript' @@ -303,7 +303,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { const adapter = realAdapter(providerSessionId, claudeConfigDir, events) try { - await adapter.acquire({ + await adapter.spawn({ identity: identity(providerSessionId), fence: 1, spawnToken: 'real-cli-model' @@ -382,7 +382,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { } try { const acquireAt = Date.now() - await adapter.acquire({ + await adapter.spawn({ identity: identity(providerSessionId), fence: 1, spawnToken: 'real-cli-started-timing' @@ -404,7 +404,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { const firstOutputAt = Date.now() // Reported for tuning; the assertions above are the contract. console.info( - `[real-cli startup timing] acquire->published ${publishedAt - acquireAt} ms, ` + + `[real-cli startup timing] spawn->published ${publishedAt - acquireAt} ms, ` + `published->open ${startedAt - publishedAt} ms, ` + `dispatch->first output ${firstOutputAt - startedAt} ms` ) @@ -439,7 +439,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { events.flatMap((event) => (event.type === 'message' ? [event.message] : [])) try { - await adapter.acquire({ + await adapter.spawn({ identity: identity(providerSessionId), fence: 1, spawnToken: 'real-cli-saved-options', @@ -518,7 +518,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { }) try { - await adapter.acquire({ + await adapter.spawn({ identity: identity(providerSessionId), fence: 1, spawnToken: 'real-cli-stop' @@ -590,7 +590,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { } try { - await adapter.acquire({ + await adapter.spawn({ identity: identity(providerSessionId), fence: 1, spawnToken: 'real-cli-queued-stop' @@ -654,7 +654,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { const frames = (): Record[] => events.flatMap((event) => (event.type === 'message' ? [event.message] : [])) try { - await adapter.acquire({ + await adapter.spawn({ identity: identity(providerSessionId), fence: 1, spawnToken: 'real-cli-no-hooks' @@ -700,7 +700,7 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { ) try { - await adapter.acquire({ + await adapter.spawn({ identity: identity(providerSessionId), fence: 1, spawnToken: 'real-cli-no-auth' diff --git a/src/main/claude/claude-structured-requested-stop.test.ts b/src/main/claude/claude-structured-requested-stop.test.ts index edcaff847bb8..ac30e51e6092 100644 --- a/src/main/claude/claude-structured-requested-stop.test.ts +++ b/src/main/claude/claude-structured-requested-stop.test.ts @@ -40,7 +40,7 @@ describe('a requested stop of a structured Claude chat', () => { const events: ClaudeStructuredSessionEvent[] = [] const adapter = adapterFor(claude, {}, events) const journal = journalSink() - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/claude/claude-structured-resume-point.test.ts b/src/main/claude/claude-structured-resume-point.test.ts index bc7ba2badc2c..dcb4c9ef195d 100644 --- a/src/main/claude/claude-structured-resume-point.test.ts +++ b/src/main/claude/claude-structured-resume-point.test.ts @@ -24,7 +24,7 @@ async function ownerMidSecondTurn(persisted: unknown[]) { [], persisted ) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-7', @@ -83,7 +83,7 @@ describe('Claude resume point is the last completed turn on every exit path', () [], persisted ) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-7', @@ -127,7 +127,7 @@ describe('Claude resume point is the last completed turn on every exit path', () throw new Error('record write failed') } ) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-7', diff --git a/src/main/claude/claude-structured-retired-model.test.ts b/src/main/claude/claude-structured-retired-model.test.ts index bc8e49065b65..4d6211d092dd 100644 --- a/src/main/claude/claude-structured-retired-model.test.ts +++ b/src/main/claude/claude-structured-retired-model.test.ts @@ -74,7 +74,7 @@ describe("the account catalog facts a child's listing hands the host", () => { routes: { list_models: () => [NATIVE_OPUS, LAUNCHED_ROW] } }) const adapter = adapterFor(claude) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', @@ -136,7 +136,7 @@ describe('a live child still launched with a retired model', () => { } }) const adapter = adapterFor(claude) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/claude/claude-structured-session-acquisition-processless.test.ts b/src/main/claude/claude-structured-session-acquisition-processless.test.ts index 4dc2824ddc7a..225a1beab7c0 100644 --- a/src/main/claude/claude-structured-session-acquisition-processless.test.ts +++ b/src/main/claude/claude-structured-session-acquisition-processless.test.ts @@ -62,7 +62,7 @@ describe('Claude structured processless acquisition', () => { }) const error = await adapter - .acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) + .spawn({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) .catch((cause: unknown) => cause) expect(error).toBeInstanceOf(AgentSessionPreSpawnError) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index d65c3bcfd90e..4952adfeb0b4 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -1,6 +1,6 @@ import type { AgentSessionAcquisition, - StructuredAgentSessionAcquireInput + StructuredAgentSessionSpawnInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control' @@ -38,7 +38,7 @@ import { createClaudeSessionJournalTranslator } from './claude-structured-session-journal-control' -export async function acquireClaudeSession({ +export async function spawnClaudeSession({ input, deps, sessions, @@ -46,7 +46,7 @@ export async function acquireClaudeSession({ exits, callbacks }: { - input: StructuredAgentSessionAcquireInput + input: StructuredAgentSessionSpawnInput deps: ClaudeStructuredSessionAdapterDeps sessions: Map acquisitions: ClaudeAcquisitionRegistry @@ -267,7 +267,7 @@ export async function acquireClaudeSession({ ) } // The host calls open after publication and holds input until it settles. - return { ...publication.acquisition, providerChildPhase: 'starting' } + return publication.acquisition } catch (error) { unbindReadingControl?.() const acquisitionError = await resolveClaudeAcquisitionError({ diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 0250722cb2a5..ba189d6606cd 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -26,13 +26,13 @@ import { import { invokeCanUseTool } from './claude-can-use-tool-test-support' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' -describe('ClaudeStructuredSessionAdapter.acquire', () => { +describe('ClaudeStructuredSessionAdapter.spawn', () => { it('pins the account and proves init without treating the system-frame uuid as a chain leaf', async () => { const claude = fakeClaude() const events: ClaudeStructuredSessionEvent[] = [] const adapter = adapterFor(claude, {}, events) - const acquisition = await adapter.acquire({ + const acquisition = await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' @@ -71,7 +71,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { const claude = fakeClaude() const adapter = adapterFor(claude, { resumesTranscript: true, continuesChain: true }) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', @@ -94,7 +94,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { }) const adapter = adapterFor(claude, { resumesTranscript: true, continuesChain: true }) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', @@ -114,7 +114,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { const events: ClaudeStructuredSessionEvent[] = [] const adapter = adapterFor(claude, {}, events) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', @@ -137,7 +137,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { }) const adapter = adapterFor(claude) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', @@ -152,15 +152,14 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { }) // The chat's permission mode is the start's, never a saved option the CLI is handed. - it('self-heals a persisted effort the CLI would refuse, and launches the start mode', async () => { + it('omits a retired effort from launch and uses the start permission mode', async () => { const claude = fakeClaude() const adapter = adapterFor(claude) const resolveLaunch = vi.spyOn(adapter['deps'], 'resolveLaunch') const options = { effort: 'retired-effort', permissionMode: 'full-access' } const start = { identity: identityFor(), fence: 7, spawnToken: 'spawn-9', options } - await adapter.acquire({ ...start, permissionMode: 'accept-edits' }) - expect(adapter.readOptionRestoreFailures('session-1')).toEqual(['effort']) + await adapter.spawn({ ...start, permissionMode: 'accept-edits' }) expect(claude.connections[0].launch.options).not.toHaveProperty('effort') expect(resolveLaunch.mock.calls[0]?.[0].permissionMode).toBe('accept-edits') }) @@ -311,7 +310,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { } }) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) expect(claude.connections[0].launch.env).toEqual({ ANTHROPIC_AUTH_TOKEN: 'configured-token', @@ -325,7 +324,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { const claude = fakeClaude() const adapter = adapterFor(claude, { claudeConfigDir: join(homedir(), '.claude'), env: {} }) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) // Pinning the CLI's own default suppresses the macOS Keychain and breaks claude.ai login. expect(claude.connections[0].launch.env).toEqual({ [SPAWN_TOKEN_ENV]: 'spawn-9' }) @@ -339,7 +338,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { env: { CLAUDE_CONFIG_DIR: '/other/account' } }) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) expect(claude.connections[0].launch.env).toEqual({ CLAUDE_CONFIG_DIR: accountHome, @@ -352,7 +351,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { const events: ClaudeStructuredSessionEvent[] = [] const adapter = adapterFor(claude, {}, events) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' @@ -402,7 +401,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { continuesChain: true, resumeLeafUuid: 'leaf-before' }) - await resumed.acquire({ + await resumed.spawn({ identity: identityFor(), fence: 9, spawnToken: 'spawn-9' @@ -414,7 +413,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { const wrongClaude = fakeClaude({ initSessionId: 'different-session' }) const wrong = adapterAtPublishFor(wrongClaude) - await wrong.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await wrong.spawn({ 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) @@ -423,7 +422,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { it('reports the CLI diagnostic when the child exits during open', async () => { const claude = fakeClaude({ exitBeforeInit: 'Claude login required' }) const adapter = adapterAtPublishFor(claude) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) await expect(claudeOpened(adapter, 'session-1')).rejects.toThrow('Claude login required') await adapter.drainObservedExits() }) @@ -434,7 +433,7 @@ describe('ClaudeStructuredSessionAdapter.acquire', () => { initAccount: { apiProvider: 'firstParty', tokenSource: 'none' } }) const adapter = adapterAtPublishFor(claude) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ 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) @@ -475,7 +474,7 @@ describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { continuesChain: true, resumeLeafUuid: 'tip' }) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) const first = claude.connections[0] first.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) first.exitVerdict = { root: 'exited', tree: 'unverifiable' } @@ -483,7 +482,7 @@ describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { options.exitBeforeInit = 'claude stream-json exited (code 1): not logged in' await expect( - adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) + adapter.spawn({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) ).rejects.toThrow('not logged in') // The second start's own proven close is the answer; the first exit is stale. await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) @@ -516,7 +515,7 @@ describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { await adapter.drainObservedExits() expect(() => adapter.readOptions({ sessionId: 'session-1', fence: 7 })).toThrow('not logged in') - await adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) + await adapter.spawn({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) expect(() => adapter.readOptions({ sessionId: 'session-1', fence: 8 })).toThrow( 'no live claude stream-json session' @@ -552,7 +551,7 @@ describe('ClaudeStructuredSessionAdapter acquisition cleanup', () => { first.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) // Before the exit publishes, the start settles it itself rather than refusing on it. - await adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) + await adapter.spawn({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) expect(claude.connections).toHaveLength(2) }) diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 491912d674f4..52ac2ff611e7 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -2,13 +2,13 @@ import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import type { AgentSessionAcquisition, - StructuredAgentSessionAcquireInput, + StructuredAgentSessionSpawnInput, StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { stopCurrentClaudeBackgroundTasks } from './claude-structured-control-actions' import { claudeHoldsDispatch } from './claude-command-lifecycle' import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' -import { acquireClaudeSession } from './claude-structured-session-acquisition' +import { spawnClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' import { setClaudeStructuredSessionOption } from './claude-structured-options' import { dispatchClaudeMessage } from './claude-structured-message-options' @@ -78,9 +78,9 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda supportsLocation = supportsClaudeStructuredLocation - acquire = (input: StructuredAgentSessionAcquireInput): Promise => { + spawn = (input: StructuredAgentSessionSpawnInput): Promise => { this.settledExitErrors.delete(input.identity.sessionId) - return acquireClaudeSession({ + return spawnClaudeSession({ input, deps: this.deps, sessions: this.sessions, @@ -240,10 +240,6 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda }) readOptions = (input: { sessionId: string; fence: number }) => readClaudeStructuredSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) - readOptionRestoreFailures = (sessionId: string): readonly string[] => [ - ...(this.sessions.get(sessionId)?.restoreSkippedOptions ?? []) - ] - releaseAcquisition = (input: { sessionId: string }): Promise => this.afterClose(input.sessionId, () => this.releaseProviderSession(input.sessionId)) diff --git a/src/main/claude/claude-structured-session-close.test.ts b/src/main/claude/claude-structured-session-close.test.ts index 5c36899ade6d..bd81cea7026d 100644 --- a/src/main/claude/claude-structured-session-close.test.ts +++ b/src/main/claude/claude-structured-session-close.test.ts @@ -20,7 +20,7 @@ describe('Claude published session close lifecycle', () => { it('never reads a failed spawn as an observed root exit, whatever checks it first', async () => { const claude = fakeClaude() const adapter = adapterFor(claude) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) const connection = claude.connections[0]! connection.exitVerdict = { root: 'exited', tree: 'exited', processless: true } expect(claudeRootExitObserved(connection)).toBe(false) @@ -31,7 +31,7 @@ describe('Claude published session close lifecycle', () => { it('reports a proven root exit when published-session close cannot prove descendants', async () => { const claude = fakeClaude() const adapter = adapterFor(claude) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) const connection = claude.connections[0]! connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } connection.close = vi.fn<() => Promise>().mockResolvedValue(false) @@ -44,7 +44,7 @@ describe('Claude published session close lifecycle', () => { it('lets the chat start again after a close that saw the root exit', async () => { const claude = fakeClaude() const adapter = adapterFor(claude) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) const first = claude.connections[0]! first.exitVerdict = { root: 'exited', tree: 'unverifiable' } first.close = vi.fn<() => Promise>().mockResolvedValue(false) @@ -53,7 +53,7 @@ describe('Claude published session close lifecycle', () => { AgentSessionAcquisitionRootExitObservedError ) - await adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) + await adapter.spawn({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) expect(claude.connections).toHaveLength(2) expect(first.close).toHaveBeenCalledTimes(1) @@ -63,12 +63,12 @@ describe('Claude published session close lifecycle', () => { const claude = fakeClaude() const events: ClaudeStructuredSessionEvent[] = [] const adapter = adapterFor(claude, {}, events) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) const first = claude.connections[0]! first.exitVerdict = { root: 'exited', tree: 'unverifiable' } first.close = vi.fn<() => Promise>().mockResolvedValue(false) - await adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) + await adapter.spawn({ identity: identityFor(), fence: 8, spawnToken: 'spawn-10' }) expect(claude.connections).toHaveLength(2) expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) @@ -114,7 +114,7 @@ describe('Claude published session close lifecycle', () => { appendTombstone: () => {}, publish: () => {} } - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/claude/claude-structured-session-commands.test.ts b/src/main/claude/claude-structured-session-commands.test.ts index 29f2bcf4aa70..781ee21e4c67 100644 --- a/src/main/claude/claude-structured-session-commands.test.ts +++ b/src/main/claude/claude-structured-session-commands.test.ts @@ -12,7 +12,7 @@ describe('session command updates', () => { const claude = fakeClaude() const changed = vi.fn() const adapter = adapterFor(claude) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', @@ -54,7 +54,7 @@ it.each([ const claude = fakeClaude({ initProof: 'session-start', initCommands: commands }) const adapter = adapterFor(claude) try { - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) const described = commands[0]?.description expect(adapter.readCommands('session-1')).toEqual( commands.map(({ name, description }) => ({ @@ -104,7 +104,7 @@ it('keeps a buffered stream catalog newer than the initialization response', asy } const adapter = adapterFor(claude) try { - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) expect(adapter.readCommands('session-1')?.map(({ name }) => name)).toEqual(['fresh']) } finally { await adapter.closeSession('session-1') diff --git a/src/main/claude/claude-structured-session-exit-blame.test.ts b/src/main/claude/claude-structured-session-exit-blame.test.ts index 79430131e15c..a62bad4e556b 100644 --- a/src/main/claude/claude-structured-session-exit-blame.test.ts +++ b/src/main/claude/claude-structured-session-exit-blame.test.ts @@ -15,7 +15,7 @@ async function startedClaude(sink: StructuredAgentSessionEventSink) { const claude = fakeClaude() const events: ClaudeStructuredSessionEvent[] = [] const adapter = adapterFor(claude, {}, events) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', events: sink }) const ended = async () => { await vi.waitFor(() => expect(events.some((event) => event.type === 'ended')).toBe(true)) return events.find((event) => event.type === 'ended') diff --git a/src/main/claude/claude-structured-session-reading-control.test.ts b/src/main/claude/claude-structured-session-reading-control.test.ts index e75338f26e2f..69e52b6eb3d5 100644 --- a/src/main/claude/claude-structured-session-reading-control.test.ts +++ b/src/main/claude/claude-structured-session-reading-control.test.ts @@ -79,7 +79,7 @@ describe('Claude structured reading control', () => { const adapter = adapterFor(claude) const events = controlledSink() - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', @@ -111,7 +111,7 @@ describe('Claude structured reading control', () => { const events = controlledSink() await expect( - adapter.acquire({ + adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', @@ -125,7 +125,7 @@ describe('Claude structured reading control', () => { const claude = fakeClaude() const adapter = adapterFor(claude) const events = controlledSink() - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', @@ -142,7 +142,7 @@ describe('Claude structured reading control', () => { const claude = fakeClaude() const adapter = adapterFor(claude) const events = controlledSink() - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', @@ -187,7 +187,7 @@ describe('Claude structured reading control', () => { .fn() .mockReturnValueOnce({ accepted: false, reason: 'backpressure' }) .mockReturnValueOnce({ accepted: false, reason: 'failed' }) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', @@ -225,7 +225,7 @@ describe('Claude structured reading control', () => { deferred.bind(target) const claude = fakeClaude() const adapter = adapterFor(claude) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index 92c3f52ec0f7..15970d011588 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -32,7 +32,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { appendTombstone: () => {}, publish: () => {} } - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', @@ -88,7 +88,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { appendTombstone: () => {}, publish: () => {} } - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', @@ -118,7 +118,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { .mockRejectedValueOnce(persistenceError) .mockResolvedValueOnce(undefined) const adapter = adapterFor(claude, {}, [], [], undefined, persistHandle) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) // The exit is proven, so the close ends the session; the write after it is bookkeeping. await expect(adapter.closeSession('session-1')).resolves.toBe(true) @@ -132,7 +132,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { const events: ClaudeStructuredSessionEvent[] = [] const persistedHandles: unknown[] = [] const adapter = adapterFor(claude, {}, events, persistedHandles) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) claude.connections[0].handlers.onMessage?.({ type: 'assistant', session_id: PROVIDER_SESSION_ID, @@ -177,7 +177,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { const persistedHandles: unknown[] = [] const events: ClaudeStructuredSessionEvent[] = [] const adapter = adapterFor(claude, {}, events, persistedHandles) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) const frame = (message: Record) => claude.connections[0].handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) frame({ type: 'assistant', uuid: 'crash-leaf' }) @@ -215,7 +215,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { undefined, vi.fn().mockRejectedValue(new Error('store unavailable')) ) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) claude.connections[0].handlers.onExit?.(new Error('crashed')) await tick() @@ -227,7 +227,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { const claude = fakeClaude() const events: ClaudeStructuredSessionEvent[] = [] const adapter = adapterFor(claude, {}, events) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) const close = vi.spyOn(claude.connections[0], 'close').mockResolvedValue(true) claude.connections[0].handlers.onExit?.(new Error('crashed')) @@ -242,7 +242,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { const events: ClaudeStructuredSessionEvent[] = [] const persistedHandles: unknown[] = [] const adapter = adapterFor(claude, {}, events, persistedHandles) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) claude.connections[0].close = vi .fn<() => Promise>() .mockResolvedValueOnce(false) @@ -259,7 +259,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { const claude = fakeClaude() const events: ClaudeStructuredSessionEvent[] = [] const adapter = adapterFor(claude, {}, events) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-1', 'tool-1') claude.connections[0].close = vi .fn<() => Promise>() @@ -276,7 +276,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { const claude = fakeClaude() const events: ClaudeStructuredSessionEvent[] = [] const adapter = adapterFor(claude, {}, events) - await adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9' }) claude.connections[0].close = vi .fn<() => Promise>() .mockResolvedValueOnce(false) @@ -345,7 +345,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { now: () => 1_700_000_000_500, persistHandle }) - const firstAcquisition = await adapter.acquire({ + const firstAcquisition = await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', @@ -388,7 +388,7 @@ describe('ClaudeStructuredSessionAdapter close and exit recovery', () => { expect(oldPrompt.settled()).toBe(false) expect(events.filter((event) => event.type === 'ended')).toEqual([]) - await adapter.acquire({ + await adapter.spawn({ identity: { ...identityFor(), providerHandle: claudeProviderHandle(PROVIDER_SESSION_ID, 'observed-retained-leaf') diff --git a/src/main/claude/claude-structured-session-startup.test.ts b/src/main/claude/claude-structured-session-startup.test.ts index ffbd756b7930..72aada6dc989 100644 --- a/src/main/claude/claude-structured-session-startup.test.ts +++ b/src/main/claude/claude-structured-session-startup.test.ts @@ -22,8 +22,8 @@ describe('Claude host-awaited open', () => { 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 } }) + const acquisition = await adapter.spawn(ACQUIRE) + expect(acquisition).toMatchObject({ process: { pid: 4321 } }) expect(acquisition.link).toBeUndefined() expect(claude.connections[0].calls).toEqual([]) expect(events.some((event) => event.type === 'options')).toBe(false) @@ -48,7 +48,7 @@ describe('Claude host-awaited open', () => { }) const claude = fakeClaude({ settings }) const { adapter, events } = adapterFor(claude) - await adapter.acquire(ACQUIRE) + await adapter.spawn(ACQUIRE) const opened = await claudeOpened(adapter, 'session-1') expect(opened.reportedOptions).toBeDefined() expect(claude.connections[0].calls.map((call) => call.subtype)).toContain('get_settings') @@ -78,7 +78,7 @@ describe('Claude host-awaited open', () => { { options: { extraArgs: { settings: '/repo/claude.json' } } }, events ) - await adapter.acquire({ ...ACQUIRE, options: { fastMode: 'true' } }) + await adapter.spawn({ ...ACQUIRE, options: { fastMode: 'true' } }) await claudeOpened(adapter, 'session-1') await vi.waitFor(() => expect(events.some((event) => event.type === 'options-reported')).toBe(true) @@ -106,7 +106,7 @@ describe('Claude host-awaited open', () => { 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) + await adapter.spawn(ACQUIRE) await expect(claudeOpened(adapter, 'session-1')).rejects.toThrow('not signed in') expect(claude.connections[0].closeCount).toBe(0) await adapter.closeSession('session-1') @@ -116,7 +116,7 @@ describe('Claude host-awaited open', () => { it('rejects a frame that proves a different provider session', async () => { const claude = fakeClaude({ initSessionId: 'foreign-session' }) const { adapter } = adapterFor(claude) - await adapter.acquire(ACQUIRE) + await adapter.spawn(ACQUIRE) await expect(claudeOpened(adapter, 'session-1')).rejects.toThrow('provider session expected') await adapter.closeSession('session-1') expect(claude.connections[0].closeCount).toBe(1) @@ -125,12 +125,12 @@ describe('Claude host-awaited open', () => { 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 acquisition = await adapter.spawn(ACQUIRE) const controller = new AbortController() const open = adapter.open({ sessionId: 'session-1', fence: 7, - acquisitionGeneration: acquisition.acquisitionGeneration ?? null, + acquisitionGeneration: acquisition.acquisitionGeneration, signal: controller.signal }) controller.abort(new Error('host start expired')) diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 228453adb69e..49e9789f88bf 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -175,7 +175,7 @@ export type ClaudeSession = { restoreSkippedOptions: Set /** The model the child was launched with, as `--model`; null when it runs the CLI's own. */ launchedModel: string | null - /** The launch left the saved Fast out, so the start applies it once the settings are read. */ + /** Fast was omitted from launch settings and needs confirmation at message input. */ fastModeAtStart: boolean /** CLI-advertised protocol capabilities from init; gates interrupt-receipt handling. */ capabilities: readonly string[] diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index f1df2d51fe4f..d29e61c56cbc 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -248,9 +248,9 @@ export function adapterFor( ...args: Parameters ): ClaudeStructuredSessionAdapter { const adapter = adapterAtPublishFor(...args) - const acquire = adapter.acquire - adapter.acquire = async (input) => { - const acquisition = await acquire(input) + const spawn = adapter.spawn + adapter.spawn = async (input) => { + const acquisition = await spawn(input) await claudeStartupSettled(adapter, input.identity.sessionId) return acquisition } @@ -312,7 +312,7 @@ export async function acquired( undefined, onDispatchSettledLate ) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/claude/claude-structured-start-fast-mode.test.ts b/src/main/claude/claude-structured-start-fast-mode.test.ts index 16f9693f0117..7438a8886806 100644 --- a/src/main/claude/claude-structured-start-fast-mode.test.ts +++ b/src/main/claude/claude-structured-start-fast-mode.test.ts @@ -26,7 +26,7 @@ it("applies a resumed conversation's saved Fast off before message input beside options: { extraArgs: { settings: '/repo/claude.json' } } }) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', 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 1e41f5121cd7..170a8cfeb90f 100644 --- a/src/main/claude/claude-structured-startup-settled-test-support.ts +++ b/src/main/claude/claude-structured-startup-settled-test-support.ts @@ -6,7 +6,7 @@ const opened = new WeakMap< Map> >() -/** Direct adapter fixtures explicitly join the host-owned open after acquire. */ +/** Direct adapter fixtures explicitly join the host-owned open after spawn. */ export function claudeOpened( adapter: ClaudeStructuredSessionAdapter, sessionId: string diff --git a/src/main/claude/claude-structured-turn-resume-point.test.ts b/src/main/claude/claude-structured-turn-resume-point.test.ts index 5fdbe7f02911..4def0251f856 100644 --- a/src/main/claude/claude-structured-turn-resume-point.test.ts +++ b/src/main/claude/claude-structured-turn-resume-point.test.ts @@ -87,7 +87,7 @@ async function liveOwner( }) }) }) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: FENCE, spawnToken: 'spawn-7', diff --git a/src/main/claude/claude-subagent-permission-request.test.ts b/src/main/claude/claude-subagent-permission-request.test.ts index c3db6a4011b6..fc8f882a45c9 100644 --- a/src/main/claude/claude-subagent-permission-request.test.ts +++ b/src/main/claude/claude-subagent-permission-request.test.ts @@ -217,7 +217,7 @@ async function pipeline() { onChildWorkEvidence: (sessionId, evidence) => feed.publishChildWork(sessionId, evidence), onEvent: (event) => hooks.onEvent?.(event) }) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(SESSION), fence: FENCE, spawnToken: 'spawn-9', diff --git a/src/main/claude/claude-subagent-resume-after-restart.test.ts b/src/main/claude/claude-subagent-resume-after-restart.test.ts index 1a02e5769846..6813ff6d7830 100644 --- a/src/main/claude/claude-subagent-resume-after-restart.test.ts +++ b/src/main/claude/claude-subagent-resume-after-restart.test.ts @@ -139,7 +139,7 @@ async function openJournal(): Promise { } /** One provider process: a fresh sink and translator over the session's journal. */ -function acquire(journal: AgentSessionJournal) { +function spawn(journal: AgentSessionJournal) { const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) const translator = createClaudeJournalTranslator({ sink: deferred.sink, coalesceMs: 0 }) deferred.bind({ journal, fence: 1, publish: () => {} }) @@ -232,7 +232,7 @@ describe('a Claude subagent resumed after its provider restarted', () => { it('keeps the canonical id its first run was given, so one child stays one child', async () => { const journal = await openJournal() - const first = acquire(journal) + const first = spawn(journal) first.translator.handle(userTurn('turn-a')) first.translator.handle( toolCall('spawn-a', 'toolu_spawn_a', 'Agent', { @@ -258,7 +258,7 @@ describe('a Claude subagent resumed after its provider restarted', () => { await first.exit() expect(proseRow(journal, 'A reads the rig recipes')).toMatchObject({ agentId: 'agent-a' }) - const second = acquire(journal) + const second = spawn(journal) second.translator.handle(userTurn('turn-b')) second.translator.handle( toolCall('resume-a', 'toolu_message_a', 'SendMessage', { to: 'agent-a', message: 'go on' }) @@ -310,7 +310,7 @@ describe('a Claude subagent resumed after its provider restarted', () => { // A backgrounded child spawns its own child after the parent's turn has ended, so the // grandchild is rostered outside any turn; the next provider run does the same again. const journal = await openJournal() - const first = acquire(journal) + const first = spawn(journal) first.translator.handle(userTurn('turn-a')) first.translator.handle( toolCall('spawn-c', 'toolu_spawn_c', 'Agent', { @@ -333,7 +333,7 @@ describe('a Claude subagent resumed after its provider restarted', () => { expect.objectContaining({ groupId: 'outside-turn' }) ]) - const second = acquire(journal) + const second = spawn(journal) second.translator.handle(userTurn('turn-b')) second.translator.handle( toolCall('spawn-d', 'toolu_spawn_d', 'Agent', { @@ -370,7 +370,7 @@ describe('a Claude subagent resumed after its provider restarted', () => { vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(1_000_000) const journal = await openJournal() - const first = acquire(journal) + const first = spawn(journal) first.translator.handle(userTurn('turn-a')) first.translator.handle( toolCall('spawn-a', 'toolu_spawn_a', 'Agent', { @@ -392,7 +392,7 @@ describe('a Claude subagent resumed after its provider restarted', () => { // The restart comes much later: that is not when the child stopped. vi.setSystemTime(5_000_000) - const second = acquire(journal) + const second = spawn(journal) second.translator.handle(userTurn('turn-b')) second.translator.handle( frame({ @@ -442,7 +442,7 @@ describe('a Claude subagent resumed after its provider restarted', () => { vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(1_000_000) const crashed = await openJournal() - const first = acquire(crashed) + const first = spawn(crashed) first.translator.handle(userTurn('turn-a')) first.translator.handle( toolCall('spawn-a', 'toolu_spawn_a', 'Agent', { @@ -461,7 +461,7 @@ describe('a Claude subagent resumed after its provider restarted', () => { expect(lost?.settledAt).toBeUndefined() vi.setSystemTime(5_000_000) - const second = acquire(journal) + const second = spawn(journal) second.translator.handle(userTurn('turn-b')) second.translator.handle( frame({ @@ -481,7 +481,7 @@ describe('a Claude subagent resumed after its provider restarted', () => { it('keeps one live entry for a child an older build listed in two rows', async () => { const journal = await journalAnOlderBuildListedTwice() - const run = acquire(journal) + const run = spawn(journal) run.translator.handle(userTurn('turn-c')) run.translator.handle(taskStarted('agent-a', 'toolu_message_a', 'Child agent-a')) // A frame for a sibling only the other row lists brings that row in too. @@ -499,7 +499,7 @@ describe('a Claude subagent resumed after its provider restarted', () => { it('resumes a twice-listed child in its later row even when a sibling reaches the older row first', async () => { const journal = await journalAnOlderBuildListedTwice() - const run = acquire(journal) + const run = spawn(journal) run.translator.handle(userTurn('turn-c')) run.translator.handle(taskCompleted('agent-b', 'toolu_spawn_b')) run.translator.handle(taskStarted('agent-a', 'toolu_message_a', 'Child agent-a')) @@ -514,12 +514,12 @@ describe('a Claude subagent resumed after its provider restarted', () => { it('still files a child no journaled row resolved under its own reference, never the parent', async () => { const journal = await openJournal() - const first = acquire(journal) + const first = spawn(journal) first.translator.handle(userTurn('turn-a')) first.translator.handle(result('result-a')) await first.exit() - const second = acquire(journal) + const second = spawn(journal) second.translator.handle(userTurn('turn-b')) second.translator.handle(taskStarted('agent-other', 'toolu_other', 'Other')) second.translator.handle(childSays('c-1', 'toolu_unknown', 'nobody named me')) diff --git a/src/main/claude/claude-turn-ownership.test.ts b/src/main/claude/claude-turn-ownership.test.ts index 56ad10c1ad34..dd7dfc63b369 100644 --- a/src/main/claude/claude-turn-ownership.test.ts +++ b/src/main/claude/claude-turn-ownership.test.ts @@ -50,7 +50,7 @@ async function acquiredWithJournal(claude: ReturnType): Promi }> { const { sink, bodies } = journalSink() const adapter = adapterFor(claude) - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'spawn-9', diff --git a/src/main/codex/codex-provider-retry-idle-sweep.test.ts b/src/main/codex/codex-provider-retry-idle-sweep.test.ts index 22c5073f2172..9a8014f7fb8d 100644 --- a/src/main/codex/codex-provider-retry-idle-sweep.test.ts +++ b/src/main/codex/codex-provider-retry-idle-sweep.test.ts @@ -56,7 +56,12 @@ beforeEach(async () => { closeSession = vi.fn(async () => true) const store = await openTestAgentSessionRecordStore(root) const adapter: StructuredAgentSessionAdapter = { - acquire: async ({ fence, spawnToken, events }) => { + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: async ({ fence, spawnToken, events }) => { sink = events ?? null return { process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, diff --git a/src/main/codex/codex-structured-acquisition-exit-proof.test.ts b/src/main/codex/codex-structured-acquisition-exit-proof.test.ts index 059c0f4851fa..68bd65a14241 100644 --- a/src/main/codex/codex-structured-acquisition-exit-proof.test.ts +++ b/src/main/codex/codex-structured-acquisition-exit-proof.test.ts @@ -47,7 +47,7 @@ describe('Codex failed-acquisition exit proof', () => { }) await expect( - adapter.acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) + adapter.spawn({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) ).rejects.toThrow('agent_session_acquisition_exit_unproven') await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) expect(close).toHaveBeenCalledTimes(2) @@ -86,7 +86,7 @@ describe('Codex failed-acquisition exit proof', () => { }) await expect( - adapter.acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) + adapter.spawn({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) ).rejects.toThrow('agent_session_acquisition_exit_unproven') await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) expect(close).toHaveBeenCalledTimes(2) @@ -119,7 +119,7 @@ describe('Codex failed-acquisition exit proof', () => { return processStart.promise } }) - const acquiring = adapter.acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) + const acquiring = adapter.spawn({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) await readStarted.promise await expect(adapter.closeAll()).rejects.toThrow( @@ -158,7 +158,7 @@ describe('Codex failed-acquisition exit proof', () => { }, readProcessStartTime: async () => 1_700_000_000_000 }) - const acquiring = adapter.acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) + const acquiring = adapter.spawn({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) await openStarted.promise const closing = adapter.closeAll() diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 22c30ba8f9fb..60c3231ea14d 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -1,7 +1,7 @@ // How a durable session record becomes a Codex process launch. // // Every input is read back from the record the store already made durable, not -// from the call that triggered the acquire. A client that attaches twice must +// from the call that triggered spawn. A client that attaches twice must // land in the same working directory under the same account home, and a resume // must name the thread this session actually proved — never one a caller asks // for, which is how a resume becomes a fork wearing a resume's name. diff --git a/src/main/codex/codex-structured-publish-at-spawn.test.ts b/src/main/codex/codex-structured-publish-at-spawn.test.ts index 1c64cca70a26..12d314242170 100644 --- a/src/main/codex/codex-structured-publish-at-spawn.test.ts +++ b/src/main/codex/codex-structured-publish-at-spawn.test.ts @@ -21,13 +21,13 @@ function startup(routes: Parameters[0] = {}) { async function open( adapter: ReturnType, - acquisitionGeneration: string | undefined, + acquisitionGeneration: string, signal = new AbortController().signal ) { return adapter.open({ sessionId: 'session-1', fence: 7, - acquisitionGeneration: acquisitionGeneration ?? null, + acquisitionGeneration, signal }) } @@ -42,7 +42,7 @@ describe('Codex host-awaited open', () => { }) const committed = Promise.withResolvers() const onSpawned = vi.fn(async () => committed.promise) - const acquiring = adapter.acquire({ ...INPUT, onSpawned, options: { model: 'gpt-saved' } }) + const acquiring = adapter.spawn({ ...INPUT, onSpawned, options: { model: 'gpt-saved' } }) await vi.waitFor(() => expect(onSpawned).toHaveBeenCalledOnce()) expect(codex.connections[0].calls).toEqual([]) committed.resolve() @@ -63,7 +63,7 @@ describe('Codex host-awaited open', () => { restoreSkippedOptions: [], optionRevision: 0 }) - expect(events.some((event) => event.type === 'started')).toBe(false) + expect(events).toEqual([]) await adapter.closeAll() }) @@ -72,7 +72,7 @@ describe('Codex host-awaited open', () => { async (method) => { const gate = Promise.withResolvers() const { adapter, codex, events } = startup({ [method]: () => gate.promise }) - const acquired = await adapter.acquire(INPUT) + const acquired = await adapter.spawn(INPUT) const opening = open(adapter, acquired.acquisitionGeneration) await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe(method)) gate.reject(new CodexAppServerRequestError(method, -32603, 'not signed in', 'not signed in')) @@ -90,7 +90,7 @@ describe('Codex host-awaited open', () => { async (method) => { const gate = Promise.withResolvers() const { adapter, codex } = startup({ [method]: () => gate.promise }) - const acquired = await adapter.acquire(INPUT) + const acquired = await adapter.spawn(INPUT) const controller = new AbortController() const opening = open(adapter, acquired.acquisitionGeneration, controller.signal) await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe(method)) @@ -108,7 +108,7 @@ describe('Codex host-awaited open', () => { const gate = Promise.withResolvers() const codex = fakeCodex({ 'thread/resume': () => gate.promise }) const adapter = adapterFor(codex, { resumeThreadId: THREAD_ID }) - const acquired = await adapter.acquire(INPUT) + const acquired = await adapter.spawn(INPUT) const opening = open(adapter, acquired.acquisitionGeneration) await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('thread/resume')) gate.resolve({ thread: { id: THREAD_ID } }) @@ -123,7 +123,7 @@ describe('Codex host-awaited open', () => { resumeThreadId: THREAD_ID, supersedeIfUnsaved: true }) - const acquired = await adapter.acquire(INPUT) + const acquired = await adapter.spawn(INPUT) const controller = new AbortController() const opening = open(adapter, acquired.acquisitionGeneration, controller.signal) await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('thread/resume')) @@ -164,7 +164,7 @@ describe('Codex host-awaited open', () => { appendTombstone: vi.fn(), publish: vi.fn() } - const acquired = await adapter.acquire({ ...INPUT, events: sink }) + const acquired = await adapter.spawn({ ...INPUT, events: sink }) await expect(open(adapter, acquired.acquisitionGeneration)).rejects.toMatchObject({ name: AgentSessionAcquisitionRefusal.name, reason: 'historyTooLarge' @@ -178,7 +178,7 @@ describe('Codex host-awaited open', () => { it('retains natural exit evidence during thread open', async () => { const gate = Promise.withResolvers() const { adapter, codex, events } = startup({ 'thread/start': () => gate.promise }) - const acquired = await adapter.acquire(INPUT) + const acquired = await adapter.spawn(INPUT) const opening = open(adapter, acquired.acquisitionGeneration) await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('thread/start')) codex.connections[0].closed = true diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index 4a85cbffa7f1..c20f6fc369ae 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -70,9 +70,15 @@ beforeEach(async () => { sink = null clock = HOST_TEST_NOW const adapter: StructuredAgentSessionAdapter = { - acquire: vi.fn(async ({ fence, events }) => { + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi.fn(async ({ fence, events }) => { sink = events ?? null return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, diff --git a/src/main/codex/codex-structured-service-tier.test.ts b/src/main/codex/codex-structured-service-tier.test.ts index df7d8c4aae19..537e0aedc849 100644 --- a/src/main/codex/codex-structured-service-tier.test.ts +++ b/src/main/codex/codex-structured-service-tier.test.ts @@ -32,7 +32,7 @@ function listing(tier = 'priority') { } } -async function acquire(adapter: CodexStructuredSessionAdapter, sessionId = 'session-1') { +async function spawn(adapter: CodexStructuredSessionAdapter, sessionId = 'session-1') { return acquireReadyCodexForTest(adapter, { identity: identityFor(sessionId), fence: 7, @@ -62,7 +62,7 @@ describe('Codex structured service tier without send-path catalog waits', () => codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-tier') const adapter = adapterFor(codex) - await acquire(adapter) + await spawn(adapter) expect(codexStartReport(adapter).reportedOptions).toMatchObject({ serviceTier: 'priority' }) @@ -79,7 +79,7 @@ describe('Codex structured service tier without send-path catalog waits', () => const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog: new AgentModelCatalogStore() }) - await acquire(adapter) + await spawn(adapter) await vi.waitFor(() => expect(codex.connections[0].calls.some((call) => call.method === 'model/list')).toBe(true) ) @@ -96,7 +96,7 @@ describe('Codex structured service tier without send-path catalog waits', () => codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'running-turn') codex.routes['turn/steer'] = () => ({ turn: { id: 'running-turn' } }) const adapter = adapterFor(codex) - await acquire(adapter) + await spawn(adapter) await send(adapter, 'first') await send(adapter, 'follow-up') expect(codex.connections[0].calls.some((call) => call.method === 'turn/steer')).toBe(true) @@ -111,7 +111,7 @@ describe('Codex structured service tier without send-path catalog waits', () => const codex = fakeCodex({ 'model/list': () => pending.promise }) codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'running-turn') const adapter = adapterFor(codex) - await acquire(adapter) + await spawn(adapter) await send(adapter, 'first') await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({ @@ -145,16 +145,16 @@ describe('Codex structured service tier without send-path catalog waits', () => '/codex/home' )! try { - await acquire(adapter) + await spawn(adapter) await vi.waitFor(() => expect(modelCatalog.hasActiveFailure(access.fingerprint)).toBe(true)) await send(adapter, 'first') expect( codex.connections[0].calls.find((call) => call.method === 'turn/start')?.params ).toMatchObject({ serviceTier: 'priority' }) - await acquire(adapter, 'session-2') + await spawn(adapter, 'session-2') expect(listModels).toHaveBeenCalledOnce() now += AGENT_MODEL_CATALOG_FAILURE_TTL_MS - await acquire(adapter, 'session-3') + await spawn(adapter, 'session-3') await vi.waitFor(() => expect(listModels).toHaveBeenCalledTimes(2)) } finally { clock.mockRestore() @@ -177,8 +177,8 @@ describe('Codex structured service tier without send-path catalog waits', () => CODEX_STRUCTURED_AGENT, '/codex/home' )! - await acquire(adapter) - await acquire(adapter) + await spawn(adapter) + await spawn(adapter) oldListing.resolve(listing('priority-old')) newListing.resolve(listing('priority-new')) await vi.waitFor(() => diff --git a/src/main/codex/codex-structured-session-adapter-fixture.ts b/src/main/codex/codex-structured-session-adapter-fixture.ts index c9994a1c45f3..6d06d49b7c09 100644 --- a/src/main/codex/codex-structured-session-adapter-fixture.ts +++ b/src/main/codex/codex-structured-session-adapter-fixture.ts @@ -1,5 +1,5 @@ import type { - StructuredAgentSessionAcquireInput, + StructuredAgentSessionSpawnInput, StructuredAgentSessionOptionsReportedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionProviderOpenResult } from '../native-chat/agent-session-wire/structured-agent-session-provider-open-contract' @@ -44,13 +44,13 @@ export function codexStartedLink(adapter: CodexStructuredSessionAdapter, session export async function acquireReadyCodexForTest( adapter: CodexStructuredSessionAdapter, - input: StructuredAgentSessionAcquireInput + input: StructuredAgentSessionSpawnInput ) { - const acquired = await adapter.acquire(input) + const acquired = await adapter.spawn(input) const result = await adapter.open({ sessionId: input.identity.sessionId, fence: input.fence, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) startReports.get(adapter)?.set(input.identity.sessionId, result) diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index fcac5dba95db..67729a0fb724 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -27,7 +27,7 @@ import { createCodexStructuredLaunchResolver } from '../runtime/structured-agent import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture' import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -describe('CodexStructuredSessionAdapter.acquire', () => { +describe('CodexStructuredSessionAdapter.spawn', () => { it('keeps a started thread usable when its model listing never answers', async () => { const codex = fakeCodex({ 'model/list': () => new Promise(() => {}) }) const adapter = adapterFor(codex) @@ -274,7 +274,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { it('rejects a resume that lands on a different thread; the host closes once', async () => { const codex = fakeCodex({ 'thread/resume': () => ({ thread: { id: 'thread-other' } }) }) const adapter = adapterFor(codex, { resumeThreadId: 'thread-proven' }) - const acquired = await adapter.acquire({ + const acquired = await adapter.spawn({ identity: identityFor('session-1'), fence: 9, spawnToken: 'spawn-9' @@ -283,7 +283,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { adapter.open({ sessionId: 'session-1', fence: 9, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) ).rejects.toThrow('resumed thread-other instead of thread-proven') @@ -295,7 +295,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { it('rejects a thread Codex never named; the host closes once', async () => { const codex = fakeCodex({ 'thread/start': () => ({}) }) const adapter = adapterFor(codex) - const acquired = await adapter.acquire({ + const acquired = await adapter.spawn({ identity: identityFor('session-1'), fence: 1, spawnToken: 'spawn-9' @@ -304,7 +304,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { adapter.open({ sessionId: 'session-1', fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) ).rejects.toThrow('did not name the thread') @@ -414,7 +414,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { }) await expect( - adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + adapter.spawn({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) ).rejects.toThrow('exited while being acquired') expect(codex.connections[0].closeCount).toBe(1) await expect( @@ -437,7 +437,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { }) const error = await adapter - .acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + .spawn({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) .catch((cause: unknown) => cause) expect(error).toMatchObject({ @@ -473,7 +473,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { openConnection, readProcessStartTime: async () => 1_700_000_000_000 }) - const acquiring = adapter.acquire({ + const acquiring = adapter.spawn({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' @@ -496,7 +496,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { openConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000 }) - const acquiring = adapter.acquire({ + const acquiring = adapter.spawn({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 04cff9f45606..34bd85f5130f 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -5,7 +5,7 @@ import { isCodexAppServerRequestError } from './codex-app-server-connection' import type { AgentSessionAcquisition, AgentSessionDispatchOutcome, - StructuredAgentSessionAcquireInput, + StructuredAgentSessionSpawnInput, StructuredAgentSessionAdapter, StructuredAgentSessionSetOptionInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -18,8 +18,7 @@ import { CodexStructuredSessionTeardown } from './codex-structured-session-teard import { applyCodexStructuredSessionOption, prepareLiveCodexSessionOptions, - readLiveCodexSessionOptions, - readCodexAcquisitionOptions + readLiveCodexSessionOptions } from './codex-structured-session-options' import { resolvedCodexMessageOptions } from './codex-structured-message-options' import { @@ -41,7 +40,7 @@ import { settleCodexSendsInEndedTurn } from './codex-structured-turn-end-settlement' import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' -import { acquireCodexStructuredSession } from './codex-structured-session-acquire' +import { spawnCodexStructuredSession } from './codex-structured-session-spawn' import { codexStoppedRequestEndWait, settleCodexRequestEndWaiters } from './codex-request-end-wait' import { changeCodexThreadGoal } from './codex-structured-thread-goal' import { startCodexTerminalStopProbe } from './codex-background-terminals' @@ -88,8 +87,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap supportsLocation = supportsCodexStructuredLocation - acquire = (input: StructuredAgentSessionAcquireInput): Promise => - acquireCodexStructuredSession({ + spawn = (input: StructuredAgentSessionSpawnInput): Promise => + spawnCodexStructuredSession({ input, deps: this.deps, sessions: this.sessions, @@ -319,12 +318,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap prepareReadOptions = (input: { sessionId: string; fence: number }) => prepareLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) - readAcquisitionOptions = (input: { - sessionId: string - fence: number - priorOptions?: Readonly> - }) => readCodexAcquisitionOptions(this.session(input.sessionId), input.priorOptions) - closeSession: NonNullable = (sessionId, cause) => this.teardown.close(sessionId, { cause }) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index 131865933656..406f6999f8f1 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -70,7 +70,12 @@ function adapterFixture() { function claudeAdapterStub(): StructuredAgentSessionAdapter { return { - acquire: vi.fn(async () => ({ process: { pid: 1 } }) as never), + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi.fn(), dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), @@ -264,7 +269,7 @@ describe('Codex structured session close lifecycle', () => { { claude: claudeAdapterStub(), codex: adapter }, async () => {} ) - await router.acquire({ identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' }) + await router.spawn({ identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' }) const current = connections[0] if (!current) { throw new Error('missing connection') diff --git a/src/main/codex/codex-structured-session-options-catalog.test.ts b/src/main/codex/codex-structured-session-options-catalog.test.ts index 9583761a68a3..cce6c2220cb4 100644 --- a/src/main/codex/codex-structured-session-options-catalog.test.ts +++ b/src/main/codex/codex-structured-session-options-catalog.test.ts @@ -132,7 +132,7 @@ describe('Codex session options through the host catalog store', () => { const store = new AgentModelCatalogStore() const request = vi.fn(async () => listAnswer('gpt-live', 'gpt-next')) const session = storeSession(request, store) - // The acquire-time restore read. + // The spawn-time restore read. const first = await readLiveCodexSessionOptions(session, undefined) expect(first.models.map((model) => model.id)).toEqual(['gpt-live', 'gpt-next']) expect(modelListCalls(request)).toBe(1) @@ -157,7 +157,7 @@ describe('Codex session options through the host catalog store', () => { ) const request = vi.fn(async () => listAnswer('gpt-live')) const session = storeSession(request, store) - // The acquire-time restore read: joining the probe would fail the chat at the probe's deadline. + // The spawn-time restore read: joining the probe would fail the chat at the probe's deadline. const result = await readLiveCodexSessionOptions(session, undefined) expect(result.models.map((model) => model.id)).toEqual(['gpt-live']) expect(modelListCalls(request)).toBe(1) diff --git a/src/main/codex/codex-structured-session-shutdown.test.ts b/src/main/codex/codex-structured-session-shutdown.test.ts index 189a268d9546..2c724fb926f9 100644 --- a/src/main/codex/codex-structured-session-shutdown.test.ts +++ b/src/main/codex/codex-structured-session-shutdown.test.ts @@ -58,11 +58,11 @@ describe('CodexStructuredSessionAdapter shutdown', () => { openConnection, readProcessStartTime: async () => 1_700_000_000_000 }) - const first = adapter.acquire({ identity: identity(), fence: 7, spawnToken: 'spawn-1' }) + const first = adapter.spawn({ identity: identity(), fence: 7, spawnToken: 'spawn-1' }) await vi.waitFor(() => expect(launchCount).toBe(1)) const closing = adapter.closeAll() - const second = adapter.acquire({ identity: identity(), fence: 8, spawnToken: 'spawn-2' }) + const second = adapter.spawn({ identity: identity(), fence: 8, spawnToken: 'spawn-2' }) const acquisitions = Promise.allSettled([first, second]) firstLaunch.resolve(LAUNCH) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-spawn.ts similarity index 89% rename from src/main/codex/codex-structured-session-acquire.ts rename to src/main/codex/codex-structured-session-spawn.ts index 34da4da09e6b..cd2d9b1679cd 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-spawn.ts @@ -6,7 +6,7 @@ import { import { AgentSessionPreSpawnError, type AgentSessionAcquisition, - type StructuredAgentSessionAcquireInput + type StructuredAgentSessionSpawnInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { closeFailedCodexAcquisition, @@ -36,8 +36,8 @@ import type { CodexStructuredNotificationRetry } from './codex-structured-notifi import type { deliverCodexServerRequest } from './codex-structured-provider-events' import { codexAcquisitionNotificationHandler } from './codex-structured-acquisition-notification' -export async function acquireCodexStructuredSession(input: { - input: StructuredAgentSessionAcquireInput +export async function spawnCodexStructuredSession(input: { + input: StructuredAgentSessionSpawnInput deps: CodexStructuredSessionAdapterDeps sessions: Map acquisitions: CodexAcquisitionRegistry @@ -55,12 +55,12 @@ export async function acquireCodexStructuredSession(input: { handleUnhandledFrame: (sessionId: string, kind: string, payload: unknown) => void forceCloseUnexpected: CodexStructuredSessionTeardown['forceCloseUnexpected'] }): Promise { - const { input: acquireInput, deps, sessions, acquisitions, notificationRetries } = input - const sessionId = acquireInput.identity.sessionId + const { input: spawnInput, deps, sessions, acquisitions, notificationRetries } = input + const sessionId = spawnInput.identity.sessionId const { previousAttempt, attempt } = acquisitions.start(sessionId) const acquisition = attempt.window let unbindReadingControl: (() => void) | undefined - const provenHandle = acquireInput.identity.providerHandle + const provenHandle = spawnInput.identity.providerHandle let primaryThreadId = provenHandle && isAgentSessionProviderHandleInNamespace(provenHandle, CODEX_STRUCTURED_HANDLE_NAMESPACE) @@ -72,7 +72,7 @@ export async function acquireCodexStructuredSession(input: { // Minted before the translator, which names this connection's frame rows with it. const acquisitionGeneration = mintCodexAcquisitionGeneration(deps) const translator = createCodexSessionJournalTranslator({ - sink: acquireInput.events, + sink: spawnInput.events, account: () => account, sessionId, acquisitionId: acquisitionGeneration, @@ -83,7 +83,7 @@ export async function acquireCodexStructuredSession(input: { prompts: acquisition.prompts }) const open = deps.openConnection ?? spawnCodexAppServerConnection - const spawnIdentity = codexSpawnedProcessIdentity(acquireInput, deps.readProcessStartTime) + const spawnIdentity = codexSpawnedProcessIdentity(spawnInput, deps.readProcessStartTime) try { await stopSupersededCodexAcquisition({ sessionId, @@ -105,20 +105,20 @@ export async function acquireCodexStructuredSession(input: { acquisitions.assertCurrent(sessionId, attempt) const resolvedLaunch = await deps .resolveLaunch({ - identity: acquireInput.identity, - spawnToken: acquireInput.spawnToken, - ...(acquireInput.permissionMode ? { permissionMode: acquireInput.permissionMode } : {}) + identity: spawnInput.identity, + spawnToken: spawnInput.spawnToken, + ...(spawnInput.permissionMode ? { permissionMode: spawnInput.permissionMode } : {}) }) .catch((error: unknown) => { throw new AgentSessionPreSpawnError(error) }) const { model: _savedNextModel, ...launchSettings } = resolvedLaunch const launch = - acquireInput.options === undefined + spawnInput.options === undefined ? resolvedLaunch : { ...launchSettings, - ...(acquireInput.options.model ? { model: acquireInput.options.model } : {}) + ...(spawnInput.options.model ? { model: spawnInput.options.model } : {}) } acquisitions.assertCurrent(sessionId, attempt) account = launch.codexHome ? deps.resolveAccountKind?.(launch.codexHome) : undefined @@ -154,7 +154,7 @@ export async function acquireCodexStructuredSession(input: { Buffer.byteLength(JSON.stringify(payload ?? null), 'utf8') ), onSpawned: spawnIdentity.onSpawned, - ...(acquireInput.onOutput ? { onOutput: acquireInput.onOutput } : {}), + ...(spawnInput.onOutput ? { onOutput: spawnInput.onOutput } : {}), onExit: (error, exit) => { try { if (attempt.acquisitionGeneration && !sessions.has(sessionId)) { @@ -165,7 +165,7 @@ export async function acquireCodexStructuredSession(input: { deps.onEvent?.({ type: 'ended', sessionId, - fence: acquireInput.fence, + fence: spawnInput.fence, acquisitionGeneration, reason: error.message, cause: @@ -204,7 +204,7 @@ export async function acquireCodexStructuredSession(input: { ) acquisition.connection = connection if (connection.pauseReading && connection.resumeReading) { - unbindReadingControl = acquireInput.events?.bindReadingControl?.({ + unbindReadingControl = spawnInput.events?.bindReadingControl?.({ pauseReading: connection.pauseReading, resumeReading: () => { connection.resumeReading?.() @@ -231,7 +231,7 @@ export async function acquireCodexStructuredSession(input: { attempt.open = (openInput) => openCodexStructuredSession( { - input: acquireInput, + input: spawnInput, deps, launch, account, @@ -250,7 +250,7 @@ export async function acquireCodexStructuredSession(input: { }, openInput ) - return { process, acquisitionGeneration, providerChildPhase: 'starting' } + return { process, acquisitionGeneration } } catch (error) { if (sessions.get(sessionId)?.connection !== acquisition.connection) { return closeFailedCodexAcquisition({ diff --git a/src/main/codex/codex-structured-session-start.ts b/src/main/codex/codex-structured-session-start.ts index b32e4a3f9e81..745d5b624305 100644 --- a/src/main/codex/codex-structured-session-start.ts +++ b/src/main/codex/codex-structured-session-start.ts @@ -1,6 +1,6 @@ import type { AgentSessionAccountKind } from '../../shared/agent-session-availability' import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' -import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionSpawnInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { AgentSessionAcquisitionRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionProviderOpenInput, @@ -35,7 +35,7 @@ import type { CodexStructuredSessionTeardown } from './codex-structured-session- import { STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' type CodexOpenContext = { - input: StructuredAgentSessionAcquireInput + input: StructuredAgentSessionSpawnInput deps: CodexStructuredSessionAdapterDeps launch: CodexStructuredLaunch account: AgentSessionAccountKind | undefined diff --git a/src/main/codex/codex-trust-config-mutation-queue.test.ts b/src/main/codex/codex-trust-config-mutation-queue.test.ts index f07c047c2033..0a922fce547d 100644 --- a/src/main/codex/codex-trust-config-mutation-queue.test.ts +++ b/src/main/codex/codex-trust-config-mutation-queue.test.ts @@ -14,7 +14,7 @@ function deferred(): { promise: Promise; resolve: () => void; reject: (e: describe('runExclusivelyForCodexTrustConfig', () => { // Why: the grant lane runs inside the installer that already owns the file; // a non-reentrant lane would queue it behind itself and never settle. - it('passes through a nested acquire of a lane the caller already holds', async () => { + it('passes through a nested spawn of a lane the caller already holds', async () => { const nested = await runExclusivelyForCodexTrustConfig('/a/config.toml', () => runExclusivelyForCodexTrustConfig('/a/config.toml', () => Promise.resolve('inner')) ) 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 659db49bf33b..bf81025eacee 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 @@ -63,15 +63,15 @@ function eventually(assertion: () => void | Promise): Promise { let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost -let acquire: Mock +let spawn: Mock let dispatch: Mock let adapterExtras: Partial let idleMs: number let clock: number -const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ +const spawnChild: StructuredAgentSessionAdapter['spawn'] = async ({ fence, spawnToken }) => ({ process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + acquisitionGeneration: `generation-${spawn.mock.calls.length}`, link: { linkId: `link-${fence}`, handle: codexProviderHandle(THREAD), @@ -94,18 +94,25 @@ async function startHost(): Promise { logger: createStructuredAgentSessionLogger(), store, adapter: starts.wrap({ - acquire, + spawn, 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, + open: + adapterExtras.open ?? + (async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + })) }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + mintSpawnToken: () => `spawn-${spawn.mock.calls.length}`, idleSweep: { intervalMs: 5, idleMs }, now: () => clock }) @@ -118,7 +125,7 @@ beforeEach(async () => { starts.clear() idleMs = 60 * 60_000 clock = NOW - acquire = vi.fn(spawnChild) + spawn = vi.fn(spawnChild) dispatch = vi.fn(async () => ({ state: 'accepted' as const, providerIdentity: { @@ -235,16 +242,6 @@ function framedStates(events: AgentSessionSubscribeEvent[], id: string): string[ ) } -function deferred() { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((next, fail) => { - resolve = next - reject = fail - }) - return { promise, resolve, reject } -} - /** Rows written by an earlier host process that ended before handing them over. */ async function writeAsEarlierProcess( write: (journal: AgentSessionJournal, fence: number) => Promise @@ -280,13 +277,13 @@ function earlierSubmission(id: string, text: string, handoverRecorded?: true) { describe('a send is answered at acceptance', () => { it('answers before the child starts, then an open chat sees the handover and the reply (W2)', async () => { await host.close(SESSION, 'evict') - const starting = deferred() - acquire.mockImplementationOnce(async (input) => { + const starting = Promise.withResolvers() + spawn.mockImplementationOnce(async (input) => { await starting.promise return spawnChild(input) }) - const answering = deferred() + const answering = Promise.withResolvers() const answer = dispatch.getMockImplementation()! dispatch.mockImplementationOnce(async (input) => { await answering.promise @@ -295,7 +292,7 @@ describe('a send is answered at acceptance', () => { const id = await accept('hello') const events = await subscribe() - await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) + await eventually(async () => expect(spawn).toHaveBeenCalledTimes(2)) expect(dispatch).not.toHaveBeenCalled() starting.resolve() @@ -308,13 +305,13 @@ describe('a send is answered at acceptance', () => { it('accepts a second send while the first one starts the child, before handing either over (W6)', async () => { await host.close(SESSION, 'evict') - const starting = deferred() - acquire.mockImplementationOnce(async (input) => { + const starting = Promise.withResolvers() + spawn.mockImplementationOnce(async (input) => { await starting.promise return spawnChild(input) }) const first = await accept('first') - await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) + await eventually(async () => expect(spawn).toHaveBeenCalledTimes(2)) const second = host.send(CALLER, sendParams('second')) starting.resolve() @@ -339,7 +336,7 @@ describe('a send is answered at acceptance', () => { describe('a start the chat needed and did not get', () => { it('shows a saved Arguments refusal on the resumed chat row and rejected message', async () => { await host.close(SESSION, 'evict') - acquire.mockRejectedValueOnce( + spawn.mockRejectedValueOnce( new AgentSessionPreSpawnError( new StructuredAgentArgumentsError('Codex', '--unsafe=private', 'unsupportedOption') ) @@ -361,7 +358,7 @@ describe('a start the chat needed and did not get', () => { it('writes one error row and rejects every queued message with it; the next send starts (W3)', async () => { await host.close(SESSION, 'evict') - acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) + spawn.mockRejectedValueOnce(new Error('spawn codex ENOENT')) const first = await accept('first') const second = await accept('second') @@ -391,7 +388,7 @@ describe('a start the chat needed and did not get', () => { it('draws the messages it failed above the error row, since they were accepted first', async () => { await host.close(SESSION, 'evict') - acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) + spawn.mockRejectedValueOnce(new Error('spawn codex ENOENT')) const first = await accept('first') const second = await accept('second') await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) @@ -410,7 +407,7 @@ describe('a start the chat needed and did not get', () => { it('notifies failed once for the queued messages one start failure refused', async () => { await host.close(SESSION, 'evict') - acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')) + spawn.mockRejectedValueOnce(new Error('spawn codex ENOENT')) const completions: AgentSessionTurnCompletionEvent[] = [] host.subscribeTurnCompletions({ id: 'dot-1', emit: (event) => completions.push(event) }) await accept('first') @@ -449,7 +446,7 @@ describe('a start the chat needed and did not get', () => { ], [ 'spawn', - () => acquire.mockRejectedValueOnce(new Error('spawn codex ENOENT')), + () => spawn.mockRejectedValueOnce(new Error('spawn codex ENOENT')), { text: "Codex couldn't restart. Send your message to try again.", failure: { @@ -461,7 +458,7 @@ describe('a start the chat needed and did not get', () => { [ 'auth', () => - acquire.mockRejectedValueOnce( + spawn.mockRejectedValueOnce( new AgentSessionPreSpawnError(new Error('Not logged in. Please run /login.')) ), { @@ -551,12 +548,12 @@ describe('an attach that fails after indexing its child', () => { // Nothing was indexed, so nothing had to be taken back: no list ever showed a child. expect(host['sessions'].get(SESSION)?.child).toBeNull() expect(owned).not.toContain(true) - const acquiresBefore = acquire.mock.calls.length + const acquiresBefore = spawn.mock.calls.length const next = await accept('after the failure') await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) - expect(acquire).toHaveBeenCalledTimes(acquiresBefore + 1) + expect(spawn).toHaveBeenCalledTimes(acquiresBefore + 1) expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([next]) }) }) @@ -604,15 +601,14 @@ describe('what an earlier host process left behind', () => { }) }) -describe('a child that exits before its message is handed over', () => { - it('rejects the message with the exit reason instead of starting another child (W24)', async () => { +describe('a child that exits before open completes', () => { + it('rejects the message with the start failure instead of starting another child (W24)', async () => { await host.close(SESSION, 'evict') await startHost() - // Each child the loop starts proves its start, then dies as its start step returns: its exit, - // asked for on the lane then, lands before the handover step. - acquire.mockImplementation(async (input) => { + // The exit lands before the host's awaited open can establish readiness. + spawn.mockImplementation(async (input) => { const child = await spawnStartingChild(input) - const acquisitionGeneration = `generation-${acquire.mock.calls.length}` + const acquisitionGeneration = `generation-${spawn.mock.calls.length}` void starts.prove({ sessionId: SESSION, fence: input.fence, acquisitionGeneration }) void host.handleAdapterEvent({ type: 'ended', @@ -629,10 +625,10 @@ 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(spawn).toHaveBeenCalledTimes(2) expect(dispatch).not.toHaveBeenCalled() }) }) @@ -640,7 +636,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(spawnStartingChild) + spawn.mockImplementation(spawnStartingChild) await host.close(SESSION, 'evict') await startHost() @@ -651,7 +647,7 @@ describe('a start that fails while messages wait on it', () => { type: 'ended', sessionId: SESSION, fence: store.getRecord(SESSION)!.lease.runtimeFence, - acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + acquisitionGeneration: `generation-${spawn.mock.calls.length}`, reason: 'codex app-server exited with code 1', failure: agentSessionFailureFact('providerStartFailed', { detail: { text: 'codex: config.toml is invalid', audience: 'person' } @@ -711,18 +707,18 @@ describe('Stop withdraws what is queued', () => { }) const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.queuedMessages?.map((card) => card.messageId)).toEqual(['person']) - expect(acquire).toHaveBeenCalledTimes(1) + expect(spawn).toHaveBeenCalledTimes(1) }) it('withdraws a message whose start holds the queue: nothing is handed over (W17b)', async () => { await host.close(SESSION, 'evict') - const starting = deferred() - acquire.mockImplementationOnce(async (input) => { + const starting = Promise.withResolvers() + spawn.mockImplementationOnce(async (input) => { await starting.promise return spawnChild(input) }) const id = await accept('hello') - await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) + await eventually(async () => expect(spawn).toHaveBeenCalledTimes(2)) const stopped = stop() starting.resolve() @@ -738,7 +734,7 @@ describe('Stop withdraws what is queued', () => { adapterExtras = { closeSession } await host.close(SESSION, 'evict') await startHost() - acquire.mockImplementationOnce(spawnStartingChild) + spawn.mockImplementationOnce(spawnStartingChild) // Held for the starting child, which never proves its start. const id = await accept('hello') await eventually(() => expect(childPhase()).toBe('starting')) @@ -759,15 +755,15 @@ describe('Stop withdraws what is queued', () => { describe('an eviction between acceptance and handover', () => { it('rejects the message as not sent, never leaves it in doubt (W24)', async () => { - const started = deferred() + const started = Promise.withResolvers() await host.close(SESSION, 'evict') await startHost() - acquire.mockImplementationOnce(async (input) => { + spawn.mockImplementationOnce(async (input) => { await started.promise return spawnChild(input) }) const id = await accept('hello') - await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) + await eventually(async () => expect(spawn).toHaveBeenCalledTimes(2)) // Asked for during the delivery loop's start step, the close runs ahead of its handover step. const closing = host.close(SESSION, 'evict') started.resolve() @@ -787,7 +783,7 @@ describe('an eviction between acceptance and handover', () => { const handed = await accept('handed over') await eventually(async () => expect((await submission(handed))?.handedOverAt).toBeDefined()) // Held, the second send is accepted and the close runs next, ahead of its handover. - const second = deferred() + const second = Promise.withResolvers() void host['tasks'].serialize(SESSION, () => second.promise) const accepting = accept('still queued') const closing = host.close(SESSION, 'evict') @@ -805,15 +801,15 @@ describe('an eviction between acceptance and handover', () => { it('does not stop an idle child while a message is still queued for it (W24)', async () => { idleMs = 1_000 - const started = deferred() + const started = Promise.withResolvers() await host.close(SESSION, 'evict') await startHost() - acquire.mockImplementationOnce(async (input) => { + spawn.mockImplementationOnce(async (input) => { await started.promise return spawnChild(input) }) const id = await accept('hello') - await eventually(async () => expect(acquire).toHaveBeenCalledTimes(2)) + await eventually(async () => expect(spawn).toHaveBeenCalledTimes(2)) // The idle sweep ticks every few milliseconds meanwhile, past the idle window. clock = NOW + idleMs await new Promise((resolve) => setTimeout(resolve, 20)) @@ -824,7 +820,7 @@ describe('an eviction between acceptance and handover', () => { // Handed to the child it was queued for; the eviction may follow once nothing is owed. await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(1)) expect(dispatch.mock.calls[0]?.[0].clientMessageId).toBe(id) - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) }) }) @@ -839,7 +835,7 @@ describe('a close that stops the child and then a wind-down step fails', () => { it.each([{ end: 'evict', starting: true, kind: 'chatClosed', verdict: null }] as const)( 'rejects what is queued as $kind after a $end (during startup: $starting)', async ({ end, starting, kind, verdict }) => { - const started = deferred() + const started = Promise.withResolvers() adapterExtras = { // Once: the host's own teardown acknowledges again. acknowledgeSessionRelease: vi.fn().mockImplementationOnce(() => { @@ -848,12 +844,12 @@ describe('a close that stops the child and then a wind-down step fails', () => { } await host.close(SESSION, 'evict') await startHost() - acquire.mockImplementationOnce(async (input) => { + spawn.mockImplementationOnce(async (input) => { await started.promise return (starting ? spawnStartingChild : spawnChild)(input) }) const id = await accept('hello') - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + await eventually(() => expect(spawn).toHaveBeenCalledTimes(2)) // Asked for during the start step, the close runs ahead of the handover step. const ending = END_CHILD[end]() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index 774f87be548d..51c72870182a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -78,12 +78,17 @@ function attachParams( function adapter(input: { origin: 'created' | 'resumed' options?: AgentSessionOptionsResult - restoreFailures?: readonly string[] }): StructuredAgentSessionAdapter { return { - acquire: vi - .fn() + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi + .fn() .mockImplementation(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -99,9 +104,6 @@ function adapter(input: { } })), ...(input.options ? { readOptions: vi.fn(async () => input.options!) } : {}), - ...(input.restoreFailures - ? { readOptionRestoreFailures: vi.fn(() => input.restoreFailures!) } - : {}), dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), @@ -119,7 +121,7 @@ function expectSettledAttachLease(record: AgentSessionRecord | null): void { describe('structured session acquisition options', () => { it('samples provider history before acquiring a replacement child', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-history-before-acquire-')) + root = await mkdtemp(join(tmpdir(), 'orca-history-before-spawn-')) const initialStore = await openTestAgentSessionRecordStore(root) let childAcquired = false const historyWindow = (): ProviderHistoryWindow => ({ @@ -129,10 +131,11 @@ describe('structured session acquisition options', () => { }) const withHistory = (origin: 'created' | 'resumed'): StructuredAgentSessionAdapter => { const sessionAdapter = adapter({ origin }) - const acquire = vi.mocked(sessionAdapter.acquire) - acquire.mockImplementation(async (input) => { + const spawn = vi.mocked(sessionAdapter.spawn) + spawn.mockImplementation(async (input) => { childAcquired = true return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -248,7 +251,7 @@ describe('structured session acquisition options', () => { }) expect(created).toMatchObject({ ok: true }) - expect(sessionAdapter.acquire).toHaveBeenCalledWith( + expect(sessionAdapter.spawn).toHaveBeenCalledWith( expect.objectContaining({ options, recordPhase }) ) expect(store.getRecord(SESSION)?.options).toEqual(options) @@ -359,41 +362,6 @@ describe('structured session acquisition options', () => { }) }) - it('keeps a saved Fast choice when the provider skips restoring it', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-fast-restore-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter = adapter({ - origin: 'created', - options: { current: { model: 'gpt-standard' }, models: [] }, - restoreFailures: ['fastMode'] - }) - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - optionRevision: () => 0, - params: attachParams(CREATE_OPERATION, null, { - model: 'gpt-standard', - fastMode: 'true' - }), - now: () => NOW, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-standard', fastMode: 'true' }) - }) - it('proves an opened Codex owner without replacing saved choices from readback', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-known-options-')) const store = await openTestAgentSessionRecordStore(root) @@ -401,8 +369,7 @@ describe('structured session acquisition options', () => { ...adapter({ origin: 'created' }), readOptions: vi.fn(async () => { throw new Error('model list unavailable') - }), - readAcquisitionOptions: vi.fn(() => ({ model: 'gpt-reported', effort: 'high' })) + }) } const created = await performAttach({ @@ -426,7 +393,6 @@ describe('structured session acquisition options', () => { expect(created).toMatchObject({ ok: true }) expect(sessionAdapter.readOptions).not.toHaveBeenCalled() - expect(sessionAdapter.readAcquisitionOptions).not.toHaveBeenCalled() expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-saved', effort: 'medium' }) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') }) @@ -438,8 +404,7 @@ describe('structured session acquisition options', () => { ...adapter({ origin: 'created' }), readOptions: vi.fn(async () => { throw new Error('model list unavailable') - }), - readAcquisitionOptions: vi.fn(() => undefined) + }) } const options = { model: 'gpt-saved', effort: 'medium' } @@ -468,7 +433,7 @@ describe('structured session acquisition options', () => { }) describe.each([ - ['adapter acquire', 'acquire'], + ['adapter spawn', 'spawn'], ['identity commit', 'identity'], ['owner proof', 'proof'], ['journal attach', 'journal'] @@ -486,15 +451,16 @@ describe('structured session acquisition options', () => { options: { current: { model: 'gpt-5.6-terra' }, models: [] } }) const injected = new Error(`${failurePoint} failed`) - const acquire = vi.mocked(base.acquire) - if (failurePoint === 'acquire') { - acquire.mockRejectedValueOnce(injected) + const spawn = vi.mocked(base.spawn) + if (failurePoint === 'spawn') { + spawn.mockRejectedValueOnce(injected) } else if (failurePoint === 'identity') { vi.spyOn(store, 'commitProcessIdentity').mockRejectedValueOnce(injected) } else if (failurePoint === 'proof') { vi.spyOn(store, 'proveOwner').mockRejectedValueOnce(injected) } else if (failurePoint === 'journal') { - acquire.mockImplementation(async ({ fence, spawnToken }) => ({ + spawn.mockImplementation(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -516,7 +482,7 @@ describe('structured session acquisition options', () => { } return cleanup }) - const failingAdapter = { ...base, acquire, releaseAcquisition } + const failingAdapter = { ...base, spawn, releaseAcquisition } let journalFailure = failurePoint === 'journal' ? injected : null const perform = ( target: AgentSessionRecordStore, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts index d47f8650abca..ec2f366d2d7b 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 @@ -79,11 +79,17 @@ it('starts a chat whose recorded owner carries the runtime stamp', async () => { return published }) const adapter: StructuredAgentSessionAdapter = { - acquire: vi.fn( + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi.fn( async ({ fence, spawnToken, onSpawned }) => { const process = { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken } await onSpawned?.(process) return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process, link: { linkId: 'created-link', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index 168d41831162..a52947468e6f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -5,14 +5,12 @@ import type { } from '../../../shared/agent-session-record' import { AgentSessionPreSpawnError, - isAgentSessionPreSpawnError, - type StructuredAgentSessionProviderChildPhase + isAgentSessionPreSpawnError } from './structured-agent-session-adapter' import { rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-provider-exit-proof' import { journalIdentityFor } from './structured-agent-session-attach' import type { AttachFlowInput } from './structured-agent-session-attach-flow' import { mintStructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt' -import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' /** The same process, whatever Orca runtime the store stamped on its record (`runtime`): that stamp * is about who holds the process, not which process it is. */ @@ -25,32 +23,14 @@ function sameOwnerProcess( return isDeepStrictEqual(storedProcess, acquiredProcess) } -/** Bookkeeping: a failure is logged, never the proven start's. */ -function handOverStartCatalogListing( - input: AttachFlowInput, - sessionId: string, - listing: AgentModelCatalogLiveListing -): void { - try { - input.onStartCatalogListing?.(listing) - } catch (error) { - input.logger.warn('saving what a started provider listed failed', { - scope: 'provider-started-catalog', - sessionId, - error - }) - } -} - /** A reservation with no process behind it is only a promise to spawn; the * adapter makes it real and the store then grants the writer. */ -export async function acquireOwner( +export async function spawnOwner( input: AttachFlowInput, record: AgentSessionRecord ): Promise<{ record: AgentSessionRecord - acquisitionGeneration: string | null - providerChildPhase: StructuredAgentSessionProviderChildPhase + acquisitionGeneration: string }> { const fence = record.lease.runtimeFence const spawnToken = record.lease.reservedSpawnToken @@ -59,9 +39,9 @@ export async function acquireOwner( } try { try { - await input.onAcquiring?.() + await input.beforeSpawn?.() // A close or Stop that landed while the attach was still reconciling launches nothing. - input.acquireSignal?.throwIfAborted() + input.startupSignal?.throwIfAborted() } catch (error) { throw new AgentSessionPreSpawnError(error) } @@ -73,12 +53,12 @@ export async function acquireOwner( spawnToken, ...(permissionMode ? { permissionMode } : {}), ...(input.eventSink ? { events: input.eventSink } : {}), - ...(input.acquireSignal ? { signal: input.acquireSignal } : {}), + ...(input.startupSignal ? { signal: input.startupSignal } : {}), optionRevision: input.optionRevision, ...(input.startupOptions ? { startupOptions: input.startupOptions } : {}) }) const progress = input.onStartupAttempt?.(attempt) - const acquired = await input.adapter.acquire({ + const acquired = await input.adapter.spawn({ ...attempt, ...(progress ? { onOutput: progress.output } : {}), ...(input.recordPhase ? { recordPhase: input.recordPhase } : {}), @@ -92,7 +72,6 @@ export async function acquireOwner( }) } }) - const providerChildPhase = acquired.providerChildPhase ?? 'ready' if (record.lease.ownerProcess === null) { await input.store.commitProcessIdentity({ sessionId: record.sessionId, @@ -103,20 +82,14 @@ export async function acquireOwner( } else if (!sameOwnerProcess(record.lease.ownerProcess, acquired.process)) { throw new Error('agent_session_ownership_unknown') } + // The process owns the lease now; a handle the provider answers with is returned by `open`. const proved = await input.store.proveOwner({ sessionId: record.sessionId, fence, ...(acquired.link ? { link: acquired.link } : {}), now: input.now() }) - if (providerChildPhase === 'ready' && acquired.catalogListing) { - handOverStartCatalogListing(input, record.sessionId, acquired.catalogListing) - } - return { - record: proved, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, - providerChildPhase - } + return { record: proved, acquisitionGeneration: acquired.acquisitionGeneration } } catch (error) { if (isAgentSessionPreSpawnError(error)) { throw error diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts index c56b875681ca..a650878a0f7e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts @@ -31,6 +31,7 @@ function identity(sessionId: string, agent: string): AgentSessionJournalIdentity function acquisition(fence: number, spawnToken: string): AgentSessionAcquisition { return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 1, processStartTimeMs: 1, spawnToken }, link: { linkId: `link-${fence}`, @@ -47,7 +48,12 @@ function fakeAdapter( ): StructuredAgentSessionAdapter { return { supportsLocation: () => true, - acquire: vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)), + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)), dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), @@ -109,10 +115,10 @@ describe('StructuredAgentSessionAdapterRouter registry', () => { expect(router.supportsCreate(LOCAL, 'grok')).toBe(true) expect(router.supportsCreate(LOCAL, 'claude')).toBe(false) - await router.acquire({ identity: identity('session-1', 'grok'), fence: 1, spawnToken: 's-1' }) - expect(pilot.acquire).toHaveBeenCalledOnce() + await router.spawn({ identity: identity('session-1', 'grok'), fence: 1, spawnToken: 's-1' }) + expect(pilot.spawn).toHaveBeenCalledOnce() await expect( - router.acquire({ identity: identity('session-2', 'claude'), fence: 1, spawnToken: 's-2' }) + router.spawn({ identity: identity('session-2', 'claude'), fence: 1, spawnToken: 's-2' }) ).rejects.toThrow('structured sessions do not support claude') }) @@ -171,7 +177,7 @@ describe('StructuredAgentSessionAdapterRouter registry', () => { }) // Claude declares no rewind; its adapter's answer cannot claim one, at rest or live. expect(router.rewindSupport('session-1', 'claude')).toEqual(unsupported) - await router.acquire({ identity: identity('session-1', 'claude'), fence: 1, spawnToken: 's' }) + await router.spawn({ identity: identity('session-1', 'claude'), fence: 1, spawnToken: 's' }) expect(router.rewindSupport('session-1')).toEqual(unsupported) expect(router.rewindSupport('session-2', 'grok')).toEqual(unsupported) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts index 17c707c270ba..f65173f747a7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -22,6 +22,7 @@ function claudeIdentity(sessionId: string): AgentSessionJournalIdentity { function acquisition(fence: number, spawnToken: string): AgentSessionAcquisition { return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 1, processStartTimeMs: 1, spawnToken }, link: { linkId: `link-${fence}`, @@ -37,7 +38,12 @@ function adapterOf( releaseAcquisition: StructuredAgentSessionAdapter['releaseAcquisition'] ): StructuredAgentSessionAdapter { return { - acquire: vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)), + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)), releaseAcquisition, dispatch: vi.fn(), cancelTurn: vi.fn(), @@ -53,7 +59,7 @@ describe('StructuredAgentSessionAdapterRouter.releaseAcquisition', () => { const codex = adapterOf(vi.fn(async () => false)) const router = claudeAndCodexRouter({ claude, codex }, async () => {}) const identity = claudeIdentity('session-1') - await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) + await router.spawn({ identity, fence: 1, spawnToken: 'spawn-1' }) await expect(router.releaseAcquisition({ sessionId: 'session-1' })).rejects.toBe(failure) // With no owner left, a later release asks every adapter instead of the stale one. @@ -73,7 +79,7 @@ describe('StructuredAgentSessionAdapterRouter.closeSession', () => { const codex = adapterOf(vi.fn(async () => false)) const router = claudeAndCodexRouter({ claude, codex }, async () => {}) const identity = claudeIdentity('session-1') - await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) + await router.spawn({ identity, fence: 1, spawnToken: 'spawn-1' }) await expect(router.closeSession('session-1')).resolves.toBe(false) await expect( @@ -101,7 +107,7 @@ describe('StructuredAgentSessionAdapterRouter.closeSession', () => { async () => {} ) const identity = claudeIdentity('session-1') - await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) + await router.spawn({ identity, fence: 1, spawnToken: 'spawn-1' }) await expect(router.closeSession('session-1')).resolves.toBe(true) await expect(closeJournal()).rejects.toThrow('journal close failed') @@ -110,7 +116,7 @@ describe('StructuredAgentSessionAdapterRouter.closeSession', () => { router.acknowledgeSessionRelease('session-1') await expect(router.closeSession('session-1')).resolves.toBe(false) - await router.acquire({ identity, fence: 2, spawnToken: 'spawn-2' }) + await router.spawn({ identity, fence: 2, spawnToken: 'spawn-2' }) await expect(router.closeSession('session-1')).resolves.toBe(true) expect(closeSession).toHaveBeenCalledTimes(2) }) @@ -127,7 +133,7 @@ describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () => { claude, codex: adapterOf(vi.fn(async () => false)) }, async () => {} ) - await router.acquire({ + await router.spawn({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' @@ -152,7 +158,7 @@ describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () => const codex = adapterOf(vi.fn(async () => false)) const router = claudeAndCodexRouter({ claude, codex }, async () => {}) const identity = claudeIdentity('session-1') - await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) + await router.spawn({ identity, fence: 1, spawnToken: 'spawn-1' }) const stopSession = router[method] await expect(stopSession('session-1')).resolves.toBe(false) @@ -178,7 +184,7 @@ describe('StructuredAgentSessionAdapterRouter optional lifecycle methods', () => claude.closeSession = closeSession const codex = adapterOf(vi.fn(async () => false)) const router = claudeAndCodexRouter({ claude, codex }, async () => {}) - await router.acquire({ + await router.spawn({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' @@ -208,7 +214,7 @@ describe('StructuredAgentSessionAdapterRouter Stop wind-down', () => { await router.awaitStoppedRequestEnd('session-1', 5) expect(claude.awaitStoppedRequestEnd).not.toHaveBeenCalled() - await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) + await router.spawn({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) await router.awaitStoppedRequestEnd('session-1', 5) expect(claude.awaitStoppedRequestEnd).toHaveBeenCalledWith('session-1', 5) }) @@ -220,7 +226,7 @@ describe('StructuredAgentSessionAdapterRouter Stop wind-down', () => { const router = claudeAndCodexRouter({ claude, codex }, async () => {}) expect(router.routePromptCancel({ sessionId: 'session-1', prompt: QUESTION })).toBeUndefined() - await router.acquire({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) + await router.spawn({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) expect(router.routePromptCancel({ sessionId: 'session-1', prompt: QUESTION })).toEqual({ kind: 'stop' }) @@ -228,10 +234,10 @@ describe('StructuredAgentSessionAdapterRouter Stop wind-down', () => { }) describe('StructuredAgentSessionAdapterRouter.closeAll', () => { - it('refuses to acquire once the global close proof is published', async () => { - const acquire = vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)) + it('refuses to spawn once the global close proof is published', async () => { + const spawn = vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)) const claude = adapterOf(vi.fn(async () => true)) - claude.acquire = acquire + claude.spawn = spawn const router = claudeAndCodexRouter( { claude, codex: adapterOf(vi.fn(async () => false)) }, async () => undefined @@ -239,13 +245,13 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { await router.closeAll() await expect( - router.acquire({ + router.spawn({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' }) ).rejects.toThrow('router is closed') - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() }) it('keeps a per-session stop proof and reports no stop for a session it never routed', async () => { @@ -255,7 +261,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { { claude, codex: adapterOf(vi.fn(async () => false)) }, closeAdapters ) - await router.acquire({ + await router.spawn({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' @@ -300,7 +306,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { throw failure }) ) - await router.acquire({ + await router.spawn({ identity: claudeIdentity('session-1'), fence: 1, spawnToken: 'spawn-1' @@ -326,7 +332,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { const closeSession = vi.fn(async () => true) const claude = adapterOf(vi.fn(async () => true)) claude.closeSession = closeSession - claude.acquire = vi.fn( + claude.spawn = vi.fn( () => new Promise((resolve) => { resolveAcquire = resolve @@ -336,7 +342,7 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { { claude, codex: adapterOf(vi.fn(async () => false)) }, async () => undefined ) - const acquiring = router.acquire({ + const acquiring = router.spawn({ identity: claudeIdentity('session-1'), fence: 2, spawnToken: 'spawn-2' 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 1f77e7bec5c1..9b9b6fcca08d 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 @@ -39,14 +39,14 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi this.adapters().some((adapter) => adapter.supportsLocation?.(location) ?? false) /** Every adapter already gates its own shutdown, so the router only has to stop UNDOING that: - * a late acquire must not clear `allAdaptersClosed` and fan a session back out to closed + * a late spawn must not clear `allAdaptersClosed` and fan a session back out to closed * adapters. Once closed, the router stays closed. */ - async acquire(input: Parameters[0]) { + async spawn(input: Parameters[0]) { if (this.allAdaptersClosed) { throw new Error('structured session adapter router is closed') } const registration = this.requireAgent(input.identity) - const acquired = await registration.adapter.acquire(input) + const acquired = await registration.adapter.spawn(input) if (this.allAdaptersClosed) { throw new Error('structured session adapter router is closed') } @@ -70,13 +70,7 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return released } - open: NonNullable = (input) => { - const adapter = this.owner(input.sessionId) - if (!adapter.open) { - return Promise.reject(new Error('The provider cannot open a published child.')) - } - return adapter.open(input) - } + open: StructuredAgentSessionAdapter['open'] = (input) => this.owner(input.sessionId).open(input) dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => this.owner(input.sessionId).dispatch(input) @@ -138,9 +132,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi this.liveOwnerOrNull(sessionId)?.holdsLiveProviderProcess?.(sessionId, acquisitionGeneration) ?? false - startUnavailable: NonNullable = (sessionId) => - this.liveOwnerOrNull(sessionId)?.startUnavailable?.(sessionId) - awaitStoppedRequestEnd = async (sessionId: string, stoppedAt: number) => this.liveOwnerOrNull(sessionId)?.awaitStoppedRequestEnd?.(sessionId, stoppedAt) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts index daa7d2028428..0c79badd56c9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts @@ -1,13 +1,26 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { AgentSessionAcquisitionExitProvenError, AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRefusal, - AgentSessionAcquisitionRootExitObservedError + AgentSessionAcquisitionRootExitObservedError, + type AgentSessionAcquisition, + type StructuredAgentSessionAdapter, + type StructuredAgentSessionLifecycleEvent } from './structured-agent-session-adapter' import { rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-provider-exit-proof' +it('requires owned spawn identity and protocol open, without a started event', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + NonNullable + >() + expectTypeOf< + Extract + >().toEqualTypeOf() +}) + describe('failed agent-session acquisition cleanup', () => { it('names a failure exit-proven after proven cleanup, keeping its diagnostic and cause', async () => { const cause = new Error('proof failed') 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 617660a25ff4..d4f0d177fb29 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -1,7 +1,4 @@ -import type { - AgentSessionAccountKind, - AgentSessionUnavailable -} from '../../../shared/agent-session-availability' +import type { AgentSessionAccountKind } from '../../../shared/agent-session-availability' import type { AgentSessionRewindReason, AgentSessionRewindSupport @@ -55,7 +52,10 @@ import type { ProviderHistoryWindow } from '../agent-session-journal/journal-sub import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' -import type { StructuredAgentSessionProviderOpen } from './structured-agent-session-provider-open-contract' +import type { + StructuredAgentSessionProviderOpen, + StructuredAgentSessionProviderOpenResult +} from './structured-agent-session-provider-open-contract' /** A live options read, plus what the child listed for its account's saved catalog. The host * saves that listing and strips it before answering; an adapter whose listing reaches the @@ -105,17 +105,14 @@ export { AgentSessionAcquisitionRootExitObservedError } from './structured-agent-session-acquisition-errors' -/** What a reservation turns into once a process is running under it. Every acquire answers as - * soon as its process exists, before any handshake: the host publishes the child `starting` and - * hands it nothing until its `started` event. */ +/** The owned process returned before protocol construction begins. */ export type AgentSessionAcquisition = { process: AgentSessionProcessIdentity - /** Only a handle the adapter already holds at spawn (one Orca chose itself); a handle the - * provider answers with comes on `started`, never made up here. */ + /** Only a handle the adapter already holds at spawn; protocol handles come from open. */ link?: AgentSessionProviderHandleLink /** Host-local identity for this exact provider child, distinct even when the durable fence is * reused by a superseding acquisition. */ - acquisitionGeneration?: string + acquisitionGeneration: string } /** A refusal before spawn that a person can act on; the site that refused names it. */ @@ -212,30 +209,6 @@ export type StructuredAgentSessionEndedEvent = { startupUnanswered?: true } -/** The child an acquire handed over has now proven its start: its protocol session exists and the - * saved options it launched with are applied. What it reports from here on is fact. */ -export type StructuredAgentSessionStartedEvent = { - type: 'started' - sessionId: string - fence: number - acquisitionGeneration: string - /** The provider handle its protocol session answered with; absent when the acquisition held it. */ - link?: AgentSessionProviderHandleLink - /** What the child proved, snapshotted by the adapter from what startup already read. The host - * handles this inside the session's serialized step, so it must not ask the CLI. */ - reportedOptions: AgentSessionOptionsResult['current'] - /** 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, with the configured default its start resolved, saved once - * as its account's catalog. Claude's default comes on its settings readback's `options-reported`. */ - 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 = { @@ -247,26 +220,26 @@ export type StructuredAgentSessionOptionsSkippedEvent = { 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' | 'link' -> & { type: 'options-reported' } +/** An optional report after open; it never delays readiness. */ +export type StructuredAgentSessionOptionsReportedEvent = + StructuredAgentSessionProviderOpenResult & { + type: 'options-reported' + sessionId: string + fence: number + acquisitionGeneration: string + } export type StructuredAgentSessionLifecycleEvent = | StructuredAgentSessionEndedEvent - | StructuredAgentSessionStartedEvent | StructuredAgentSessionOptionsReportedEvent | StructuredAgentSessionOptionsSkippedEvent -/** Whether the provider child behind an acquisition has proven its start. Every acquire hands - * over a `starting` child, which the host gives no input until the `started` event flips it. */ +/** Whether the host's open has proven the child ready for input. */ export type StructuredAgentSessionProviderChildPhase = 'starting' | 'ready' /** The host's startup attempt (`structured-agent-session-startup-attempt`), which the host always * passes whole. Only the fields every caller has are required, so an adapter test may pass less. */ -export type StructuredAgentSessionAcquireInput = Pick< +export type StructuredAgentSessionSpawnInput = Pick< StructuredAgentSessionStartupAttempt, 'identity' | 'fence' | 'spawnToken' > & @@ -289,7 +262,7 @@ export type StructuredAgentSessionSetOptionInput = { export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & { /** Opens the published process under the host's startup signal. */ - open?: StructuredAgentSessionProviderOpen + open: StructuredAgentSessionProviderOpen /** Provider-aware capability check for hosts that route more than one adapter. */ supportsCreate?(location: AgentSessionExecutionLocation, agent: string): boolean /** Provider/runtime support, kept here so remote enablement changes adapter data, not UI logic. */ @@ -297,7 +270,7 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** Makes the reservation real. Called once per reservation, with the spawn * token the lease was reserved under and the fence the handle must be minted * at — the store rejects a link minted at any other fence. */ - acquire(input: StructuredAgentSessionAcquireInput): Promise + spawn(input: StructuredAgentSessionSpawnInput): Promise /** Reaps an acquired provider when the host cannot commit or prove its lease. * Returns true only after provider child exit is proven. Throws * `AgentSessionAcquisitionRootExitObservedError` when the provider root's own @@ -390,9 +363,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** The adapter's own child for this exact acquisition has a pid and its root exit has not been * seen: first-hand proof of life for lease renewal. Absent or false falls back to a PID probe. */ holdsLiveProviderProcess?(sessionId: string, acquisitionGeneration: string): boolean - /** Why the session's running child can take no turn, as its own start found; undefined when - * unknown or no child runs. Lives and dies with that child. */ - startUnavailable?(sessionId: string): AgentSessionUnavailable | undefined /** The `/` surface the running provider reports for itself. Undefined when the * provider never reports one, which is what keeps the client on its catalog. */ readCommands?(sessionId: string): AgentSessionSlashCommand[] | undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 79d5c5d44923..0118cca439a8 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 @@ -97,9 +97,15 @@ function attachParams(transcriptPath?: string): AgentSessionAttachParams { function adapter(): StructuredAgentSessionAdapter { return { - acquire: vi - .fn() + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi + .fn() .mockImplementation(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, link: { linkId: 'resumed-link', @@ -184,7 +190,7 @@ describe('adopting a provider conversation on create', () => { } expect(replay.cursor.epoch).toBe(first.cursor.epoch) expect(JSON.stringify(replay.value.page.items)).toContain('not yet in rollout') - expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) + expect(sessionAdapter.spawn).toHaveBeenCalledTimes(1) }) it.each(['missing', 'oversized', 'empty', 'invalid', 'source-less'] as const)( @@ -209,7 +215,7 @@ describe('adopting a provider conversation on create', () => { ok: false, refusal: { code: 'agent_session_identity_required' } }) - expect(sessionAdapter.acquire).not.toHaveBeenCalled() + expect(sessionAdapter.spawn).not.toHaveBeenCalled() expect(sessionAdapter.releaseAcquisition).not.toHaveBeenCalled() expect(onAttached).not.toHaveBeenCalled() expect(store?.getRecord(SESSION)).toBeNull() @@ -229,7 +235,7 @@ describe('adopting a provider conversation on create', () => { ) const sessionAdapter = adapter() await expect(attach(transcriptPath, sessionAdapter)).rejects.toThrow('disk write failed') - expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) + expect(sessionAdapter.spawn).toHaveBeenCalledTimes(1) expect(sessionAdapter.releaseAcquisition).toHaveBeenCalledTimes(1) }) @@ -284,11 +290,11 @@ describe('adopting a provider conversation on create', () => { await writeCodexRollout(transcriptPath, 'prepared before acquiring') const prepare = vi.spyOn(legacyImport, 'prepareLegacyTranscriptImport') const sessionAdapter = adapter() - const acquire = sessionAdapter.acquire - sessionAdapter.acquire = vi.fn(async (input) => { + const spawn = sessionAdapter.spawn + sessionAdapter.spawn = vi.fn(async (input) => { expect(prepare).toHaveBeenCalledTimes(1) await rm(transcriptPath) - return acquire(input) + return spawn(input) }) const result = await attach(transcriptPath, sessionAdapter, async ({ journal }) => journal.close() 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 17112f3679a9..cc26775555fd 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 @@ -36,7 +36,7 @@ const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost -let acquire: Mock +let spawn: Mock let generation = 0 /** Everything a live subscriber was sent after its opening snapshot. */ @@ -106,7 +106,7 @@ function exitBeforeProof(): Promise { } function providerSink() { - const events = acquire.mock.calls.at(-1)?.[0].events + const events = spawn.mock.calls.at(-1)?.[0].events if (!events) { throw new Error('no acquired provider sink') } @@ -117,7 +117,7 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-append-delivery-')) resetHostTestOperationIds() generation = 0 - acquire = vi.fn(async ({ fence, spawnToken }) => ({ + spawn = vi.fn(async ({ fence, spawnToken }) => ({ process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -134,7 +134,12 @@ beforeEach(async () => { logger: createStructuredAgentSessionLogger(), store, adapter: { - acquire, + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn, releaseAcquisition: vi.fn(async () => true), closeSession: vi.fn(async () => true), dispatch: vi.fn(async () => ({ state: 'admitted' as const })), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts index 6a54478a4613..2557c8e52c83 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts @@ -46,7 +46,13 @@ function adapter(catalog: ClaudeAtRestCommandCatalog): StructuredAgentSessionAda return { supportsLocation: () => true, supportsCreate: () => true, - acquire: vi.fn(async (input) => ({ + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi.fn(async (input) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4001, 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 d4bcaace1d2b..1f447d0b52aa 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 @@ -34,7 +34,7 @@ import type { StructuredAgentRegistry } from './structured-agent-registry' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import { readAgentSessionHydrationPage } from './agent-session-history-page' -import { acquireOwner } from './structured-agent-session-acquisition' +import { spawnOwner } from './structured-agent-session-acquisition' import { importAdoptedTranscript, prepareAdoptedTranscript @@ -63,9 +63,9 @@ export type AttachFlowInput = { params: AgentSessionAttachParams now: () => number recordPhase?: AgentSessionCreatePhaseRecorder - /** Aborted when a close, or a Stop admitted now, must not wait behind this attach's acquire. */ - acquireSignal?: AbortSignal - /** The attempt this attach's acquire runs under, minted before the adapter is called; what it + /** Aborted when a close, or a Stop admitted now, must not wait behind this attach's spawn. */ + startupSignal?: AbortSignal + /** The attempt this attach's spawn runs under, minted before the adapter is called; what it * answers learns of the child's spawn and output. */ onStartupAttempt?: ( attempt: StructuredAgentSessionStartupAttempt @@ -87,7 +87,7 @@ export type AttachFlowInput = { /** Host-owned provider sink, bound to the journal inside `onAttached`. */ eventSink?: StructuredAgentSessionEventSink /** Stops acquisition-window events targeting the superseded journal. */ - onAcquiring?: () => Promise | void + beforeSpawn?: () => Promise | void /** Settles writes already captured by the superseded journal before opening another. */ beforeJournalOpen?: () => Promise | void /** The conversation's own open journal, which the attach adopts: it never opens one itself. */ @@ -186,7 +186,7 @@ export async function performAttach( return { ok: false, refusal: replay.refusal } } } - // Sample provider history before a new child is acquired. Once acquireOwner + // Sample provider history before a new child is acquired. Once spawnOwner // starts the child, the adapter's liveness signal intentionally becomes // conservative and an absent prompt can no longer prove non-delivery. providerHistoryWindow = await readProviderHistoryWindow({ @@ -203,7 +203,7 @@ export async function performAttach( owner = 'none' } else { const acquired = await withAgentSessionCreatePhase('acquire_owner', input.recordPhase, () => - acquireOwner(input, record) + spawnOwner(input, record) ) record = acquired.record acquisitionGeneration = acquired.acquisitionGeneration diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 71f50ed697b0..bb83242d3012 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 @@ -52,7 +52,7 @@ export type StructuredAgentSessionAttachOptions = { startupOptions?: Readonly> /** A close, an admitted Stop or quit aborted this attach: its refusal is that abort's. */ onAborted?: () => void - /** The startup limit aborted this attach's acquire: it failed, nobody stopped it. */ + /** The startup limit aborted this attach's spawn: it failed, nobody stopped it. */ onStartupExpired?: () => void } @@ -112,14 +112,14 @@ async function runAttach( options: StructuredAgentSessionAttachOptions ): Promise> { // Begun first, so a close or Stop during any phase below still stops the start. - const acquire = context.runtimeState.acquireAborts.begin(params.envelope.sessionId) + const startup = context.runtimeState.providerWaits.begin(params.envelope.sessionId) try { - return await runAttachUnderAbort(context, callerKey, params, options, acquire.signal) + return await runAttachUnderAbort(context, callerKey, params, options, startup.signal) } finally { - acquire.end() - if (isStructuredAgentSessionStartupExpired(acquire.signal.reason)) { + startup.end() + if (isStructuredAgentSessionStartupExpired(startup.signal.reason)) { options.onStartupExpired?.() - } else if (acquire.signal.aborted) { + } else if (startup.signal.aborted) { options.onAborted?.() } } @@ -130,7 +130,7 @@ async function runAttachUnderAbort( callerKey: string, params: AgentSessionAttachParams, options: StructuredAgentSessionAttachOptions, - acquireSignal: AbortSignal + startupSignal: AbortSignal ): Promise> { const sessionId = params.envelope.sessionId const recordPhase = options.recordPhase @@ -180,7 +180,7 @@ async function runAttachUnderAbort( logger: context.deps.logger, eventSink: attemptSink.sink, // The superseded child's writes settle into its own journal before a new child starts. - onAcquiring: async () => { + beforeSpawn: async () => { const barrier = await context.runtimeState.currentEventSink(sessionId)?.drained() if (barrier && !barrier.ok) { throw barrier.error @@ -199,9 +199,7 @@ async function runAttachUnderAbort( params, now: () => context.now(), recordPhase, - acquireSignal, - onStartCatalogListing: (listing) => - context.deps.modelCatalog?.recordLiveListing(sessionId, listing), + startupSignal, onStartupAttempt: (startup) => { attempt.startup = startup return startupAttempts.track(sessionId, startup) @@ -221,7 +219,7 @@ async function runAttachUnderAbort( // The cleanup released the acquisition, which for a re-attach is the live child itself. onAcquisitionReleased: (cause, verdict) => endStructuredAgentSessionReleasedChild(context, sessionId, cause, verdict), - onAttached: async (attached, acquisitionGeneration, owner, providerChildPhase) => { + onAttached: async (attached, acquisitionGeneration, owner) => { const fence = structuredAgentSessionConversationFence(context.deps.store, sessionId) const current = context.sessions.get(sessionId)?.child ?? null const startedFor = owner === 'acquired' ? options.startedFor : current?.startedFor @@ -255,15 +253,15 @@ async function runAttachUnderAbort( child: { generation: acquisitionGeneration ?? current?.generation ?? null, fence, - // An acquire reports its phase; a re-attach keeps what its child already proved. - phase: owner === 'acquired' ? providerChildPhase : (current?.phase ?? 'ready'), + // A spawned child starts unready; a re-attach keeps what its child already proved. + phase: owner === 'acquired' ? 'starting' : (current?.phase ?? 'ready'), ...(startedFor === undefined ? {} : { startedFor }), ...(permissionMode === undefined ? {} : { permissionMode }) } } } // Only a proven child has the protocol session recovery asks; a starting one recovers in - // its `started` step, before it is handed anything. + // its open step, before it is handed anything. if (attempt.candidate?.child.phase === 'ready') { await recoverStructuredRewind( context.deps, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts index ebd23c423e30..e55f240cb913 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 @@ -29,7 +29,7 @@ const PICKED_MODEL = 'opus' let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost -let acquire: Mock +let spawn: Mock let activeModel: string let dispatch: Mock let order: string[] @@ -61,7 +61,7 @@ function adapter(): StructuredAgentSessionAdapter { order.push('dispatch') return { state: 'admitted' as const } }) - acquire = vi.fn(async ({ fence, spawnToken, options }) => { + spawn = vi.fn(async ({ fence, spawnToken, options }) => { activeModel = options?.model ?? DEFAULT_MODEL return { process: { hostId: 'local', pid: 4200, processStartTimeMs: NOW, spawnToken }, @@ -69,7 +69,7 @@ function adapter(): StructuredAgentSessionAdapter { link: { linkId: `claude-native-${fence}`, handle: claudeProviderHandle(CLAUDE_SESSION, 'native-leaf'), - origin: acquire.mock.calls.length === 1 ? 'created' : 'resumed', + origin: spawn.mock.calls.length === 1 ? 'created' : 'resumed', mintedAtFence: fence, observedAt: NOW } @@ -85,7 +85,7 @@ function adapter(): StructuredAgentSessionAdapter { optionRevision: 0 }) }), - acquire, + spawn, dispatch, cancelTurn: vi.fn(async () => ({ cancelled: true })), answerPrompt: vi.fn(async () => undefined), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index 2ea3a16024be..b84b0e71e1fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -63,7 +63,7 @@ describe('Claude root-exit stop', () => { now: NOW }) const fence = reservation.record.lease.runtimeFence - const acquisition = await adapter.acquire({ + const acquisition = await adapter.spawn({ identity: { ...identityFor(), hostId: 'local', workspaceId: 'folder-1' }, fence, spawnToken: 'spawn-1' @@ -112,7 +112,7 @@ describe('Claude root-exit stop', () => { journal, params, child: { - generation: acquisition.acquisitionGeneration ?? null, + generation: acquisition.acquisitionGeneration, fence, phase: 'ready' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts index 2823e9c5d651..6fd6822d5e66 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-aborts-start.test.ts @@ -39,8 +39,8 @@ function openHost(state: ReturnType): StructuredAgentSessi it('a close stops a start the provider never answers instead of queueing behind it', async () => { const state = hostTestState() let signal: AbortSignal | undefined - // A start the provider never answers ends only when its acquire is aborted. - state.acquire.mockImplementation( + // A start the provider never answers ends only when its spawn is aborted. + state.spawn.mockImplementation( (input) => new Promise((_resolve, reject) => { signal = input.signal @@ -49,7 +49,7 @@ it('a close stops a start the provider never answers instead of queueing behind ) const host = openHost(state) const attaching = host.attach(CALLER, attachParams()) - await vi.waitFor(() => expect(state.acquire).toHaveBeenCalled()) + await vi.waitFor(() => expect(state.spawn).toHaveBeenCalled()) await host.close(SESSION, 'user-close') expect(signal?.aborted).toBe(true) expect((await attaching).ok).toBe(false) @@ -70,5 +70,5 @@ it('a close while the attach still probes the previous owner asks no adapter to name: 'AgentSessionPreSpawnError' }) await closing - expect(state.acquire).not.toHaveBeenCalled() + expect(state.spawn).not.toHaveBeenCalled() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index a027d9b1caf7..4369b0a2e9f7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -57,7 +57,7 @@ beforeEach(() => { ...adapter(), closeSession: async () => { closeCalls += 1 - const events = state.acquire.mock.calls.at(-1)?.[0].events + const events = state.spawn.mock.calls.at(-1)?.[0].events if (providerEnd === null) { return true } @@ -92,7 +92,7 @@ beforeEach(() => { /** A running turn, anchored to its user row, with a status list watching the session. */ async function runningTurn(): Promise { await attach() - const events = hostTestState().acquire.mock.calls[0]?.[0].events + const events = hostTestState().spawn.mock.calls[0]?.[0].events if (!events) { throw new Error('missing provider event sink') } @@ -187,7 +187,7 @@ describe('a turn cut short by closing its provider', () => { // arrives: the journal has no turn yet. await attach() await host.flushStreamedEvents(SESSION) - const events = hostTestState().acquire.mock.calls[0]?.[0].events + const events = hostTestState().spawn.mock.calls[0]?.[0].events events?.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'cut-turn', ordinal: 0 }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] }, @@ -208,7 +208,7 @@ describe('a turn cut short by closing its provider', () => { it('leaves a turn cut off before the close as news', async () => { providerEnd = null await attach() - const events = hostTestState().acquire.mock.calls[0]?.[0].events + const events = hostTestState().spawn.mock.calls[0]?.[0].events // An earlier death the user already saw as Interrupted, then closed. events?.appendItem( CUT_TURN, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts index a61255e34e52..d93c6fab1e61 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-note-end.test.ts @@ -75,11 +75,7 @@ beforeEach(async () => { adapter = adapterFor(codex, launch, [], { onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), onEvent: (event) => { - if ( - event.type === 'started' || - event.type === 'options-reported' || - (event.type === 'ended' && 'cause' in event) - ) { + if (event.type === 'options-reported' || (event.type === 'ended' && 'cause' in event)) { void host.handleAdapterEvent(event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index fcb63b6e5d05..da51ccf726d7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -72,11 +72,7 @@ beforeEach(async () => { const adapter = adapterFor(codex, launch, [], { onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), onEvent: (event) => { - if ( - event.type === 'started' || - event.type === 'options-reported' || - (event.type === 'ended' && 'cause' in event) - ) { + if (event.type === 'options-reported' || (event.type === 'ended' && 'cause' in event)) { void host.handleAdapterEvent(event) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts index c6fda61af089..b1d467541304 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts @@ -25,7 +25,7 @@ it('publishes idle provider reloads only when the actual command catalog changes const claude = fakeClaude() const adapter = adapterFor(claude) const publish = vi.fn() - await adapter.acquire({ + await adapter.spawn({ identity: identityFor(), fence: 7, spawnToken: 'commands', 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 7dd9fe004998..41a133cb8ab1 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 @@ -103,7 +103,7 @@ describe('closing the handle', () => { const sent = await rig.host.send(CALLER, restTestSend('after the close', fence())) expect(sent.ok).toBe(true) - // A cold start: reopen, reconcile and acquire. + // A cold start: reopen, reconcile and spawn. await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), COLD_START) // The reader opened on the first handle is fed by the second. await vi.waitFor(() => expect(readerSaw(reader.events).texts).toContain('after the close')) @@ -172,7 +172,7 @@ describe('closing the handle', () => { // And the handle a write finds is a live one. const sent = await rig.host.send(CALLER, restTestSend('after the close', fence())) expect(sent.ok).toBe(true) - // A cold start: reopen, reconcile and acquire. + // A cold start: reopen, reconcile and spawn. await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), COLD_START) }) @@ -223,8 +223,8 @@ describe('the sweep and the lease (P2-20)', () => { describe('a start that never finishes (P2-15)', () => { it('is stopped by the sweep, and the message held for it carries its one row and rejection', async () => { const stopReason = 'Codex never finished starting, so Orca stopped it.' - const spawn = rig.adapter.acquire.getMockImplementation()! - rig.adapter.acquire.mockImplementationOnce(rig.heldStart(spawn)) + const spawn = rig.adapter.spawn.getMockImplementation()! + rig.adapter.spawn.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 ad4e098c2b0c..9d708bc9350c 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 @@ -224,7 +224,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { * still queued waits for the chat's next turn; a person's unsent message stays as a card. */ close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => { // Outside the queue: a start the provider never answers must not hold the close behind it. - host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') + host.context().runtimeState.providerWaits.abort(sessionId, 'closed while starting') return serialize(sessionId, async () => { readRefusals.forget(sessionId) const session = sessions.get(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index 85738139603c..ec66045dcde9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -177,7 +177,7 @@ async function settleGoneGeneration( failureTextContext: structuredAgentSessionFailureWordsContext(record) }) } catch (error) { - // Best effort: the next open or acquire re-derives it. + // Best effort: the next open or spawn re-derives it. deps.logger.warn("settling a gone agent's work on open failed", { scope: 'open-dead-generation', sessionId: record.sessionId, 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 4047df3d0bc7..deb3a65ab8b6 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 @@ -1,3 +1,4 @@ +import { eventually } from './structured-agent-session-queued-message-rig.test-fixture' import { envelope as mutationEnvelope } from './structured-agent-session-host-test-harness' // A Stop that names no turn stops what the conversation has in flight: it withdraws what is // queued, and interrupts a handed-over message even before the provider has opened its turn — @@ -16,7 +17,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionAcquisition, - StructuredAgentSessionAcquireInput, + StructuredAgentSessionSpawnInput, StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' @@ -65,13 +66,9 @@ let log: ReturnType let events: StructuredAgentSessionEventSink | undefined /** Set by a test whose later children are real Codex ones; the first stays the fake. */ let acquireNext: - | ((input: StructuredAgentSessionAcquireInput) => Promise) + | ((input: StructuredAgentSessionSpawnInput) => Promise) | undefined -function eventually(assertion: () => void | Promise): Promise { - return vi.waitFor(assertion, { timeout: 10_000 }) -} - beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-conversation-stop-')) resetHostTestOperationIds() @@ -91,7 +88,12 @@ beforeEach(async () => { store, adapter: startsWhenPublished( { - acquire: async (input) => { + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: async (input) => { const { fence, spawnToken, events: sink } = input events = sink // A child started after a Stop ended the last one resumes the chat's thread. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts index e393cbf0d157..bf047db4f95c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts @@ -77,7 +77,7 @@ function adapterThatNeverFinishesStarting(): StructuredAgentSessionAdapter { return Object.assign(adapterFor(codex), { supportsCreate: () => true }) } -/** A Codex adapter whose child spawns and reports its pid, and whose acquire never answers: the +/** A Codex adapter whose child spawns and reports its pid, and whose spawn never answers: the * host dies with the child recorded and the create still unanswered. */ function adapterThatNeverAnswersItsSpawn(): StructuredAgentSessionAdapter { const codex = fakeCodex() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index cd9ecc7e7418..5a6a54c54bae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -168,7 +168,12 @@ function openHost(overrides: Partial): void { logger: createStructuredAgentSessionLogger(), store, adapter: { - acquire: vi.fn(), + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi.fn(), dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), @@ -428,10 +433,15 @@ describe('a turn a read reached before the reconcile proved its owner dead', () const stopOwnerProcess = vi.fn(() => { alive = false }) - const acquire = vi.fn() + const spawn = vi.fn() openHost({ adapter: { - acquire, + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn, dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), @@ -449,7 +459,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () await drainSession() expect(stopOwnerProcess).toHaveBeenCalledOnce() - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(await settledTurn()).toMatchObject({ state: 'interrupted', completedAt: LAST_RENEWED_AT @@ -457,7 +467,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () }) it('never touches the turn a start after the crash is writing', async () => { - const acquire = vi.fn( + const spawn = vi.fn( async ({ fence, spawnToken, onSpawned, events }) => { const process = { hostId: 'local', @@ -473,6 +483,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process, link: { linkId: `claude-${fence}-link`, @@ -486,7 +497,12 @@ describe('a turn a read reached before the reconcile proved its owner dead', () ) openHost({ adapter: { - acquire, + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn, dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), @@ -544,7 +560,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () * later start succeeds. The crashed owner, and any child left behind, probe gone. */ async function hostWithFailingFirstStart(failure: Error) { let now = RELAUNCHED_AT - const acquire = vi.fn( + const spawn = vi.fn( async ({ fence, spawnToken, onSpawned }) => { const process = { hostId: 'local', pid: 7_000 + fence, processStartTimeMs: now, spawnToken } await onSpawned?.(process) @@ -552,6 +568,7 @@ async function hostWithFailingFirstStart(failure: Error) { throw failure } return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process, link: { linkId: `claude-${fence}-link`, @@ -565,7 +582,12 @@ async function hostWithFailingFirstStart(failure: Error) { ) openHost({ adapter: { - acquire, + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn, releaseAcquisition: async () => true, dispatch: vi.fn(), cancelTurn: vi.fn(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts index 0d87155f5147..337157af5889 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts @@ -81,7 +81,7 @@ it('imports adoption at rest and resumes its original provider identity after it expect(rig.store.getRecord(SESSION)?.providerHandleChain).toMatchObject([ { origin: 'adopted', mintedAtFence: 1, handle: { nativeId: THREAD } } ]) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() expect(history).not.toHaveBeenCalled() await rm(join(rig.root, 'rollout.jsonl')) expect( @@ -93,7 +93,7 @@ it('imports adoption at rest and resumes its original provider identity after it ).toMatchObject({ ok: true, replayed: true }) expect(await rig.host.send(CALLER, restTestSend('new work'))).toMatchObject({ ok: true }) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) - expect(rig.adapter.acquire).toHaveBeenCalledWith( + expect(rig.adapter.spawn).toHaveBeenCalledWith( expect.objectContaining({ identity: expect.objectContaining({ providerHandle: expect.objectContaining({ nativeId: THREAD }) @@ -102,7 +102,7 @@ it('imports adoption at rest and resumes its original provider identity after it ) expect(history).toHaveBeenCalledOnce() expect(history.mock.invocationCallOrder[0]).toBeLessThan( - rig.adapter.acquire.mock.invocationCallOrder[0] + rig.adapter.spawn.mock.invocationCallOrder[0] ) expect(rig.store.getRecord(SESSION)?.accountHome).toEqual(params.accountHome) }) @@ -121,7 +121,7 @@ it('refuses concurrent adoption of a provider conversation by another session', ]) expect(rig.store.listRecords()).toHaveLength(1) expect(rig.store.listOperationRows()).toHaveLength(1) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() }) it('refuses concurrent founding under a tab another chat already owns', async () => { @@ -135,7 +135,7 @@ it('refuses concurrent founding under a tab another chat already owns', async () { refusal: { code: 'agent_session_conflict', details: { reason: 'tabIdTaken' } } } ]) expect(rig.store.listVisibleSessionIds()).toHaveLength(1) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() }) it('validates adoption history before founding anything', async () => { @@ -174,11 +174,11 @@ it.each([ }) expect(await rig.host.create(CALLER, params)).toMatchObject({ ok: true }) expect(rig.store.getRecord(SESSION)?.location).toEqual(params.location) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() } ) -it('pins the host-resolved directory of a floating chat before any acquire', async () => { +it('pins the host-resolved directory of a floating chat before any spawn', async () => { const params = createTestParams(undefined, { location: { executionHostId: 'local', @@ -191,7 +191,7 @@ it('pins the host-resolved directory of a floating chat before any acquire', asy ok: true }) expect(rig.store.getRecord(SESSION)?.launchDirectory).toBe(rig.root) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() }) it.each(['adopted', 'blank'] as const)( @@ -245,6 +245,6 @@ it.each(['adopted', 'blank'] as const)( ) } expect(await stopping).toMatchObject({ ok: true }) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() } ) 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 366f7288ec07..10af2760483b 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 @@ -42,7 +42,7 @@ function firstMessage() { function abortableAcquisition() { const entered = Promise.withResolvers() - rig.adapter.acquire.mockImplementationOnce( + rig.adapter.spawn.mockImplementationOnce( ({ signal }) => new Promise((_resolve, reject) => { entered.resolve() @@ -57,7 +57,7 @@ function abortableAcquisition() { return entered.promise } -it('commits the record, tab, operation and first message together and returns while acquire is unresolved', async () => { +it('commits the record, tab, operation and first message together and returns while spawn is unresolved', async () => { const first = firstMessage() const params = createTestParams(first, { surfaceTabId: 'chat-create-tab' }) const entered = abortableAcquisition() @@ -71,7 +71,7 @@ it('commits the record, tab, operation and first message together and returns wh }) ) const publishTab = vi.fn(async () => { - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() expect(rig.store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', ownerProcess: null, @@ -100,7 +100,7 @@ it('commits the record, tab, operation and first message together and returns wh } ]) expect(publishTab).toHaveBeenCalledOnce() - await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(rig.adapter.spawn).toHaveBeenCalledOnce()) await entered expect(rig.adapter.dispatch).not.toHaveBeenCalled() expect(await stopCreatedChat(rig.host)).toMatchObject({ ok: true, value: { cancelled: true } }) @@ -129,7 +129,7 @@ it('creates a blank chat at rest without acquiring and persists its launch argum } }) expect(resolveLaunchEnv).toHaveBeenCalledOnce() - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() }) it('keeps delivery behind runtime tab publication even after the message is committed', async () => { @@ -153,7 +153,7 @@ it('keeps delivery behind runtime tab publication even after the message is comm await held.promise await new Promise((resolve) => setTimeout(resolve, 0)) expect(rig.store.getRecord(SESSION)).not.toBeNull() - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() expect(answered).toBe(false) release.resolve() expect(await creating).toMatchObject({ ok: true }) @@ -161,7 +161,7 @@ it('keeps delivery behind runtime tab publication even after the message is comm await stopCreatedChat(rig.host) }) -it('an immediate Stop cancels the queued first message before acquire and replay never redelivers it', async () => { +it('an immediate Stop cancels the queued first message before spawn and replay never redelivers it', async () => { const first = firstMessage() const params = createTestParams(first) let stopping: ReturnType | undefined @@ -174,7 +174,7 @@ it('an immediate Stop cancels the queued first message before acquire and replay }) ).toMatchObject({ ok: true }) expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() expect(await rig.host.create(CALLER, params, { firstMessage: first })).toMatchObject({ ok: true, replayed: true, @@ -192,32 +192,28 @@ it('an immediate Stop cancels the queued first message before acquire and replay it('holds the created first message until the child proves its start, then hands it over once', async () => { const first = firstMessage() - const spawn = rig.adapter.acquire.getMockImplementation()! + const spawn = rig.adapter.spawn.getMockImplementation()! let generation = '' const holdStart = rig.heldStart(spawn) - rig.adapter.acquire.mockImplementationOnce(async (input) => { + rig.adapter.spawn.mockImplementationOnce(async (input) => { const child = await holdStart(input) - generation = child.acquisitionGeneration ?? '' + generation = child.acquisitionGeneration return child }) expect( await rig.host.create(CALLER, createTestParams(first), { firstMessage: first }) ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(rig.adapter.spawn).toHaveBeenCalledOnce()) await new Promise((resolve) => setTimeout(resolve, 0)) expect(rig.adapter.dispatch).not.toHaveBeenCalled() const held = (await rig.host.journalSnapshot(SESSION)).submissions expect(held).toEqual([expect.objectContaining({ clientMessageId: first.clientMessageId })]) expect(held[0].handedOverAt).toBeUndefined() - await rig.host.handleAdapterEvent({ - type: 'started', + await rig.proveStart({ sessionId: SESSION, fence: rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 0, - acquisitionGeneration: generation, - reportedOptions: { model: 'gpt-live' }, - restoreSkippedOptions: [], - optionRevision: rig.host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) + acquisitionGeneration: generation }) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) @@ -229,12 +225,12 @@ 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(rig.heldStart(spawn)) + const spawn = rig.adapter.spawn.getMockImplementation()! + rig.adapter.spawn.mockImplementationOnce(rig.heldStart(spawn)) expect( await rig.host.create(CALLER, createTestParams(first), { firstMessage: first }) ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(rig.adapter.spawn).toHaveBeenCalledOnce()) expect(await stopCreatedChat(rig.host)).toMatchObject({ ok: true, value: { cancelled: true } }) @@ -314,7 +310,7 @@ it('replays the stopped first message even after its row falls outside the hydra rejection: { kind: 'cancelled' } }) expect(append).not.toHaveBeenCalled() - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() expect(rig.adapter.dispatch).not.toHaveBeenCalled() }) @@ -355,7 +351,7 @@ it('admits Stop with the create-returned fence after acquisition advances it and ).toMatchObject({ event: { reason: 'user-stop' } }) expect(rig.adapter.dispatch).not.toHaveBeenCalled() } finally { - rig.host.collaboratorsForTests().runtimeState.acquireAborts.abort(SESSION, 'test cleanup') + rig.host.collaboratorsForTests().runtimeState.providerWaits.abort(SESSION, 'test cleanup') await stopping } }) @@ -380,7 +376,7 @@ it.each(['journal_rows', 'agent_session_operations'])( expect((await readPersistedTestAgentSessionStore(rig.root)).records).toEqual({}) expect(loadTestJournal(rig.root, SESSION)?.state.submissions.size).toBe(0) expect(rig.host.hasSession(SESSION)).toBe(false) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() expect(publishTab).not.toHaveBeenCalled() db.exec('DROP TRIGGER refuse_create') const entered = abortableAcquisition() @@ -403,7 +399,7 @@ it('answers a journal founding failure as a refusal before publishing any create ).toMatchObject({ ok: false, refusal: { code: 'agent_session_journal_unreadable' } }) expect(rig.store.listRecords()).toEqual([]) expect(rig.store.listOperationRows()).toEqual([]) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() }) it('retries tab publication from the committed message without acquiring early or appending again', async () => { @@ -417,7 +413,7 @@ it('retries tab publication from the committed message without acquiring early o } }) ).toMatchObject({ ok: false, refusal: { details: { reason: 'tabUnconfirmed' } } }) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() const append = vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission') const entered = abortableAcquisition() expect(await rig.host.create(CALLER, params, { firstMessage: first })).toMatchObject({ 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 be29deec69b2..6566ede046f3 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 @@ -13,6 +13,7 @@ // settles it first (`journal-unsent-send-hold.ts`). import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' @@ -31,9 +32,9 @@ import type { import { oldestQueuedSubmission, recordStructuredAgentSessionStartFailure, - refusedStructuredAgentSessionStart + refusedStructuredAgentSessionStart, + startThatFailedWhileQueued } 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' @@ -42,7 +43,7 @@ import type { StructuredAgentSessionStartReadiness } from './structured-agent-se import { holdRestartedStructuredAgentSessionSends } from './structured-agent-session-host-lifetime' import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' import { waitsForCodexTurnOptions } from './structured-agent-session-message-options' -import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' +import type { StructuredAgentSessionProviderWaits } from './structured-agent-session-provider-waits' export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap @@ -76,7 +77,7 @@ export type StructuredAgentSessionDeliveryLoopDeps = { /** A person's Stop is still ending the session's work: the status feed's own reading. */ stopping: (sessionId: string) => boolean now: () => number - acquireAborts: Pick + providerWaits: Pick } type RefusedStart = Extract @@ -281,8 +282,11 @@ export class StructuredAgentSessionDeliveryLoop { return 'continue' } } + const nextBody = session.journal.itemBody(agentJournalSubmissionKey(next.clientMessageId)) if ( this.deps.record(sessionId)?.provider === 'codex' && + nextBody?.kind === 'message' && + !nextBody.command && waitsForCodexTurnOptions(session.journal, next) ) { return this.stop(sessionId) @@ -298,7 +302,7 @@ export class StructuredAgentSessionDeliveryLoop { record: () => this.deps.record(sessionId), childWork: () => this.deps.readChildWork(sessionId), now: this.deps.now, - acquireAborts: this.deps.acquireAborts + providerWaits: this.deps.providerWaits }, next ) @@ -354,22 +358,3 @@ export class StructuredAgentSessionDeliveryLoop { return 'stop' } } - -/** A start that died while this message waited on it — a view's, say — is the message's failed - * start: settled with it, under its key, rather than started again into the same failure. */ -function startThatFailedWhileQueued( - session: StructuredAgentSessionHostSession, - oldest: NonNullable> -): StartFailure | null { - const ended = failedProviderChildStart(session) - if ( - !ended || - oldest.acceptedSequence === undefined || - ended.endedAt.epoch !== session.journal.cursor().epoch || - ended.endedAt.sequence < oldest.acceptedSequence - ) { - return null - } - const cause = structuredAgentSessionEndedChildFailure(ended) - return cause ? { startKey: ended.generation, cause } : null -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts index 2814638e0413..a6e99b53d504 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 @@ -100,9 +100,15 @@ function params( function grokAdapter(): StructuredAgentSessionAdapter { return { supportsLocation: () => true, - acquire: vi - .fn() + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi + .fn() .mockImplementation(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, link: { linkId: `grok-link-${fence}`, @@ -183,7 +189,7 @@ it('refuses an unregistered agent before creating a durable record', async () => spawnToken: 'spawn-unregistered' }) expect(result).toMatchObject(UNSUPPORTED) - expect(adapter.acquire).not.toHaveBeenCalled() + expect(adapter.spawn).not.toHaveBeenCalled() expect(store.getRecord(SESSION)).toBeNull() }) @@ -203,7 +209,7 @@ it('keeps saved history readable without a registration and resumes when it retu const store = await openTestAgentSessionRecordStore(root!) const agents = new StructuredAgentRegistry([]) const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) - const acquire = vi.spyOn(router, 'acquire') + const spawn = vi.spyOn(router, 'spawn') const host = new StructuredAgentSessionHost({ store, agents, @@ -243,7 +249,7 @@ it('keeps saved history readable without a registration and resumes when it retu await expect( host.attach({ callerKey: 'client-1' }, params('2', before)) ).resolves.toMatchObject(UNSUPPORTED) - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(store.getRecord(SESSION)!.lease.runtimeFence).toBe(before) } finally { await host.flushAllStreamedEvents() @@ -258,7 +264,7 @@ it('keeps saved history readable without a registration and resumes when it retu store }) expect(result).toMatchObject({ ok: true }) - expect(adapter.acquire).toHaveBeenCalledOnce() + expect(adapter.spawn).toHaveBeenCalledOnce() expect(store.listVisibleSessionIds()).toEqual([SESSION]) }) @@ -280,7 +286,7 @@ it.each([ }) expect(result).toMatchObject(UNSUPPORTED) - expect(adapter.acquire).not.toHaveBeenCalled() + expect(adapter.spawn).not.toHaveBeenCalled() // Still listed and readable: its tab publishes and its history opens. expect(store.getRecord(SESSION)).toMatchObject({ provider: 'grok', accountHome: GROK_HOME }) expect(store.getRecord(SESSION)?.providerHandleChain[0]?.handle.transport).toBe('acp') @@ -299,7 +305,7 @@ it("refuses to start a chat that pins another agent's account variable", async ( }) expect(result).toMatchObject(UNSUPPORTED) - expect(adapter.acquire).not.toHaveBeenCalled() + expect(adapter.spawn).not.toHaveBeenCalled() }) it('starts the same chat again once its agent is declared as it was', async () => { @@ -315,7 +321,7 @@ it('starts the same chat again once its agent is declared as it was', async () = }) expect(result).toMatchObject({ ok: true }) - expect(adapter.acquire).toHaveBeenCalledOnce() + expect(adapter.spawn).toHaveBeenCalledOnce() }) it('refuses an attach whose agent is not the session it names', async () => { @@ -344,6 +350,6 @@ it('refuses an attach whose agent is not the session it names', async () => { ok: false, refusal: { code: 'agent_session_operation_invalid', details: { reason: 'requestMalformed' } } }) - expect(adapter.acquire).not.toHaveBeenCalled() + expect(adapter.spawn).not.toHaveBeenCalled() expect(store.getRecord(SESSION)).toBeNull() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index d0b3ca564ba6..15ffe92a4d47 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -12,8 +12,7 @@ import type { import type { StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' import { settleStructuredAgentSessionOptionsReported, - settleStructuredAgentSessionOptionsSkipped, - settleStructuredAgentSessionProviderStarted + settleStructuredAgentSessionOptionsSkipped } from './structured-agent-session-provider-started' import { endExitedStructuredAgentSessionChildUnderSerialize, @@ -83,7 +82,7 @@ export class StructuredAgentSessionEventRecovery { acknowledgeRelease: (sessionId) => deps.adapter.acknowledgeSessionRelease?.(sessionId) }, abortOpen: (sessionId) => - runtimeState.acquireAborts.abort(sessionId, 'provider exited while opening') + runtimeState.providerWaits.abort(sessionId, 'provider exited while opening') } } @@ -133,9 +132,6 @@ export class StructuredAgentSessionEventRecovery { * nothing restarts the child. The next send does, through the delivery loop, which also owns any * message still queued. */ async handle(event: StructuredAgentSessionLifecycleEvent): Promise { - if (event.type === 'started') { - return settleStructuredAgentSessionProviderStarted(this.context, event) - } if (event.type === 'options-reported') { return settleStructuredAgentSessionOptionsReported(this.context, event) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index a469af94120e..74c8fe0bcb2b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -37,12 +37,13 @@ const PROVIDER_STOPPED = let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost -let acquire: Mock +let spawn: Mock beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-failed-create-verdict-')) resetHostTestOperationIds() - acquire = vi.fn(async ({ fence, spawnToken }) => ({ + spawn = vi.fn(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -58,7 +59,12 @@ beforeEach(async () => { logger: createStructuredAgentSessionLogger(), store, adapter: { - acquire, + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn, releaseAcquisition: vi.fn(async () => true), dispatch: vi.fn(async () => ({ state: 'admitted' as const })), cancelTurn: vi.fn(async () => ({ cancelled: true })), @@ -91,7 +97,7 @@ describe('failed create owner verdict', () => { ])( 'answers %s as exited on the first call and its replay, and a new operation starts fresh', async (_case, failure, situation, message) => { - acquire.mockRejectedValueOnce(failure()) + spawn.mockRejectedValueOnce(failure()) const first = hostTestAttachParams(null) // The replay names the same details as the first answer: the ledger kept them beside the code, // and the verdict reaches released clients at the top level exactly as before. @@ -104,12 +110,12 @@ describe('failed create owner verdict', () => { await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() const retry = hostTestAttachParams(null) expect(retry.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId) await expect(host.attach(CALLER, retry)).resolves.toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') } ) @@ -126,7 +132,7 @@ describe('failed create owner verdict', () => { ])( 'answers %s as exited on the first call, in the shape its replay takes', async (_case, cause, situation, message) => { - acquire.mockRejectedValueOnce(new AgentSessionAcquisitionRootExitObservedError(cause())) + spawn.mockRejectedValueOnce(new AgentSessionAcquisitionRootExitObservedError(cause())) const first = hostTestAttachParams(null) // The replay names the same details as the first answer: the ledger kept them beside the code, // and the verdict reaches released clients at the top level exactly as before. @@ -139,17 +145,17 @@ describe('failed create owner verdict', () => { await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) } ) it('answers an acquisition refusal with its verdict directly', async () => { - acquire.mockRejectedValueOnce(new AgentSessionAcquisitionRefusal('not signed in')) + spawn.mockRejectedValueOnce(new AgentSessionAcquisitionRefusal('not signed in')) await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toEqual({ ok: false, @@ -163,7 +169,7 @@ describe('failed create owner verdict', () => { }) it('never claims exited when the failed attempt could not prove its process gone', async () => { - acquire.mockRejectedValueOnce(new AgentSessionAcquisitionExitUnprovenError(new Error('hung'))) + spawn.mockRejectedValueOnce(new AgentSessionAcquisitionExitUnprovenError(new Error('hung'))) const first = hostTestAttachParams(null) await expect(host.attach(CALLER, first)).rejects.toThrow() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts index e510916b0973..3e68042bd389 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts @@ -31,12 +31,12 @@ const EXIT_REASON = 'claude stream-json exited (code 1): claude: not signed in' let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost -let acquire: Mock +let spawn: Mock beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-failed-create-sink-')) resetHostTestOperationIds() - acquire = vi.fn(async ({ fence, spawnToken }) => ({ + spawn = vi.fn(async ({ fence, spawnToken }) => ({ process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -54,7 +54,12 @@ beforeEach(async () => { logger: createStructuredAgentSessionLogger(), store, adapter: { - acquire, + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn, releaseAcquisition: vi.fn(async () => true), dispatch: vi.fn(async () => ({ state: 'admitted' as const })), cancelTurn: vi.fn(async () => ({ cancelled: true })), @@ -81,7 +86,7 @@ describe('a create that fails after its child wrote through the unbound sink', ( ])( 'releases the sink when %s, so a new create and shutdown both proceed', async (_how, cause) => { - acquire.mockImplementationOnce(async ({ events }) => { + spawn.mockImplementationOnce(async ({ events }) => { // The published child's exit reached the translator before any journal was attached. events?.setActivity?.(null) throw cause @@ -99,7 +104,7 @@ describe('a create that fails after its child wrote through the unbound sink', ( ok: true }) await expect(host.flushAllStreamedEvents()).resolves.toBeUndefined() - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) } ) @@ -120,7 +125,7 @@ describe('a create that fails after its child wrote through the unbound sink', ( acquisitionGeneration: `generation-${exitedFence}` }) const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - acquire.mockImplementationOnce(async ({ events }) => { + spawn.mockImplementationOnce(async ({ events }) => { events?.setActivity?.(null) throw cause }) @@ -135,10 +140,10 @@ describe('a create that fails after its child wrote through the unbound sink', ( await expect(host.attach(CALLER, hostTestAttachParams(fence))).resolves.toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(3) + expect(spawn).toHaveBeenCalledTimes(3) // Only the adopted child's sink still takes writes: the failed attempt's closed with it, and // the exited generation's closed when the resume replaced it. - const [exited, failedAttempt, resumed] = acquire.mock.calls.map(([input]) => input.events) + const [exited, failedAttempt, resumed] = spawn.mock.calls.map(([input]) => input.events) expect(failedAttempt?.tryPublish?.()).toEqual({ accepted: false, reason: 'closed' }) expect(exited?.tryPublish?.()).toEqual({ accepted: false, reason: 'closed' }) expect(resumed?.tryPublish?.()).toEqual({ accepted: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index 6fad454e993f..569a4f2771a8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -20,7 +20,7 @@ import { createTrackedJournalOpener, openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' +import { StructuredAgentSessionProviderWaits } from './structured-agent-session-provider-waits' import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -191,7 +191,7 @@ function attachContext( adoptEventSink: () => undefined, probeOwner: async () => ({ outcome: 'pid-absent' }), discardEventSink: () => undefined, - acquireAborts: new StructuredAgentSessionAcquireAborts() + providerWaits: new StructuredAgentSessionProviderWaits() }, sessions, subscribers: { snapshot: () => undefined, publish: () => undefined }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index f308b7903983..39d0693660bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -48,7 +48,7 @@ const attachParams = (): AgentSessionAttachParams => hostTestAttachParams(null) let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost -let acquire: Mock +let spawn: Mock let answerPrompt: Mock let ordinal = 0 @@ -61,7 +61,12 @@ function adapter(): StructuredAgentSessionAdapter { } }) return { - acquire, + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn, releaseAcquisition: vi.fn(async () => true), dispatch, cancelTurn: vi.fn(async () => ({ cancelled: true })), @@ -72,7 +77,7 @@ function adapter(): StructuredAgentSessionAdapter { async function seedGroupedQuestion(): Promise<{ itemId: string; revision: number }> { const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 100 } - const events = acquire.mock.calls.at(-1)?.[0].events + const events = spawn.mock.calls.at(-1)?.[0].events if (!events) { throw new Error('seedGroupedQuestion requires an acquired session') } @@ -118,7 +123,8 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-wire-grouped-')) resetHostTestOperationIds() ordinal = 0 - acquire = vi.fn(async ({ fence }) => ({ + spawn = vi.fn(async ({ fence }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts index 1cf90b4749ed..9d29f2c3ec00 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-held-start.test-support.ts @@ -1,62 +1,52 @@ -// A fake adapter's children prove their start once published, except those a test holds `starting`. +// Fake protocol opens use the same awaited host path as production adapters. -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionLifecycleEvent -} from './structured-agent-session-adapter' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -type Acquire = StructuredAgentSessionAdapter['acquire'] - -/** A report read at this revision is never admitted, so the empty report persists nothing. */ -const NO_OPTIONS_REPORTED = -1 +type Spawn = StructuredAgentSessionAdapter['spawn'] +type Open = StructuredAgentSessionAdapter['open'] export type StructuredAgentSessionHeldStarts = { - /** Wraps an acquire so the child it answers stays `starting`. */ - held(acquire: Acquire): Acquire - /** Wraps the adapter's acquire: every child not held proves its start once published. */ - wrap(adapter: A): A - /** Settles `started` for a child, as its provider answering the handshake does. */ + held(spawn: Spawn): Spawn + wrap(adapter: A): A & StructuredAgentSessionAdapter prove(child: { sessionId: string; fence: number; acquisitionGeneration: string }): Promise clear(): void } export function structuredAgentSessionHeldStarts( - host: () => { handleAdapterEvent(event: StructuredAgentSessionLifecycleEvent): Promise } + _host: () => unknown ): StructuredAgentSessionHeldStarts { - const heldGenerations = new Set() - let generations = 0 - const prove: StructuredAgentSessionHeldStarts['prove'] = (child) => - host().handleAdapterEvent({ - type: 'started', - ...child, - reportedOptions: { model: '' }, - restoreSkippedOptions: [], - optionRevision: NO_OPTIONS_REPORTED - }) + const held = new Map void>() + const waiting = new Map>() + const prove: StructuredAgentSessionHeldStarts['prove'] = async (child) => { + held.get(child.acquisitionGeneration)?.() + await Promise.resolve() + } return { - held: (acquire) => async (input) => { - const acquired = await acquire(input) - const acquisitionGeneration = acquired.acquisitionGeneration ?? `held-start-${++generations}` - heldGenerations.add(acquisitionGeneration) - return { ...acquired, acquisitionGeneration } + held: (spawn) => async (input) => { + const acquired = await spawn(input) + const generation = acquired.acquisitionGeneration + waiting.set(generation, new Promise((resolve) => held.set(generation, resolve))) + return acquired }, wrap: (adapter) => { - const acquire: Acquire = async (input) => { - const acquired = await adapter.acquire(input) - const acquisitionGeneration = - acquired.acquisitionGeneration ?? `instant-start-${++generations}` - if (!heldGenerations.has(acquisitionGeneration)) { - void prove({ - sessionId: input.identity.sessionId, - fence: input.fence, - acquisitionGeneration - }) + const open: Open = async (input) => { + const generation = input.acquisitionGeneration + if (generation) { + await waiting.get(generation) + waiting.delete(generation) + held.delete(generation) } - return { ...acquired, acquisitionGeneration } + return adapter.open(input) } - return { ...adapter, acquire } + return { ...adapter, open } }, prove, - clear: () => heldGenerations.clear() + clear: () => { + for (const resolve of held.values()) { + resolve() + } + held.clear() + waiting.clear() + } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index ef168d7ce0b3..f370a3750177 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -26,7 +26,7 @@ import { structuredAgentSessionFailureWordsContext } from './structured-agent-se import { recoverStructuredRewind } from './structured-rewind-recovery' import { retireOutgrownStructuredAgentSessionChild } from './structured-agent-session-outgrown-child' import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' -import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' +import type { StructuredAgentSessionProviderWaits } from './structured-agent-session-provider-waits' export type StructuredAgentSessionConversationDelivery = { loop: StructuredAgentSessionDeliveryLoop @@ -53,7 +53,7 @@ export type StructuredAgentSessionConversationDelivery = { export function createStructuredAgentSessionConversationDelivery(input: { deps: StructuredAgentSessionHostDeps - acquireAborts: Pick + providerWaits: Pick sessions: Map serialize: (sessionId: string, task: () => Promise) => Promise trackStart: (start: Promise) => Promise @@ -125,7 +125,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { readChildWork: input.clientDelivery.readChildWork, stopping: input.clientDelivery.readStopping, now: () => deps.now?.() ?? Date.now(), - acquireAborts: input.acquireAborts + providerWaits: input.providerWaits }) const adoptOpened = async ( sessionId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts index 8669a3c92d24..2a9bb6756084 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts @@ -43,7 +43,12 @@ async function hostWithStartTimeLessChild(platform: NodeJS.Platform) { (_sessionId: string, generation: string) => process.live && generation === 'gen-1' ) const adapter: StructuredAgentSessionAdapter = { - acquire: async ({ fence }) => ({ + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: async ({ fence }) => ({ process: { hostId: 'local', pid: 4242, 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 6501040da1a7..1f78d3bd925c 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 @@ -127,7 +127,7 @@ export function cancelStructuredAgentSessionTurn( const { prompt } = params const stoppedPrePrompt = !prompt && params.turnId === undefined - ? abortAcquireForStop(context, caller, params.envelope, plan) + ? abortProviderWaitForStop(context, caller, params.envelope, plan) : false // A card's Cancel stops whatever the chat has in flight, as the Stop button does; it reaches the // Stop only for a card the live turn raised (`cancelStructuredAgentSessionPrompt`). @@ -147,7 +147,7 @@ export function cancelStructuredAgentSessionTurn( * queue is waiting on stops now, as a close's does, and the Stop's own step then finds no child. * Only a Stop admission would run now; one naming a turn is about a child already gone, so it * leaves a newer start alone. */ -function abortAcquireForStop( +function abortProviderWaitForStop( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, envelope: AgentSessionMutationEnvelope, @@ -165,7 +165,7 @@ function abortAcquireForStop( ) { return false } - return context.acquireAborts.abort(envelope.sessionId, 'stopped while starting') + return context.providerWaits.abort(envelope.sessionId, 'stopped while starting') } export function respondToStructuredAgentSessionPrompt( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts index cbae46441bfe..68bca5fb18c2 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 @@ -6,7 +6,7 @@ import { type StructuredAgentSessionSinkBarrier } from './structured-agent-session-event-sink' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' -import { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' +import { StructuredAgentSessionProviderWaits } from './structured-agent-session-provider-waits' import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' import { heldProviderChildReader, @@ -23,7 +23,7 @@ import { export class StructuredAgentSessionHostRuntimeState { private readonly eventSinks = new Map() - readonly acquireAborts = new StructuredAgentSessionAcquireAborts() + readonly providerWaits = new StructuredAgentSessionProviderWaits() readonly startupAttempts: StructuredAgentSessionStartupAttempts readonly optionRevisions = new StructuredAgentSessionOptionRevisions() private readonly leaseRenewer: StructuredAgentSessionLeaseRenewer @@ -43,7 +43,7 @@ export class StructuredAgentSessionHostRuntimeState { settled: recordAgentSessionStartup, expire: (expired) => { // Still acquiring, or applying the picks made meanwhile: either wait ends now. - this.acquireAborts.abort(expired.sessionId, new StructuredAgentSessionStartupExpiredError()) + this.providerWaits.abort(expired.sessionId, new StructuredAgentSessionStartupExpiredError()) if (expired.child !== null) { onStartupExpired?.(expired) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts index 152d84a3bee6..af8c683c75f1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.test.ts @@ -26,7 +26,7 @@ describe('structured agent-session host teardown', () => { runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop, - acquireAborts: { abortAll: () => {} } + providerWaits: { abortAll: () => {} } }, tasks: { drainAttaches: noop }, evictOwnedSessions: noop, @@ -75,7 +75,7 @@ describe('structured agent-session host teardown', () => { runtimeState: { stopLeaseRenewal: () => undefined, flushAllEventSinks: noop, - acquireAborts: { abortAll: () => {} } + providerWaits: { abortAll: () => {} } }, tasks: { drainAttaches: noop }, evictOwnedSessions: noop, @@ -109,7 +109,7 @@ describe('structured agent-session host teardown', () => { runtimeState: { stopLeaseRenewal: () => {}, flushAllEventSinks: flush, - acquireAborts: { abortAll: () => {} } + providerWaits: { abortAll: () => {} } }, tasks: { drainAttaches: cleaned }, evictOwnedSessions: cleaned, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index 759075639593..0b72a2262b8c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -60,7 +60,7 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { runtimeState: { stopLeaseRenewal: () => Promise | void flushAllEventSinks: () => Promise - acquireAborts: { abortAll: (reason: string) => void } + providerWaits: { abortAll: (reason: string) => void } } tasks: { drainAttaches: () => Promise } evictOwnedSessions: () => Promise @@ -87,7 +87,7 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { // Before the drain, which would otherwise wait out a start the provider never answers. { name: 'abort-acquires', - run: () => collaborators.runtimeState.acquireAborts.abortAll('closed by quit') + run: () => collaborators.runtimeState.providerWaits.abortAll('closed by quit') }, { name: 'drain-attaches', run: () => collaborators.tasks.drainAttaches() }, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts index f8f9745f8905..6473116c97ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts @@ -38,12 +38,18 @@ describe('abandoning a structured agent-session host', () => { reportEntered = resolve }) const adapter: StructuredAgentSessionAdapter = { - acquire: async ({ fence }) => { + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: async ({ fence }) => { if (gate) { reportEntered() await gate } return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index d4fb189ede09..2d7c7af336d3 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 @@ -67,7 +67,7 @@ let recoveryCapsule: TrackedTestRecoveryCapsule let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let log: ReturnType -let acquire: Mock +let spawn: Mock let releaseAcquisition: Mock> let dispatch: Mock let cancelTurn: Mock @@ -86,7 +86,12 @@ function accepted(): AgentSessionDispatchOutcome { function adapter(): StructuredAgentSessionAdapter { return startsWhenPublished( { - acquire, + spawn, + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), releaseAcquisition, dispatch: async (input) => { await input.beforeDispatch?.() @@ -109,7 +114,7 @@ async function attach(): Promise { /** Emits a pending approval through the acquired provider sink. */ async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revision: number }> { const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 99 } - const events = acquire.mock.calls.at(-1)?.[0].events + const events = spawn.mock.calls.at(-1)?.[0].events if (!events) { throw new Error('seedApproval requires an acquired session') } @@ -138,7 +143,8 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-wire-host-')) resetHostTestOperationIds() ordinal = 0 - acquire = vi.fn(async ({ fence }) => ({ + spawn = vi.fn(async ({ fence }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -213,7 +219,7 @@ export function hostTestState() { host, /** Every entry the beforeEach host logged; a host a test builds itself logs elsewhere. */ log, - acquire, + spawn, releaseAcquisition, dispatch, cancelTurn, 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 c9674540007f..6876ade6ddd5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -69,7 +69,7 @@ export type StructuredAgentSessionChildClose = { /** The provider process behind a conversation. Written only in * `structured-agent-session-provider-child`. */ export type StructuredAgentSessionProviderChild = StructuredAgentSessionProviderChildIdentity & { - /** Every acquired child is `starting` until the host settles its `started` event; only then is + /** Every acquired child is `starting` until the host settles its open result; only then is * it handed input and are its reported options fact. */ phase: StructuredAgentSessionProviderChildPhase /** The queued message whose delivery started this child, fixed when the start is made; absent diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index db1d0f7054d3..e22c85a94d9d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -36,7 +36,7 @@ import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router- let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost -let acquire: Mock +let spawn: Mock let releaseAcquisition: Mock> let dispatch: Mock let cancelTurn: Mock @@ -48,7 +48,7 @@ beforeEach(() => { root, store, host, - acquire, + spawn, releaseAcquisition, dispatch, cancelTurn, @@ -124,9 +124,10 @@ describe('attach', () => { }) it('retires a failed proved acquisition before admitting a fresh operation', async () => { - const acquire = vi - .fn() + const spawn = vi + .fn() .mockImplementationOnce(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -142,6 +143,7 @@ describe('attach', () => { } })) .mockImplementation(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -160,7 +162,7 @@ describe('attach', () => { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { ...adapter(), acquire }, + adapter: { ...adapter(), spawn }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', @@ -182,7 +184,7 @@ describe('attach', () => { expect(await host.attach(CALLER, params)).toEqual(refused) const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 expect(await host.attach(CALLER, ensureParams(releasedFence))).toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') expect(releaseAcquisition).toHaveBeenCalledWith({ sessionId: SESSION }) }) @@ -203,7 +205,7 @@ describe('attach', () => { it('drains writes captured by the old journal before acquiring its replacement', async () => { const record = await attach() - const events = acquire.mock.calls[0]?.[0].events + const events = spawn.mock.calls[0]?.[0].events const oldJournal = ( host as unknown as { sessions: Map } ).sessions.get(SESSION)!.journal @@ -228,11 +230,11 @@ describe('attach', () => { const replacement = host.attach(CALLER, ensureParams(released.lease.runtimeFence)) await new Promise((resolve) => setImmediate(resolve)) - expect(acquire).toHaveBeenCalledTimes(1) + expect(spawn).toHaveBeenCalledTimes(1) appendGate.resolve() await expect(replacement).resolves.toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) }) }) @@ -394,7 +396,7 @@ describe('respondToPrompt', () => { turnId: 'c', ordinal: 1 } - acquire.mock.calls.at(-1)?.[0].events?.appendItem( + spawn.mock.calls.at(-1)?.[0].events?.appendItem( identity, { kind: 'approval', @@ -618,7 +620,7 @@ describe('restart', () => { const body = hostTestMessage('persisted conversation') await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) await reboot(async () => ({ outcome: 'indeterminate', reason: 'read does not need ownership' })) - acquire.mockClear() + spawn.mockClear() const listRecords = vi.spyOn(store, 'listRecords') await host.restoreReadableSessions() @@ -630,7 +632,7 @@ describe('restart', () => { ]) const history = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok && history.page.items).not.toHaveLength(0) - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(listRecords).toHaveBeenCalledTimes(restoreReads) }) @@ -646,7 +648,7 @@ describe('restart', () => { } })) await reboot(async () => ({ outcome: 'pid-absent' })) - acquire.mockClear() + spawn.mockClear() await host.restoreReadableSessions() // The recovery stage clears on evidence at startup; the child comes back only once work @@ -654,7 +656,7 @@ describe('restart', () => { const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 expect(await host.attach(CALLER, ensureParams(fence))).toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeKind: 'native', claimStatus: 'live', @@ -674,11 +676,11 @@ describe('restart', () => { await host.restoreReadableSessions() const started = Promise.withResolvers() const release = Promise.withResolvers() - const settled = acquire.getMockImplementation() + const settled = spawn.getMockImplementation() if (!settled) { - throw new Error('missing acquire implementation') + throw new Error('missing spawn implementation') } - acquire.mockImplementationOnce(async (input) => { + spawn.mockImplementationOnce(async (input) => { started.resolve() await release.promise return settled(input) @@ -715,12 +717,12 @@ describe('restart', () => { {}, stopOwnerProcess ) - acquire.mockClear() + spawn.mockClear() expect(await host.attach(CALLER, ensureParams(await staleFenceFrom(held)))).toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() // An unverifiable pid may already belong to an unrelated process. expect(stopOwnerProcess).not.toHaveBeenCalled() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index c8c5efd1eae9..dec391b54590 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 @@ -116,7 +116,7 @@ export class StructuredAgentSessionHost { }) this.conversationDelivery = conversation.createStructuredAgentSessionConversationDelivery({ deps, - acquireAborts: this.runtimeState.acquireAborts, + providerWaits: this.runtimeState.providerWaits, sessions: this.sessions, serialize: (sessionId, task) => this.serialize(sessionId, task), // Quit drains a delivery start before it evicts, so the child it produces is stopped. @@ -292,7 +292,7 @@ export class StructuredAgentSessionHost { 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, + providerWaits: this.runtimeState.providerWaits, optionRevisions: this.runtimeState.optionRevisions, now: () => this.now() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index 6e7cb6b95b31..a303def1d4c3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -32,7 +32,7 @@ afterEach(async () => { }) function providerEvents() { - const events = rig.adapter.acquire.mock.calls.at(-1)?.[0].events + const events = rig.adapter.spawn.mock.calls.at(-1)?.[0].events if (!events) { throw new Error('no provider child was started') } @@ -89,7 +89,7 @@ describe('the idle sweep', () => { await expect(rig.host.readOptions(SESSION)).resolves.toMatchObject({ current: {} }) const sent = await rig.host.send(CALLER, restTestSend('carry on', fence())) expect(sent.ok).toBe(true) - await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(rig.adapter.spawn).toHaveBeenCalledTimes(2)) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) await vi.waitFor(() => expect(readerSaw(reader.events).texts).toContain('carry on')) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts index 61b62598beeb..90c692b9d152 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-instant-start.test-support.ts @@ -1,35 +1,17 @@ -// A fake adapter whose child proves its start the moment it is published, as a provider with an -// instant handshake does. Every acquire answers before its handshake now, so a fake that should -// behave like a ready provider reports `started` itself; the host queues it behind the attach -// that publishes the child. +// Fake providers publish their generation and return protocol facts from host-awaited open. -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionLifecycleEvent -} from './structured-agent-session-adapter' - -/** A report read at this revision is never admitted, so the fake's empty report persists nothing, - * as a fake with no options to report never did. */ -const NO_OPTIONS_REPORTED = -1 +import type { StructuredAgentSessionAdapter } 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}` - void host().handleAdapterEvent({ - type: 'started', - sessionId: input.identity.sessionId, - fence: input.fence, - acquisitionGeneration, - reportedOptions: { model: '' }, - restoreSkippedOptions: [], - optionRevision: NO_OPTIONS_REPORTED - }) - return { ...acquired, acquisitionGeneration } + _host: () => unknown +): A & StructuredAgentSessionAdapter { + const spawn: StructuredAgentSessionAdapter['spawn'] = async (input) => { + const acquired = await adapter.spawn(input) + if (!acquired.acquisitionGeneration) { + throw new Error('test provider did not publish its generation') + } + return acquired } - return { ...adapter, acquire } + return { ...adapter, spawn } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts index ee3f3901ea2c..f903f60243ca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts @@ -146,18 +146,18 @@ it('continues when the restart offer cannot be read', async () => { }) it('answers once Orca accepted it, while the agent is still starting', async () => { - const { host, acquire, dispatch } = await interruptedRestart() + const { host, spawn, dispatch } = await interruptedRestart() const turnItemId = await cutTurn(host) - const start = acquire.getMockImplementation() + const start = spawn.getMockImplementation() if (!start) { - throw new Error('the harness acquire has no implementation') + throw new Error('the harness spawn has no implementation') } let finishStarting!: () => void const starting = new Promise((resolve) => { finishStarting = resolve }) // An agent start slower than a paired client's wait for the answer. - acquire.mockImplementationOnce(async (input) => { + spawn.mockImplementationOnce(async (input) => { await starting return start(input) }) @@ -171,9 +171,9 @@ it('answers once Orca accepted it, while the agent is still starting', async () }) it('says in the chat when the agent cannot start after the click was answered', async () => { - const { host, acquire } = await interruptedRestart() + const { host, spawn } = await interruptedRestart() const turnItemId = await cutTurn(host) - acquire.mockRejectedValueOnce(new Error('the agent could not start')) + spawn.mockRejectedValueOnce(new Error('the agent could not start')) const answer = await host.restartResume.continueInterrupted(SESSION, turnItemId) 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 96370376918c..5fe7dd410e24 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -104,7 +104,13 @@ beforeEach(async () => { store, adapter: startsWhenPublished( { - acquire: vi.fn(async ({ fence }) => ({ + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi.fn(async ({ fence }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts index cd26b96e5cd1..a21235e1c852 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-send-after-create.test.ts @@ -2,8 +2,8 @@ // // Both shipped adapters look the session up in a live map and throw when it is absent // (`claude-structured-session-adapter.ts:331-337`, `codex-structured-session-state.ts`'s -// `requireLiveCodexSession`), and both populate that map as the last step of `acquire` -// (`claude-structured-session-acquisition.ts:278`, `codex-structured-session-acquire.ts:275`). +// `requireLiveCodexSession`), and both populate that map as the last step of `spawn` +// (`claude-structured-session-acquisition.ts:278`, `codex-structured-session-spawn.ts:275`). // So the question is purely one of ordering, and that is what these model. import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -19,13 +19,13 @@ import { HOST_TEST_NOW as NOW } from './structured-agent-session-host-test-data' let host: StructuredAgentSessionHost -/** Mirrors both adapters: `acquire` publishes into the live map, `dispatch` throws without it. */ +/** Mirrors both adapters: `spawn` publishes into the live map, `dispatch` throws without it. */ function modelAdapterLiveness(registerOnAcquire: boolean): void { - const { acquire, dispatch } = hostTestState() + const { spawn, dispatch } = hostTestState() const live = new Set() - const spawn = acquire.getMockImplementation()! - acquire.mockImplementation(async (input) => { - const acquisition = await spawn(input) + const spawnChild = spawn.getMockImplementation()! + spawn.mockImplementation(async (input) => { + const acquisition = await spawnChild(input) if (registerOnAcquire) { live.add(input.identity.sessionId) } @@ -80,7 +80,7 @@ beforeEach(() => { }) describe('a launch prompt sent the instant the create resolves', () => { - it('reaches a live provider, because attach returns only after acquire published it', async () => { + it('reaches a live provider, because attach returns only after spawn published it', async () => { modelAdapterLiveness(true) await expect(launchAndDeliver()).resolves.toEqual({ @@ -92,7 +92,7 @@ describe('a launch prompt sent the instant the create resolves', () => { // Positive control: the assertion above is only evidence if this arm can fail, and it names // the user-visible symptom precisely — a committed row that no agent will ever answer. - it('would commit a row and strand it if acquire ever resolved before publication', async () => { + it('would commit a row and strand it if spawn ever resolved before publication', async () => { modelAdapterLiveness(false) await expect(launchAndDeliver()).resolves.toEqual({ 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 d32ea5f05a56..7483dc95eda9 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 @@ -39,7 +39,7 @@ function eventually(assertion: () => void | Promise): Promise { let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost -let acquire: Mock +let spawn: Mock let probe: Mock<() => Promise> let stopOwnerProcess: Mock<(pid: number, signal: 'SIGTERM' | 'SIGKILL') => void> let dispatch: Mock @@ -51,7 +51,12 @@ function openHost(): void { store, adapter: startsWhenPublished( { - acquire, + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn, closeSession: vi.fn(async () => true), releaseAcquisition: vi.fn(async () => true), dispatch, @@ -63,7 +68,7 @@ function openHost(): void { ), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + mintSpawnToken: () => `spawn-${spawn.mock.calls.length}`, now: () => NOW, probeOwner: probe, stopOwnerProcess @@ -84,7 +89,7 @@ async function persistFromOlderBuild(lease: OlderBuildLease): Promise { // Over the attached owner: the older build's stage or terminal owner kept it from releasing. await writeOlderBuildLease(root, SESSION, { ...attached, ...lease }) store = await openTestAgentSessionRecordStore(root) - acquire.mockClear() + spawn.mockClear() openHost() } @@ -128,7 +133,8 @@ beforeEach(async () => { probe = vi.fn(async () => ({ outcome: 'pid-absent' as const })) stopOwnerProcess = vi.fn() dispatch = vi.fn(async () => ({ state: 'admitted' as const })) - acquire = vi.fn(async ({ fence, spawnToken }) => ({ + spawn = vi.fn(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -180,7 +186,7 @@ describe('a record an older build left mid terminal handoff', () => { expect(store.getRecord(SESSION)?.lease).not.toHaveProperty('settlementRetryRequired') expect(await delivered('after the upgrade')).toMatchObject({ dispatchState: 'pending' }) expect(dispatch).toHaveBeenCalledOnce() - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeKind: 'native', claimStatus: 'live', @@ -276,7 +282,7 @@ describe('a record an older build left mid terminal handoff', () => { }) expect(dispatch).not.toHaveBeenCalled() expect(stopOwnerProcess).not.toHaveBeenCalled() - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() // The user closes the terminal; the next send's start proves it gone and the chat takes over. probe.mockResolvedValue({ outcome: 'pid-absent' }) 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 d00c72f22c5b..7a77e6abb7bd 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 @@ -71,7 +71,12 @@ beforeEach(async () => { store, adapter: startsWhenPublished( { - acquire: async (input) => { + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: async (input) => { events = input.events return { process: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 8a077cbfe2dc..8f775176d8ef 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 @@ -13,7 +13,7 @@ import { } from './structured-agent-session-mutation-admission' 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 { StructuredAgentSessionProviderWaits } from './structured-agent-session-provider-waits' import type { StructuredAgentSessionOptionRevisions } from './structured-agent-session-option-revisions' import type { StructuredAgentSessionStartupAttempts } from './structured-agent-session-startup-attempt' import type { @@ -50,7 +50,7 @@ export type StructuredAgentSessionMutationContext = { wakeQueuedDrain?: (sessionId: string) => void /** The provider wait each session's serialize is on (a start, an option write), which a caller * outside that serialize aborts. */ - acquireAborts: Pick + providerWaits: Pick /** Moved by every pick a running child takes, so a report it read before is never persisted. */ optionRevisions: Pick now: () => number diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts index 9b03b07ed501..26317e892ebc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts @@ -39,7 +39,7 @@ afterEach(async () => { }) function providerEvents() { - const events = hostTestState().acquire.mock.calls.at(-1)?.[0].events + const events = hostTestState().spawn.mock.calls.at(-1)?.[0].events if (!events) { throw new Error('no acquired provider') } @@ -181,11 +181,11 @@ it('ends a turn whose provider output would not read back as failed, and the nex const closeSession = vi.fn(async () => true) Object.assign(hostTestState().host.deps.adapter, { closeSession }) // A provider that names its generation, as the real adapters do, so its stop settles the turn. - const { acquire } = hostTestState() - const acquired = acquire.getMockImplementation()! - acquire.mockImplementation(async (input) => ({ + const { spawn } = hostTestState() + const acquired = spawn.getMockImplementation()! + spawn.mockImplementation(async (input) => ({ ...(await acquired(input)), - acquisitionGeneration: `generation-${acquire.mock.calls.length}` + acquisitionGeneration: `generation-${spawn.mock.calls.length}` })) await attach() emit(1, { @@ -214,7 +214,7 @@ it('ends a turn whose provider output would not read back as failed, and the nex ]) ) expect(await send(host, 'the next message')).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(hostTestState().acquire).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(hostTestState().spawn).toHaveBeenCalledTimes(2)) const reopened = await relaunch() expect(await historyBodies(reopened)).toContainEqual( 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 443b2726a689..4467a8caf567 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 @@ -31,7 +31,7 @@ let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let router: StructuredAgentSessionAdapter -let acquire: Mock +let spawn: Mock let closeNativeSession: Mock> let activeModel: string let activeEffort: string | null @@ -54,34 +54,22 @@ function envelope(method: string, fields: Record): AgentSession } function adapter(): StructuredAgentSessionAdapter { - acquire = vi.fn(async ({ fence, spawnToken, options, identity }) => { + spawn = vi.fn(async ({ fence, spawnToken, options }) => { activeModel = options?.model ?? DEFAULT_MODEL activeEffort = options?.effort ?? null - const acquisitionGeneration = `generation-${acquire.mock.calls.length}` - // The handshake answers at once, reporting the options the child runs with. - void host.handleAdapterEvent({ - type: 'started', - sessionId: identity.sessionId, - fence, - acquisitionGeneration, - reportedOptions: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) }, - restoreSkippedOptions: [], - optionRevision: host - .collaboratorsForTests() - .runtimeState.optionRevisions.current(identity.sessionId) - }) + const acquisitionGeneration = `generation-${spawn.mock.calls.length}` return { acquisitionGeneration, process: { hostId: 'local', - pid: 4200 + acquire.mock.calls.length, + pid: 4200 + spawn.mock.calls.length, processStartTimeMs: NOW, spawnToken }, link: { linkId: `native-link-${fence}`, handle: codexProviderHandle(THREAD), - origin: acquire.mock.calls.length === 1 ? 'created' : 'resumed', + origin: spawn.mock.calls.length === 1 ? 'created' : 'resumed', mintedAtFence: fence, observedAt: NOW } @@ -93,7 +81,12 @@ function adapter(): StructuredAgentSessionAdapter { }) return { supportsLocation: () => true, - acquire, + open: async () => ({ + reportedOptions: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) }, + restoreSkippedOptions: [], + optionRevision: 0 + }), + spawn, dispatch: vi.fn(async () => { dispatchedModels.push(activeModel) return { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts index 1f81a840b40f..9d78b0895cdb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts @@ -44,7 +44,7 @@ describe('a chat permission mode', () => { it('starts a chat saved before modes existed as Ask, and reads it back as Ask', async () => { await foundRestTestChat(rig) - expect(rig.adapter.acquire).toHaveBeenCalledWith( + expect(rig.adapter.spawn).toHaveBeenCalledWith( expect.objectContaining({ permissionMode: 'ask' }) ) expect((await rig.host.readOptions(SESSION)).current.permissionMode).toBe('ask') @@ -80,14 +80,14 @@ describe('a chat permission mode', () => { expect((await rig.host.send(CALLER, restTestSend('after the change', fence()))).ok).toBe(true) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, 'evict') - expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) - expect(rig.adapter.acquire).toHaveBeenLastCalledWith( + expect(rig.adapter.spawn).toHaveBeenCalledTimes(2) + expect(rig.adapter.spawn).toHaveBeenLastCalledWith( expect.objectContaining({ permissionMode: 'auto' }) ) expect((await rig.host.send(CALLER, restTestSend('and again', fence()))).ok).toBe(true) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(3)) - expect(rig.adapter.acquire).toHaveBeenCalledTimes(2) + expect(rig.adapter.spawn).toHaveBeenCalledTimes(2) }) it('keeps the running agent for a send while its reply runs', async () => { @@ -99,7 +99,7 @@ describe('a chat permission mode', () => { expect((await rig.host.send(CALLER, restTestSend('mid-reply', fence()))).ok).toBe(true) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) expect(rig.adapter.closeSession).not.toHaveBeenCalled() - expect(rig.adapter.acquire).toHaveBeenCalledTimes(1) + expect(rig.adapter.spawn).toHaveBeenCalledTimes(1) holds.mockReturnValue(false) }) }) 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..95d2ca33e241 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 @@ -78,7 +78,12 @@ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { throw new Error('not reached before a spawn') } const adapter: StructuredAgentSessionAdapter = { - acquire: async () => { + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: async () => { throw thrown }, dispatch: unused, 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..e1d98c18da8e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -68,20 +68,33 @@ function attachParams( } } +function adapterForSpawn( + spawn: StructuredAgentSessionAdapter['spawn'] +): StructuredAgentSessionAdapter { + return { + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn, + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } +} + describe('processless structured session reservation', () => { it('refuses an adapter that declares no create support before reserving a lease', async () => { root = await mkdtemp(join(tmpdir(), 'orca-unsupported-attach-')) const store = await openTestAgentSessionRecordStore(root) const reserveOwner = vi.spyOn(store, 'reserveOwner') - const acquire = vi.fn() + const spawn = vi.fn() const adapter = { - supportsCreate: vi.fn(() => false), - acquire, - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } as unknown as StructuredAgentSessionAdapter + ...adapterForSpawn(spawn), + supportsCreate: vi.fn(() => false) + } await expect( performAttach({ @@ -107,7 +120,7 @@ describe('processless structured session reservation', () => { refusal: { code: 'structured_agent_session_unsupported' } }) expect(reserveOwner).not.toHaveBeenCalled() - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() }) it('refuses a replay when adapter support drifts after durable reservation', async () => { @@ -118,10 +131,11 @@ describe('processless structured session reservation', () => { .mockReturnValueOnce(true) .mockReturnValueOnce(true) .mockReturnValueOnce(false) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attach reaches only `supportsCreate` and `acquire` on this fake adapter. const adapter = { + ...adapterForSpawn(vi.fn()), supportsCreate, - acquire: vi.fn(async ({ fence, spawnToken }) => ({ + spawn: vi.fn(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, link: { linkId: 'link-1', @@ -131,7 +145,7 @@ describe('processless structured session reservation', () => { observedAt: NOW } })) - } as unknown as StructuredAgentSessionAdapter + } const input = { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), @@ -158,7 +172,7 @@ describe('processless structured session reservation', () => { refusal: { code: 'structured_agent_session_unsupported' } }) expect(supportsCreate).toHaveBeenCalledTimes(3) - expect(adapter.acquire).toHaveBeenCalledOnce() + expect(adapter.spawn).toHaveBeenCalledOnce() }) it('releases a new reservation when support drifts before acquisition', async () => { @@ -170,8 +184,8 @@ describe('processless structured session reservation', () => { .mockReturnValueOnce(false) .mockReturnValueOnce(true) .mockReturnValueOnce(true) - const acquire = vi.fn() - const adapter = { supportsCreate, acquire } as unknown as StructuredAgentSessionAdapter + const spawn = vi.fn() + const adapter = { ...adapterForSpawn(spawn), supportsCreate } const input = { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), @@ -197,7 +211,7 @@ describe('processless structured session reservation', () => { refusal: { code: 'structured_agent_session_unsupported' } }) - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, @@ -213,17 +227,17 @@ describe('processless structured session reservation', () => { ok: false, refusal: { code: 'structured_agent_session_unsupported' } }) - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() }) it('settles a pre-spawn failure and its processless evidence in one durable transaction', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-reservation-')) const store = await openTestAgentSessionRecordStore(root) - const adapter = { - acquire: vi.fn(async () => { + const adapter = adapterForSpawn( + vi.fn(async () => { throw new AgentSessionPreSpawnError(new Error('workspace no longer exists')) }) - } as unknown as StructuredAgentSessionAdapter + ) const settlement = vi.spyOn(store, 'settleFailedAcquisition') await expect( @@ -274,12 +288,13 @@ describe('processless structured session reservation', () => { it('does not rerun a settled pre-spawn failure and admits a fresh operation', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-retry-')) const store = await openTestAgentSessionRecordStore(root) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attach reaches only the members this fake adapter defines. const adapter = { - acquire: vi - .fn() + ...adapterForSpawn(vi.fn()), + spawn: vi + .fn() .mockRejectedValueOnce(new AgentSessionPreSpawnError(new Error('launch not ready'))) .mockImplementationOnce(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -295,7 +310,7 @@ describe('processless structured session reservation', () => { } })), releaseAcquisition: vi.fn(async () => true) - } as unknown as StructuredAgentSessionAdapter + } const input = { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), @@ -323,7 +338,7 @@ describe('processless structured session reservation', () => { ok: false, refusal: { code: 'agent_session_operation_invalid' } }) - expect(adapter.acquire).toHaveBeenCalledOnce() + expect(adapter.spawn).toHaveBeenCalledOnce() await expect( performAttach({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts index 87ad1fc95a11..47c670fe5fe7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts @@ -43,11 +43,11 @@ let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let answerPrompt: Mock -let acquire: Mock +let spawn: Mock let serial = 0 beforeEach(() => { - ;({ root, store, host, answerPrompt, acquire } = hostTestState()) + ;({ root, store, host, answerPrompt, spawn } = hostTestState()) }) function opId(at = NOW): string { @@ -99,7 +99,7 @@ async function reviseFromProvider(itemId: string): Promise { if (body.kind !== 'approval') { throw new Error('expected an approval') } - const events = acquire.mock.calls.at(-1)?.[0].events + const events = spawn.mock.calls.at(-1)?.[0].events events?.appendItem( PROMPT, { @@ -314,7 +314,7 @@ describe('a prompt answer', () => { it('replays a grouped answer whole after a rewind, free text included', async () => { await attach() const question = { ...PROMPT, ordinal: 100 } - acquire.mock.calls.at(-1)?.[0].events?.appendItem( + spawn.mock.calls.at(-1)?.[0].events?.appendItem( question, { kind: 'question', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel-repeat.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel-repeat.test.ts index 6d73178c952c..b280b31bbc0c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel-repeat.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel-repeat.test.ts @@ -12,11 +12,11 @@ import { import { HOST_TEST_THREAD as THREAD } from './structured-agent-session-host-test-data' let host: StructuredAgentSessionHost -let acquire: Mock +let spawn: Mock let cancelTurn: Mock beforeEach(() => { - ;({ host, acquire, cancelTurn } = hostTestState()) + ;({ host, spawn, cancelTurn } = hostTestState()) }) describe('a prompt Cancel pressed again', () => { @@ -28,7 +28,7 @@ describe('a prompt Cancel pressed again', () => { cancelTurn.mockImplementationOnce(async () => { await first.promise // As a provider does: interrupting the turn cancels the prompt it was waiting on. - acquire.mock.calls.at(-1)?.[0].events?.appendItem( + spawn.mock.calls.at(-1)?.[0].events?.appendItem( identity, { kind: 'approval', 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 87e689ec91c3..93c06dca35e9 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 @@ -54,7 +54,7 @@ const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost -let acquire: Mock +let spawn: Mock let dispatch: Mock let adapterExtras: Partial @@ -63,10 +63,10 @@ function eventually(assertion: () => void | Promise): Promise { } function generation(): string { - return `generation-${acquire.mock.calls.length}` + return `generation-${spawn.mock.calls.length}` } -const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ +const spawnChild: StructuredAgentSessionAdapter['spawn'] = async ({ fence, spawnToken }) => ({ process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, acquisitionGeneration: generation(), link: { @@ -98,11 +98,18 @@ function startHost(): void { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined), ...adapterExtras, - acquire + spawn, + open: + adapterExtras.open ?? + (async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + })) }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + mintSpawnToken: () => `spawn-${spawn.mock.calls.length}`, now: () => NOW }) } @@ -117,7 +124,7 @@ beforeEach(async () => { resetHostTestOperationIds() adapterExtras = {} starts.clear() - acquire = vi.fn(spawnChild) + spawn = vi.fn(spawnChild) dispatch = vi.fn(async (input) => ({ state: 'accepted' as const, providerIdentity: { @@ -216,17 +223,9 @@ function currentChild() { return { sessionId: SESSION, fence: child.fence, acquisitionGeneration: child.generation } } -/** The current child proves its start, as a publish-first provider's `started` event does. */ +/** Release this child's held protocol open. */ function prove(): Promise { - const { acquisitionGeneration, ...child } = currentChild() - return host.handleAdapterEvent({ - type: 'started', - ...child, - acquisitionGeneration, - reportedOptions: { model: 'default' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) - }) + return starts.prove(currentChild()) } function exit(child: ReturnType, reason: string, startupUnproven?: true) { @@ -291,7 +290,7 @@ describe('Stop on a child still proving its start', () => { it('ends the child and keeps the conversation and its readers (R1)', async () => { adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) + spawn.mockImplementationOnce(spawnStartingChild) // Held for the starting child, which never proves its start. const first = await accept('hello') const journal = conversation()?.journal @@ -399,7 +398,7 @@ describe('a published child that dies while it proves its start', () => { it('leaves one error row keyed by the start, and every message held for it rejected with it (R2)', async () => { await restartHost() - acquire.mockImplementation(spawnStartingChild) + spawn.mockImplementation(spawnStartingChild) // Both are held for the starting child, which never proves its start. const first = await accept('first') const events = await subscribe() @@ -426,7 +425,7 @@ describe('a published child that dies while it proves its start', () => { }) } expect(rejectedIn(events, second)).toBe(true) - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) expect(dispatch).not.toHaveBeenCalled() }) }) @@ -444,7 +443,7 @@ describe('a start another operation made that dies while a sent message waits on it("is the message's own failed start: one error row, the message rejected, no second start (R2)", async () => { await restartHost() - acquire.mockImplementation(spawnStartingChild) + spawn.mockImplementation(spawnStartingChild) // An operation that needs the agent starts a child that has not proven its start. await startForOperation() const operationChild = currentChild() @@ -474,13 +473,13 @@ describe('a start another operation made that dies while a sent message waits on ]) expect(rejectedIn(events, id)).toBe(true) // The setup's child and the operation's: nothing started again into the same failure. - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) expect(dispatch).not.toHaveBeenCalled() }) it('leaves a message sent after that start failed to a fresh start (R2)', async () => { await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) + spawn.mockImplementationOnce(spawnStartingChild) await startForOperation() await exit(currentChild(), EXIT, true) expect(await statusRows()).toHaveLength(1) @@ -488,7 +487,7 @@ describe('a start another operation made that dies while a sent message waits on const id = await accept('after the failure') await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - expect(acquire).toHaveBeenCalledTimes(3) + expect(spawn).toHaveBeenCalledTimes(3) }) it('starts again for a message whose proven child crashed: only a failed start settles it (R2)', async () => { @@ -504,12 +503,12 @@ describe('a start another operation made that dies while a sent message waits on await eventually(async () => expect((await submission(id))?.dispatchState).not.toBe('pending')) expect((await submission(id))?.dispatchState).toBe('accepted') - expect(acquire).toHaveBeenCalledTimes(3) + expect(spawn).toHaveBeenCalledTimes(3) }) it('hands over to a child started since the failed one, not to the failure (R2)', async () => { await restartHost() - acquire.mockImplementation(spawnStartingChild) + spawn.mockImplementation(spawnStartingChild) await startForOperation() const params = sendParams('hello') @@ -525,16 +524,15 @@ describe('a start another operation made that dies while a sent message waits on await eventually(async () => expect((await submission(id))?.dispatchState).not.toBe('pending')) expect((await submission(id))?.dispatchState).toBe('accepted') - expect(acquire).toHaveBeenCalledTimes(3) + expect(spawn).toHaveBeenCalledTimes(3) }) }) -describe('a child that ends before its message is handed over', () => { - it('starts one child for the message, then rejects it and stops (R2)', async () => { +describe('a child that ends before open completes', () => { + it('starts one child for the message, then rejects its start and stops (R2)', async () => { await restartHost() - // The child the loop starts proves its start, then exits as its start step returns: that exit, - // asked for on the lane then, lands before the handover step. - acquire.mockImplementation(async (input) => { + // The exit lands before the host's awaited open can establish readiness. + spawn.mockImplementation(async (input) => { const child = await spawnStartingChild(input) const { acquisitionGeneration } = child if (acquisitionGeneration === undefined) { @@ -561,11 +559,11 @@ 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.', + reason: 'Codex stopped before it finished starting. Send your message to try again.', rejection: failure }) expect(await statusRows()).toEqual([ @@ -573,7 +571,7 @@ describe('a child that ends before its message is handed over', () => { ]) expect(rejectedIn(events, id)).toBe(true) await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) expect(dispatch).not.toHaveBeenCalled() }) }) @@ -582,7 +580,7 @@ describe('another child indexed after a Stop ended the starting one', () => { it('hands the next message to the child now there once it proves its start (R2)', async () => { adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() - acquire.mockImplementation(spawnStartingChild) + spawn.mockImplementation(spawnStartingChild) // Held for the starting child, which never proves its start. const first = await accept('first') await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) @@ -611,7 +609,7 @@ describe('another child indexed after a Stop ended the starting one', () => { phase: 'ready' }) expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([secondId]) - expect(acquire).toHaveBeenCalledTimes(3) + expect(spawn).toHaveBeenCalledTimes(3) expect(await submission(first)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) }) }) @@ -670,14 +668,14 @@ describe('a quit with a message still queued', () => { }) const id = await accept('hello', { person: true }) await eventually(() => expect(recovering).toHaveBeenCalled()) - const acquiresBefore = acquire.mock.calls.length + const acquiresBefore = spawn.mock.calls.length const quit = host.flushAllStreamedEvents() starting.resolve() await quit // Quit aborts the start, so nothing is launched behind it and nothing is left to stop. - expect(acquire).toHaveBeenCalledTimes(acquiresBefore) + expect(spawn).toHaveBeenCalledTimes(acquiresBefore) expect(closeSession).not.toHaveBeenCalled() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', @@ -692,7 +690,7 @@ describe('a quit with a message still queued', () => { describe('a send whose start failed, sent again with the same operation id', () => { it('replays the recorded rejection and starts no second agent (R2)', async () => { - acquire.mockRejectedValueOnce(new Error('spawn claude ENOENT')) + spawn.mockRejectedValueOnce(new Error('spawn claude ENOENT')) const fenceBefore = store.getRecord(SESSION)!.lease.runtimeFence const params = sendParams('hello') // A failed start is a rejected message, never a refused send. @@ -704,7 +702,7 @@ describe('a send whose start failed, sent again with the same operation id', () await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) // The start moved the fence while the message was out. expect(store.getRecord(SESSION)!.lease.runtimeFence).toBeGreaterThan(fenceBefore) - const starts = acquire.mock.calls.length + const starts = spawn.mock.calls.length const replay = await host.send(CALLER, params) @@ -714,7 +712,7 @@ describe('a send whose start failed, sent again with the same operation id', () value: { submission: { clientMessageId: id, dispatchState: 'rejected' } } }) await settleLoop() - expect(acquire).toHaveBeenCalledTimes(starts) + expect(spawn).toHaveBeenCalledTimes(starts) expect(dispatch).not.toHaveBeenCalled() }) }) @@ -729,7 +727,7 @@ describe('how a stopped child ends the start its loop was waiting on', () => { async function stoppedWhileStarting(stop: () => Promise) { adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) + spawn.mockImplementationOnce(spawnStartingChild) await accept('first') await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) await stop() @@ -764,18 +762,18 @@ describe('how a stopped child ends the start its loop was waiting on', () => { const start = deferred() adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() - acquire.mockImplementationOnce(async (input) => { + spawn.mockImplementationOnce(async (input) => { await start.promise return spawnStartingChild(input) }) const first = await accept('first') - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + await eventually(() => expect(spawn).toHaveBeenCalledTimes(2)) let starts = 0 const closed = host['serialize'](SESSION, async () => { await stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { cause: 'user-close' }) - starts = acquire.mock.calls.length + starts = spawn.mock.calls.length beforeStart() }) start.resolve() @@ -791,7 +789,7 @@ describe('how a stopped child ends the start its loop was waiting on', () => { dispatchState: 'rejected', ...CHAT_CLOSED }) - expect(acquire).toHaveBeenCalledTimes(starts) + expect(spawn).toHaveBeenCalledTimes(starts) expect(dispatch).not.toHaveBeenCalled() expect(await statusRows()).toEqual([]) }) @@ -801,15 +799,15 @@ describe('how a stopped child ends the start its loop was waiting on', () => { adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() // The start step holds the queue: the message is still queued when the close's stop runs. - acquire.mockImplementationOnce(async (input) => { + spawn.mockImplementationOnce(async (input) => { await start.promise return spawnStartingChild(input) }) const first = await accept('first', { person: true }) - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + await eventually(() => expect(spawn).toHaveBeenCalledTimes(2)) let starts = 0 const closed = closeStopOnly().then(() => { - starts = acquire.mock.calls.length + starts = spawn.mock.calls.length }) start.resolve() await closed @@ -818,7 +816,7 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(await submission(first)).toMatchObject({ dispatchState: 'rejected', ...CHAT_CLOSED }) const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.queuedMessages?.map((card) => card.messageId)).toEqual([first]) - expect(acquire).toHaveBeenCalledTimes(starts) + expect(spawn).toHaveBeenCalledTimes(starts) expect(dispatch).not.toHaveBeenCalled() // The close stopped the chat running: the card waits for its next turn, by a mark the // re-check wrote once, when it kept the card. @@ -845,7 +843,7 @@ describe('how a stopped child ends the start its loop was waiting on', () => { }) expect((await submission(first))?.dispatchState).toBe('pending') - expect(acquire).toHaveBeenCalledTimes(starts) + expect(spawn).toHaveBeenCalledTimes(starts) expect(dispatch).not.toHaveBeenCalled() const second = await accept('second') @@ -886,7 +884,7 @@ describe('how a stopped child ends the start its loop was waiting on', () => { const reason = 'the start watchdog fired' adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) + spawn.mockImplementationOnce(spawnStartingChild) const first = await accept('first') await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) await host['serialize'](SESSION, () => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts index 3e60c6ee17a1..e5436ac52689 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open-contract.ts @@ -5,7 +5,7 @@ import type { AgentSessionProviderHandleLink } from '../../../shared/agent-sessi export type StructuredAgentSessionProviderOpenInput = { sessionId: string fence: number - acquisitionGeneration: string | null + acquisitionGeneration: string signal: AbortSignal } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts index 06489fcd7510..10a6a31305bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.test.ts @@ -23,13 +23,12 @@ const OPENED: StructuredAgentSessionProviderOpenResult = { function awaitOpen() { const state = hostTestState() - const acquire = state.acquire.getMockImplementation() - if (!acquire) { + const spawn = state.spawn.getMockImplementation() + if (!spawn) { throw new Error('The host fixture has no spawn implementation.') } - state.host.deps.adapter.acquire = async (input) => ({ - ...(await acquire(input)), - providerChildPhase: 'starting', + state.host.deps.adapter.spawn = async (input) => ({ + ...(await spawn(input)), acquisitionGeneration: 'awaited-open' }) const pending = Promise.withResolvers() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts index 8a91a8389aab..1cee764881fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-open.ts @@ -17,18 +17,21 @@ export async function openStructuredAgentSessionProviderChild( sessionId: string, child: StructuredAgentSessionProviderChildIdentity ): Promise { - const open = context.deps.adapter.open - if (!open || !context.runtimeState.startupAttempts.onClock(sessionId, child)) { + if (!context.runtimeState.startupAttempts.onClock(sessionId, child)) { return } - const attempt = context.runtimeState.acquireAborts.begin(sessionId) + const attempt = context.runtimeState.providerWaits.begin(sessionId) try { attempt.signal.throwIfAborted() + const acquisitionGeneration = child.generation + if (acquisitionGeneration === null) { + throw new Error('An unopened provider child has no acquisition generation') + } const result = await waitForPromiseWithSignal( - open.call(context.deps.adapter, { + context.deps.adapter.open({ sessionId, fence: child.fence, - acquisitionGeneration: child.generation, + acquisitionGeneration, signal: attempt.signal }), attempt.signal @@ -38,10 +41,9 @@ export async function openStructuredAgentSessionProviderChild( } await settleStructuredAgentSessionProviderStarted(context, { ...result, - type: 'started', sessionId, fence: child.fence, - acquisitionGeneration: child.generation ?? '' + acquisitionGeneration }) } catch (error) { // Stop, close, quit and expiry already own the child's end. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index 09421a97cc56..f7d32ef8aa94 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -27,7 +27,13 @@ let root = '' function claudeAdapter(): StructuredAgentSessionAdapter { return { supportsCreate: (_location, agent) => agent === 'claude', - acquire: async ({ fence, spawnToken }) => ({ + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, 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 67a939aceabd..aed02f1cb7f9 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 @@ -20,6 +20,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' } @@ -27,9 +28,10 @@ let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost const providerStarted = vi.fn() +const starts = structuredAgentSessionHeldStarts(() => host) function startHost(): void { - const acquire: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ + const spawn: StructuredAgentSessionAdapter['spawn'] = async ({ fence, spawnToken }) => ({ process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -44,15 +46,20 @@ function startHost(): void { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire, + adapter: starts.wrap({ + spawn: starts.held(spawn), releaseAcquisition: vi.fn(async () => true), closeSession: vi.fn(async () => true), dispatch: vi.fn(async () => ({ state: 'admitted' as const })), cancelTurn: vi.fn(async () => ({ cancelled: true })), answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, + setOption: vi.fn(async () => undefined), + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }) + }), modelCatalog: { read: vi.fn(), recordLiveListing: vi.fn(), @@ -75,21 +82,19 @@ beforeEach(async () => { }) afterEach(async () => { + starts.clear() await host.flushAllStreamedEvents() await rm(root, { recursive: true, force: true }) }) /** The adapter's report that the child's protocol session exists. */ -function proveStart(): Promise { - return host.handleAdapterEvent({ - type: 'started', +async function proveStart(): Promise { + await starts.prove({ sessionId: SESSION, fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, - acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'sonnet' }, - restoreSkippedOptions: [], - optionRevision: 0 + acquisitionGeneration: 'generation-1' }) + await vi.waitFor(() => expect(providerStarted).toHaveBeenCalledOnce()) } it('a published child tells the catalog only once its start is proven, with its record', async () => { 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 e36758ff177c..cedeb319cb7d 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 @@ -221,8 +221,8 @@ describe('a publish-first Claude create whose init is slow', () => { host.create(CALLER, createTestParams(first, claudeParams()), { firstMessage: first }) ).resolves.toMatchObject({ ok: true }) - // Delivery, not create, starts the CLI. Its `started` event carries the account listing; the - // settings read after it (`options-reported`) resolves the configured model and says so once. + // Delivery starts the CLI. Its open result carries the account listing; a later settings read + // resolves the configured model and says so once. await vi.waitFor(() => expect(savedListings.length).toBeGreaterThanOrEqual(1)) expect(savedListings[0]).toEqual({ sessionId: SESSION, 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 fb88c180faea..8c94bd6e0bf3 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,6 +1,6 @@ // The host's half of a provider child proving its start. // -// Every acquire hands the host a child that has answered nothing yet, so the record keeps only the +// Every spawn hands the host a child that has answered nothing yet, so the record keeps only the // saved options the reservation carried, and the delivery loop hands it nothing. This is where the // host learns the start landed: the provider handle the child answered with is recorded (a resume // needs it), a rewind left in doubt is settled, the child turns @@ -14,9 +14,9 @@ import type { StructuredAgentSessionOptionsReportedEvent, - StructuredAgentSessionOptionsSkippedEvent, - StructuredAgentSessionStartedEvent + StructuredAgentSessionOptionsSkippedEvent } from './structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenResult } from './structured-agent-session-provider-open-contract' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession, @@ -32,7 +32,7 @@ import { 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 type { StructuredAgentSessionProviderWaits } from './structured-agent-session-provider-waits' import { recordAgentSessionProviderHandle } from '../../runtime/agent-session-provider-handle-transition' import { recoverStructuredRewind } from './structured-rewind-recovery' @@ -44,7 +44,7 @@ export type StructuredAgentSessionProviderStartedContext = { publishStatus?: (sessionId: string) => void runtimeState: { startupAttempts: Pick - acquireAborts: Pick + providerWaits: 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. */ @@ -53,9 +53,15 @@ export type StructuredAgentSessionProviderStartedContext = { stopStartingChild: (sessionId: string, child: StructuredAgentSessionProviderChildIdentity) => void } +type OpenedProvider = StructuredAgentSessionProviderOpenResult & { + sessionId: string + fence: number + acquisitionGeneration: string +} + export async function settleStructuredAgentSessionProviderStarted( context: StructuredAgentSessionProviderStartedContext, - event: StructuredAgentSessionStartedEvent + event: OpenedProvider ): Promise { // Serialized behind the attach that published this child, so the lease it proved is committed. await context.serialize(event.sessionId, async () => { @@ -129,12 +135,12 @@ async function recoverRewindAtStart( * without it a later start could not resume what this child ran. False when there is none. */ async function recordStartedHandle( context: StructuredAgentSessionProviderStartedContext, - event: StructuredAgentSessionStartedEvent + event: OpenedProvider ): Promise { const { store, logger } = context.deps const { link } = event if (!link) { - // Only an acquisition that already held its handle may prove its start without one. + // Only a spawn that already held its handle may prove its open without one. return store.getRecord(event.sessionId)?.lease.provenHandleLinkId != null } try { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts index aa5844ef0eea..249d7b6f0a90 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts @@ -19,7 +19,7 @@ export function adapterSupportsCreate( return adapter.supportsLocation?.(location) ?? false } -/** Honors declared gates while retaining legacy adapters whose acquire path is authoritative. */ +/** Honors declared gates while retaining adapters whose spawn path is authoritative. */ export function adapterSupportsCreateIfDeclared( adapter: StructuredAgentSessionAdapter, location: AgentSessionExecutionLocation, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-waits.ts similarity index 69% rename from src/main/native-chat/agent-session-wire/structured-agent-session-acquire-aborts.ts rename to src/main/native-chat/agent-session-wire/structured-agent-session-provider-waits.ts index a92dee125dc7..fa6ada33be52 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-provider-waits.ts @@ -1,14 +1,12 @@ -// 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. +// One provider wait per session: startup or a message option write at handover. Close, Stop, quit, +// or the startup limit aborts it from outside the queue instead of waiting for a stalled provider. -export class StructuredAgentSessionAcquireAborts { +export class StructuredAgentSessionProviderWaits { private readonly inFlight = new Map() /** Set by quit: the host is going away, so an attach that begins after it starts aborted. */ private quitReason: Error | null = null - /** For the attach about to run; `end` once it settles. */ + /** For the provider wait about to run; `end` once it settles. */ begin(sessionId: string, handover = false): { signal: AbortSignal; end: () => void } { const controller = new AbortController() if (this.quitReason) { @@ -33,7 +31,7 @@ export class StructuredAgentSessionAcquireAborts { return stoppedHandover } - /** Quit: every start under way stops, and so does any the attach drain still runs. */ + /** Quit: every provider wait under way stops, including those queued in the attach drain. */ abortAll(reason: string): void { this.quitReason ??= new Error(reason) for (const { controller } of this.inFlight.values()) { 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 c09039c119d7..3eb6362c218e 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 @@ -59,9 +59,25 @@ export function createQueuedRigProvider( async () => true ) let events: StructuredAgentSessionEventSink | undefined + let proveOpen: (() => void) | null = null const scripted: StructuredAgentSessionAdapter = { - acquire: async ({ identity, fence, spawnToken, events: sink }) => { + open: () => + options.starting + ? new Promise((resolve) => { + proveOpen = () => + resolve({ + reportedOptions: { model: 'default' }, + restoreSkippedOptions: [], + optionRevision: -1 + }) + }) + : Promise.resolve({ + reportedOptions: { model: 'default' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: async ({ identity, fence, spawnToken, events: sink }) => { starts() const hold = startHold startHold = null @@ -170,6 +186,7 @@ export function createQueuedRigProvider( return { adapter, + proveOpen: () => proveOpen?.(), dispatch, starts, holdNextStart, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 9af72d823e5e..f87063bbefae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -183,7 +183,7 @@ export async function createQueuedMessageTestRig( await result await eventually(async () => { if (host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase === 'starting') { - await emitStarted() + await proveProviderOpen() } expect((await submission(id))?.handedOverAt).toBeDefined() }) @@ -254,25 +254,17 @@ export async function createQueuedMessageTestRig( }) } - /** The starting child proves its start, as a publish-first provider's `started` event does: the - * host hands it what it held. */ + /** The starting child proves its open before the host hands it queued work. */ async function proveStart(): Promise { await eventually(() => expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') ) - await emitStarted() + await proveProviderOpen() } - function emitStarted(): Promise { - return host.handleAdapterEvent({ - type: 'started', - sessionId: SESSION, - fence: store.getRecord(SESSION)!.lease.runtimeFence, - acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'default' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) - }) + async function proveProviderOpen(): Promise { + provider.proveOpen() + await Promise.resolve() } async function dispose(): Promise { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts index cbf4f840191f..fb3b494b10d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts @@ -14,7 +14,7 @@ import { 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 { host, store, spawn, dispatch } = hostTestState() const compact = vi.fn(async () => ({ state: 'admitted' as const })) host.deps.adapter.compact = compact const providerWrites = command === 'send' ? dispatch : compact @@ -46,13 +46,13 @@ it.each(['send', 'compact'] as const)( retained: [] } })) - acquire.mockRejectedValueOnce(new Error('restart failed')) + spawn.mockRejectedValueOnce(new Error('restart failed')) expect(await run({ callerKey: 'reconnecting-phone' })).toMatchObject({ ok: true, replayed: true }) - expect(acquire).toHaveBeenCalledTimes(1) + expect(spawn).toHaveBeenCalledTimes(1) expect(providerWrites).toHaveBeenCalledTimes(1) } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index ff3967c36347..c300ff18c981 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -31,7 +31,7 @@ const CALLER = { callerKey: 'client-1' } let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost -let acquire: Mock +let spawn: Mock const spawnedOwners = new Set>() const supersededHosts = new Set() @@ -73,13 +73,18 @@ async function startAgent(): Promise { function adapter(): StructuredAgentSessionAdapter { return { - acquire, - releaseAcquisition: vi.fn(async () => undefined), + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn, + releaseAcquisition: vi.fn(async () => false), dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), setOption: vi.fn() - } as unknown as StructuredAgentSessionAdapter + } } function openHost(overrides: Partial = {}): void { @@ -104,7 +109,8 @@ async function reopenStore(): Promise { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-recovery-exits-')) resetHostTestOperationIds() - acquire = vi.fn(async ({ fence }) => ({ + spawn = vi.fn(async ({ fence }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -133,7 +139,7 @@ afterEach(async () => { describe('recovery exits', () => { it('releases an ownerless unproven acquisition, so the next start goes ahead', async () => { - acquire.mockRejectedValueOnce(new Error('simulated crash before identity commit')) + spawn.mockRejectedValueOnce(new Error('simulated crash before identity commit')) await expect(host.attach(CALLER, hostTestAttachParams(null))).rejects.toThrow( 'agent_session_acquisition_exit_unproven' ) @@ -152,7 +158,7 @@ describe('recovery exits', () => { openHost({ mintSpawnToken: () => 'spawn-b' }) expect(await host.attach(CALLER, hostTestAttachParams(2))).toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) }) it('releases an unproven acquisition whose owner later dies, without replaying it as a handoff', async () => { @@ -190,7 +196,7 @@ describe('recovery exits', () => { // Death proof releases the residue outright: no handoff continuation, no spawned child, // no stage a user would have to clear by hand. - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, @@ -202,7 +208,7 @@ describe('recovery exits', () => { // The ordinary native recovery path remains: the next start resumes it. await startAgent() - expect(acquire).toHaveBeenCalledTimes(3) + expect(spawn).toHaveBeenCalledTimes(3) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', handoffStage: null, @@ -237,7 +243,7 @@ describe('recovery exits', () => { const retried = await host.attach(CALLER, hostTestAttachParams(2)) expect(retried).toMatchObject({ ok: true }) // A fresh child was spawned; the orphan pid's lease did not survive as the owner. - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeFence: 3, claimStatus: 'live', @@ -267,7 +273,7 @@ describe('recovery exits', () => { // Healing is startup's job; spawning is not. The orphan is stopped and the lease is free, but // nothing has asked this session for work, so no replacement child exists yet. expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') - expect(acquire).toHaveBeenCalledTimes(1) + expect(spawn).toHaveBeenCalledTimes(1) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, @@ -276,7 +282,7 @@ describe('recovery exits', () => { await startAgent() - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeFence: 3, claimStatus: 'live', @@ -287,7 +293,8 @@ describe('recovery exits', () => { it('recovers when the outgoing runtime writes after the replacement probes its dying owner', async () => { const outgoing = await spawnOwner('spawn-a') - acquire.mockResolvedValueOnce({ + spawn.mockResolvedValueOnce({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: outgoing.process, link: { linkId: 'link-outgoing', @@ -329,10 +336,11 @@ describe('recovery exits', () => { claimStatus: 'released', ownerProcess: null }) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() const replacement = await spawnOwner('spawn-b') - acquire.mockResolvedValueOnce({ + spawn.mockResolvedValueOnce({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: replacement.process, link: { linkId: 'link-replacement', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 1897adfe1771..a96f36ff6d0d 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 @@ -66,7 +66,13 @@ async function createHarness(options: { attached?: boolean } = {}) { const setOption = vi.fn(async () => undefined) const adapter: StructuredAgentSessionAdapter = startsWhenPublished( { - acquire: async ({ fence }) => ({ + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: async ({ fence }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts index 8dffa04c3d6b..3957ca923ad4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts @@ -41,7 +41,13 @@ const NON_DEFAULT: StructuredAgentDefinition = { function fakeAdapter(): StructuredAgentSessionAdapter { return { - acquire: vi.fn(async ({ fence, spawnToken }) => ({ + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi.fn(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 1, processStartTimeMs: 1, spawnToken }, link: { linkId: `link-${fence}`, @@ -99,7 +105,7 @@ describe('a registered definition', () => { const agents = new StructuredAgentRegistry([{ definition: NON_DEFAULT, adapter }]) const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) - await router.acquire({ + await router.spawn({ identity: { sessionId: 'session-routed', workspaceId: 'workspace-1', @@ -110,7 +116,7 @@ describe('a registered definition', () => { fence: 1, spawnToken: 'spawn-1' }) - expect(adapter.acquire).toHaveBeenCalledOnce() + expect(adapter.spawn).toHaveBeenCalledOnce() expect(agents.capabilities(RECORD.provider)).toBe(NON_DEFAULT.capabilities) const accepted = pick(agents, RECORD, 'pilotOption') 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 78033627f562..91005f91b08e 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 @@ -47,7 +47,12 @@ beforeEach(async () => { store = await openTestAgentSessionRecordStore(root) const adapter: StructuredAgentSessionAdapter = startsWhenPublished( { - acquire: async ({ fence, spawnToken }) => ({ + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: async ({ fence, spawnToken }) => ({ process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, acquisitionGeneration: 'generation-1', link: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts index 36e9027f3db4..916537c9d68c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts @@ -30,11 +30,11 @@ import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router- const REQUESTED = 'Cancellation requested.' let host: StructuredAgentSessionHost -let acquire: Mock +let spawn: Mock let cancelTurn: Mock beforeEach(() => { - ;({ host, acquire, cancelTurn } = hostTestState()) + ;({ host, spawn, cancelTurn } = hostTestState()) }) function stopTurn(turnId = 'turn-1') { @@ -43,7 +43,7 @@ function stopTurn(turnId = 'turn-1') { /** The provider's record of turn-1, as its translator writes it. */ async function turn(state: AgentJournalTurnLifecycleState): Promise { - const events = acquire.mock.calls.at(-1)?.[0].events + const events = spawn.mock.calls.at(-1)?.[0].events if (!events) { throw new Error('turn requires an acquired session') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts index 822fb635b966..072599544c6e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts @@ -66,7 +66,7 @@ async function streamThenServerKilled(): Promise { throw new Error(`send refused: ${sent.refusal.code}`) } await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) - const events = rig.adapter.acquire.mock.calls[0]?.[0].events + const events = rig.adapter.spawn.mock.calls[0]?.[0].events if (!events) { throw new Error('the provider has no event sink') } @@ -174,7 +174,7 @@ describe('a server replaced mid-turn on the same execution host', () => { ) expect(sent.ok).toBe(true) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) - const events = rig.adapter.acquire.mock.calls[1]?.[0].events + const events = rig.adapter.spawn.mock.calls[1]?.[0].events if (!events) { throw new Error('the new provider has no event sink') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts index f3737ba10865..60036eccdc3c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts @@ -27,10 +27,10 @@ let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let dispatch: Mock -let acquire: Mock +let spawn: Mock beforeEach(() => { - ;({ root, store, host, dispatch, acquire } = hostTestState()) + ;({ root, store, host, dispatch, spawn } = hostTestState()) }) afterEach(() => vi.restoreAllMocks()) @@ -178,7 +178,7 @@ describe('a resent send id', () => { phase: 'prepared', retained: [] }) - acquire.mockClear() + spawn.mockClear() vi.spyOn(console, 'warn').mockImplementation(() => undefined) await expect(host.send(CALLER, params)).resolves.toMatchObject({ @@ -186,7 +186,7 @@ describe('a resent send id', () => { replayed: true, value: { submission: { clientMessageId: params.envelope.clientOperationId } } }) - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(store.getRecord(SESSION)?.rewind?.phase).toBe('prepared') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 21e35eebb9a8..c2bf46e09480 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -43,14 +43,14 @@ export const IDLE_MS = STRUCTURED_AGENT_SESSION_IDLE_MS export const SWEEP_INTERVAL_MS = 5 export type RestTestAdapter = { - acquire: Mock + open: StructuredAgentSessionAdapter['open'] + spawn: Mock closeSession: Mock> dispatch: Mock acknowledgeSessionRelease: Mock< NonNullable > holdsDispatch: Mock> - startUnavailable: Mock> readOptions: Mock> } @@ -67,11 +67,16 @@ export type RestTestRig = { forget: Mock readChildWork: Mock<(subject: unknown) => AgentChildWorkView[]> } - /** Wraps an acquire so its child stays `starting`: every other child proves its start once + /** Wraps a spawn so its child stays `starting`: every other child proves its start once * published. */ heldStart: ( - acquire: StructuredAgentSessionAdapter['acquire'] - ) => StructuredAgentSessionAdapter['acquire'] + spawn: StructuredAgentSessionAdapter['spawn'] + ) => StructuredAgentSessionAdapter['spawn'] + proveStart: (child: { + sessionId: string + fence: number + acquisitionGeneration: string + }) => Promise /** Opens a fresh host over the same store and journals: what a restart leaves behind. */ restart: (deps?: Partial) => Promise dispose: () => Promise @@ -149,7 +154,12 @@ export async function createRestTestRig( } let store = await openTestAgentSessionRecordStore(root) const adapter: RestTestAdapter = { - acquire: vi.fn(async ({ fence, spawnToken }) => ({ + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi.fn(async ({ fence, spawnToken }) => ({ acquisitionGeneration: `generation-${++generations}`, process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { @@ -166,7 +176,6 @@ export async function createRestTestRig( dispatch: vi.fn(async () => acceptedDispatch()), acknowledgeSessionRelease: vi.fn(), holdsDispatch: vi.fn(() => false), - startUnavailable: vi.fn(() => undefined), readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })) } const starts = structuredAgentSessionHeldStarts(() => rig.host) @@ -201,6 +210,7 @@ export async function createRestTestRig( statusEvents, sink, heldStart: starts.held, + proveStart: starts.prove, restart: async (overrides = {}) => { await rig.host.flushAllStreamedEvents().catch(() => undefined) store = await openTestAgentSessionRecordStore(root) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts index c53f9b659517..34b9c064b7ac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-audience.test.ts @@ -35,7 +35,7 @@ it('neither lists nor dismisses an offer the caller cannot show', async () => { }) it('neither continues nor reserves an offer the caller cannot show, named or not', async () => { - const { host, root, acquire, dispatch } = await interruptedRestart() + const { host, root, spawn, dispatch } = await interruptedRestart() for (const named of [undefined, [SESSION]]) { expect(await host.restartResume.continueAfterRestart(named, 'modal', cannotShowCodex)).toEqual({ @@ -46,7 +46,7 @@ it('neither continues nor reserves an offer the caller cannot show, named or not failed: [] }) } - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(dispatch).not.toHaveBeenCalled() expect(await offersIn(root)).toHaveLength(1) @@ -55,9 +55,9 @@ it('neither continues nor reserves an offer the caller cannot show, named or not }) it('keeps a failure the caller cannot show, and leaves it out of an action reply', async () => { - const { host, acquire } = await interruptedRestart() + const { host, spawn } = await interruptedRestart() await host.restartResume.list() - acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + spawn.mockRejectedValueOnce(new Error('provider could not reconnect')) await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(await host.restartResume.listFailures()).toHaveLength(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts index 6d8514c830e9..946f99d055ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-child-work.test.ts @@ -29,11 +29,11 @@ it('continues a chat whose subagent the restart stopped', async () => { // The user opens the chat before choosing Resume: reading it starts nothing, so the offer stands. it('still offers and continues a chat the user opened before resuming', async () => { - const { host, acquire, dispatch } = await interruptedRestart('children') + const { host, spawn, dispatch } = await interruptedRestart('children') const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: vi.fn() }) await host.history({ sessionId: SESSION, direction: 'tail' }) unsubscribe() - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(await host.restartResume.list()).toMatchObject([ { sessionId: SESSION, activity: { tasks: [{ kind: 'agent', label: 'Review loop 4' }] } } ]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index be5b153ecc87..75443c4a7180 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -53,9 +53,9 @@ it('files nothing for a chat the user moved on in before its attempt, and spends // The continuation is accepted and its agent then fails to start: the message is rejected with the // cause, the failure is filed, and the chat says the agent did not carry on. it('says so in the chat when the agent cannot start for the continuation', async () => { - const { host, acquire } = await interruptedRestart() + const { host, spawn } = await interruptedRestart() await host.restartResume.list() - acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + spawn.mockRejectedValueOnce(new Error('provider could not reconnect')) // Before the action, the quit's cut reads with its one notice. expect(await readerNotes(host)).toEqual([QUIT_CUT_NOTICE]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index db9107193361..413ae5c1b00e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -78,7 +78,7 @@ export async function interruptedRestart( serveHostTestChildWork(() => children) } await attach() - const events = previous.acquire.mock.calls[0]?.[0].events + const events = previous.spawn.mock.calls[0]?.[0].events if (!events) { throw new Error('missing provider event sink') } @@ -181,7 +181,7 @@ export async function interruptedRestart( now: () => clock.now }) replaceHostTestState({ store, host }) - previous.acquire.mockClear() + previous.spawn.mockClear() previous.releaseAcquisition.mockClear() previous.dispatch.mockClear() const capsule = JSON.parse( @@ -249,7 +249,7 @@ export const QUIT_CUT_NOTICE = { * client's send lands after the action reserved the offer, just before the continuation is * accepted. `userAnswers` instead has the user send before or after the whole attempt. */ export async function supersededRefusal(userAnswers?: 'before' | 'after') { - const { host, store, acquire, dispatch, root } = await interruptedRestart() + const { host, store, spawn, dispatch, root } = await interruptedRestart() await host.restartResume.list() const body = hostTestMessage('A newer task from another client') const answer = () => @@ -276,7 +276,7 @@ export async function supersededRefusal(userAnswers?: 'before' | 'after') { } try { const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') - return { host, store, acquire, dispatch, root, result } + return { host, store, spawn, dispatch, root, result } } finally { writing.mockRestore() admitting.mockRestore() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts index 032b0e07b2af..028206fcf442 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-after-reattach.test.ts @@ -15,11 +15,11 @@ import { // A retry is a new action with a new message, not a replay of the rejected one, and it is // delivered with the same body. it("delivers a retry as a new continuation after the first one's start failed", async () => { - const { host, acquire, dispatch, marker } = await interruptedRestart('children') + const { host, spawn, dispatch, marker } = await interruptedRestart('children') if (!marker) { throw new Error('missing interrupted restart marker') } - acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + spawn.mockRejectedValueOnce(new Error('provider could not reconnect')) const first = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(first.failed).toMatchObject([{ sessionId: SESSION, outcome: 'refused', retryable: true }]) const [rejected] = (await host.journalSnapshot(SESSION)).submissions diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts index a9aeb826a7f9..5245fa2d5630 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts @@ -65,8 +65,8 @@ it('deletes the offer when the chat itself is closed', async () => { // A failure record is the same obligation in a later state; the chat's close ends it too. it('deletes a failed-resume record when the chat itself is closed', async () => { - const { host, root, acquire } = await interruptedRestart() - acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + const { host, root, spawn } = await interruptedRestart() + spawn.mockRejectedValueOnce(new Error('provider could not reconnect')) await host.restartResume.continueAfterRestart([SESSION], 'modal') const capsule = new AgentSessionRecoveryCapsule(root) expect(await capsule.listFailed(NOW)).toHaveLength(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts index 932bd700fbf7..0c168f7ebaa1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts @@ -61,13 +61,13 @@ function offersFile(root: string): Promise { async function keptWithoutAWord( host: Awaited>['host'], root: string, - acquire: ReturnType + spawn: ReturnType ) { const before = await offersFile(root) expect(await host.restartResume.list()).toEqual([]) expect(await offersFile(root)).toEqual(before) expect((await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued).toEqual([]) - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(await host.restartResume.listFailures()).toEqual([]) expect(await offersFile(root)).toEqual(before) const capsule = new AgentSessionRecoveryCapsule(root) @@ -76,18 +76,18 @@ async function keptWithoutAWord( } it('keeps the offer of a chat whose journal a newer Orca wrote, and offers, runs and counts nothing', async () => { - const { host, root, acquire } = await interruptedRestart() + const { host, root, spawn } = await interruptedRestart() newerOrcaRow(root) - await keptWithoutAWord(host, root, acquire) + await keptWithoutAWord(host, root, spawn) }) // A newer Orca's whole database: every chat in it is a newer Orca's, even one whose tables changed. it('keeps the offer of every chat in a newer Orca database, and offers, runs and counts nothing', async () => { - const { host, root, acquire } = await interruptedRestart() + const { host, root, spawn } = await interruptedRestart() const database = openTestJournalHostDatabase(root) Object.defineProperty(database, 'readOnly', { value: true }) database.db.exec('ALTER TABLE journal_sessions RENAME TO journal_sessions_newer') - await keptWithoutAWord(host, root, acquire) + await keptWithoutAWord(host, root, spawn) }) // However a newer Orca's refusal reaches a send (a journal first opened when sending), it files @@ -116,9 +116,9 @@ it('keeps the offer and files nothing when the send is refused as a newer Orca c // A failure filed before the chat became a newer Orca's stays for that Orca, and is not shown here. it('keeps a failure already filed for a chat a newer Orca saved since, but does not show it', async () => { - const { host, root, acquire } = await interruptedRestart() + const { host, root, spawn } = await interruptedRestart() await host.restartResume.list() - acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + spawn.mockRejectedValueOnce(new Error('provider could not reconnect')) await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(await host.restartResume.listFailures()).toMatchObject([{ sessionId: SESSION }]) await host.close(SESSION, 'evict') @@ -133,7 +133,7 @@ it('keeps a failure already filed for a chat a newer Orca saved since, but does // What listing does with a journal that throws for another reason: a damaged chat stays offered, // and acting on it files the failure, which names the damage. it('still offers a damaged chat, and files its failure when acting on it', async () => { - const { host, root, acquire } = await interruptedRestart() + const { host, root, spawn } = await interruptedRestart() updateTestJournalRowJson(openTestJournalHostDatabase(root).db, SESSION, 1, '}{') expect((await host.restartResume.list()).map((candidate) => candidate.sessionId)).toEqual([ @@ -141,7 +141,7 @@ it('still offers a damaged chat, and files its failure when acting on it', async ]) await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(await host.restartResume.listFailures()).toMatchObject([ { sessionId: SESSION, @@ -169,7 +169,7 @@ it.each([ attachParams({ envelope: { ...attachParams().envelope, sessionId: NEWER } }) ) expect(created.ok).toBe(true) - hostTestState().acquire.mockClear() + hostTestState().spawn.mockClear() const { host, root, marker } = await interruptedRestart() newerOrcaRow(root, NEWER) const path = join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts index 89e039689a5f..0ba8fe48f86f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts @@ -36,7 +36,7 @@ afterEach(() => vi.restoreAllMocks()) const SUPERSEDED = 'agent_session_restart_work_superseded' -/** A start under a loaded machine: reconcile, acquire, hand over. */ +/** A start under a loaded machine: reconcile, spawn, hand over. */ const COLD_START = { timeout: 10_000 } async function offered(work: 'turn' | 'submission' = 'turn') { @@ -68,18 +68,18 @@ function sentTexts(dispatch: Awaited>['dispatch']): s } it('withdraws the offer when /compact starts the agent at rest (R-01)', async () => { - const { host, root, acquire } = await offered() + const { host, root, spawn } = await offered() Object.assign(host.deps.adapter, { compact: vi.fn(async () => ({})) }) expect(await host.conversationCommand(CALLER, compactParams())).toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() await vi.waitFor(async () => expect(await offersIn(root)).toEqual([]), COLD_START) expect(await host.restartResume.list()).toEqual([]) }) it('keeps the offer through every way of looking at the chat (R-02)', async () => { - const { host, root, acquire } = await offered() + const { host, root, spawn } = await offered() const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: vi.fn() }) await host.history({ sessionId: SESSION, direction: 'tail' }) await host.readOptions(SESSION) @@ -88,7 +88,7 @@ it('keeps the offer through every way of looking at the chat (R-02)', async () = await host.revealSession(SESSION) unsubscribe() - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(await host.restartResume.list()).toHaveLength(1) expect(await offersIn(root)).toHaveLength(1) }) @@ -128,12 +128,12 @@ it('refuses the continuation when another start lands inside its action (R-03b)' }) it('keeps the offer when a tabbed chat is restored at boot (R-05)', async () => { - const { host, store, root, acquire } = await offered() + const { host, store, root, spawn } = await offered() await store.setSessionTabVisibility(SESSION, true) await host.restoreReadableSessions([SESSION]) - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(await host.restartResume.list()).toHaveLength(1) expect(await offersIn(root)).toHaveLength(1) }) @@ -168,7 +168,7 @@ it('still writes the message id the previous build requires on every marker', as /** Sends a message whose start fails, and waits for it to be rejected. */ async function sendWhoseStartFails(state: Awaited>) { - state.acquire.mockRejectedValueOnce(new Error('Not signed in')) + state.spawn.mockRejectedValueOnce(new Error('Not signed in')) const params = userSend('while signed out') await state.host.send(CALLER, params) await vi.waitFor(async () => @@ -218,7 +218,7 @@ it('keeps the offer withdrawn after the idle sweep closes the chat and it is rea it('keeps a failure not retryable after the user moved on and the sweep closed the chat', async () => { const state = await offered() const { host, clock } = state - state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + state.spawn.mockRejectedValueOnce(new Error('provider could not reconnect')) const resumed = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(resumed.failed).toMatchObject([{ sessionId: SESSION, retryable: true }]) await sendWhoseStartFails(state) @@ -250,7 +250,7 @@ it('resumes more than a day after the quit', async () => { it('keeps a failed resume retryable more than a day later, after the ledger pruned its row', async () => { const state = await offered() const { host, clock, store } = state - state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + state.spawn.mockRejectedValueOnce(new Error('provider could not reconnect')) const first = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(first.failed).toMatchObject([{ sessionId: SESSION, retryable: true }]) clock.now += DAY_AND_AN_HOUR @@ -273,12 +273,12 @@ it('keeps a failed resume retryable more than a day later, after the ledger prun it('keeps a resume retryable when its agent started but refused the continuation', async () => { const state = await offered() const { host, store } = state - const acquire = state.acquire.getMockImplementation() - if (!acquire) { - throw new Error('the harness acquire has no implementation') + const spawn = state.spawn.getMockImplementation() + if (!spawn) { + throw new Error('the harness spawn has no implementation') } - state.acquire.mockImplementationOnce(async (input) => ({ - ...(await acquire(input)), + state.spawn.mockImplementationOnce(async (input) => ({ + ...(await spawn(input)), acquisitionGeneration: 'generation-continuation' })) state.dispatch.mockResolvedValueOnce({ @@ -293,7 +293,7 @@ it('keeps a resume retryable when its agent started but refused the continuation const first = await host.restartResume.continueAfterRestart([SESSION], 'modal') - expect(state.acquire).toHaveBeenCalledOnce() + expect(state.spawn).toHaveBeenCalledOnce() expect(first.failed).toMatchObject([{ sessionId: SESSION, retryable: true }]) expect(await host.restartResume.listFailures()).toMatchObject([ { sessionId: SESSION, retryable: true } @@ -342,11 +342,11 @@ it('keeps an unanswered continuation the agent was handed from being sent again' it("names the user's prompt on a failed retry, not the rejected continuation", async () => { const state = await offered('submission') const { host } = state - state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + state.spawn.mockRejectedValueOnce(new Error('provider could not reconnect')) const first = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(first.failed).toMatchObject([{ latestPrompt: 'Perform the original task' }]) - state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + state.spawn.mockRejectedValueOnce(new Error('provider could not reconnect')) const retried = await host.restartResume.continueAfterRestart([SESSION], 'retry') expect(retried.failed).toMatchObject([ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index 38391833ff25..72b49af92c97 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -103,14 +103,14 @@ it.each(['turn', 'submission'] as const)( // Orca stopped; the continuation asks the agent // to check what finished rather than refusing. it('still continues a chat whose last send acquisition proves was never delivered', async () => { - const { host, acquire, dispatch } = await interruptedRestart('submission') + const { host, spawn, dispatch } = await interruptedRestart('submission') expect(await host.restartResume.list()).toHaveLength(1) const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect((await host.journalSnapshot(SESSION)).submissions[0]).toMatchObject({ dispatchState: 'rejected', rejection: { kind: 'notDelivered' } }) - expect(acquire).toHaveBeenCalledTimes(1) + expect(spawn).toHaveBeenCalledTimes(1) expect(dispatch).toHaveBeenCalledTimes(1) expect(result.continued).toMatchObject([{ outcome: 'continued' }]) }) @@ -196,8 +196,8 @@ it('reports a continuation delivered when its send throws after its message is i // The continuation is accepted, then its start fails: the message is rejected with the cause and // the failure is filed, and nothing is stopped because nothing started. it('rejects the continuation when its start fails, and files a retryable refusal', async () => { - const { host, acquire, dispatch, closeSession } = await interruptedRestart() - acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + const { host, spawn, dispatch, closeSession } = await interruptedRestart() + spawn.mockRejectedValueOnce(new Error('provider could not reconnect')) const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(result.resumed).toMatchObject([{ outcome: 'refused' }]) expect(result.continued).toMatchObject([{ outcome: 'refused' }]) @@ -210,13 +210,13 @@ it('rejects the continuation when its start fails, and files a retryable refusal expect((await host.journalSnapshot(SESSION)).submissions).toMatchObject([ { dispatchState: 'rejected' } ]) - expect(acquire).toHaveBeenCalledTimes(1) + expect(spawn).toHaveBeenCalledTimes(1) expect(dispatch).not.toHaveBeenCalled() expect(closeSession).not.toHaveBeenCalled() }) it('admits one durable continuation under concurrent calls', async () => { - const { host, root, acquire, dispatch } = await interruptedRestart() + const { host, root, spawn, dispatch } = await interruptedRestart() const results = await Promise.all([ host.restartResume.continueAfterRestart([SESSION], 'window-one'), host.restartResume.continueAfterRestart([SESSION], 'window-two') @@ -227,7 +227,7 @@ it('admits one durable continuation under concurrent calls', async () => { expect( results.flatMap((result) => result.continued).filter((r) => r.outcome === 'continued') ).toHaveLength(1) - expect(acquire).toHaveBeenCalledTimes(1) + expect(spawn).toHaveBeenCalledTimes(1) expect(dispatch).toHaveBeenCalledTimes(1) expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1) expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) @@ -265,12 +265,12 @@ it('waits out a slow provider answer before reporting the continuation', async ( // Opening the chat is inspection only: it starts nothing, and the explicit restart action is what // removes the durable offer, so reading the chat must not make this status disappear. it('keeps offering a chat after the user opens it', async () => { - const { host, root, acquire } = await interruptedRestart() + const { host, root, spawn } = await interruptedRestart() expect(await host.restartResume.list()).toHaveLength(1) const unsubscribe = await host.subscribe({ id: 'pane', sessionId: SESSION, emit: vi.fn() }) await host.history({ sessionId: SESSION, direction: 'tail' }) unsubscribe() - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(await host.restartResume.list()).toHaveLength(1) await host.restartResume.recordMarkers() @@ -296,8 +296,8 @@ it('keeps a durable offer intact through a teardown with no explicit action', as it('serializes teardown publication behind an explicit dismissal', async () => { await attach() - const { host, root, acquire } = hostTestState() - const events = acquire.mock.calls[0]?.[0].events + const { host, root, spawn } = hostTestState() + const events = spawn.mock.calls[0]?.[0].events if (!events) { throw new Error('missing provider event sink') } @@ -390,7 +390,7 @@ it("refuses a continuation quietly when the user's own message was accepted firs /** A continuation whose start failed, filed as a retryable failure. */ async function failedContinuation() { const state = await interruptedRestart() - state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + state.spawn.mockRejectedValueOnce(new Error('provider could not reconnect')) const result = await state.host.restartResume.continueAfterRestart([SESSION], 'modal') expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'refused' }]) return state @@ -464,7 +464,7 @@ it('dismisses one failure by name and leaves the rest of the durable records alo it('logs teardown capsule publication failure and still releases the provider', async () => { const previous = hostTestState() await attach() - const events = previous.acquire.mock.calls[0]?.[0].events + const events = previous.spawn.mock.calls[0]?.[0].events if (!events) { throw new Error('missing provider event sink') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-provider-output.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-provider-output.test.ts index 694443515dd9..62ced880069f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-provider-output.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-provider-output.test.ts @@ -76,7 +76,7 @@ async function beginTurn() { throw new Error('send refused') } await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) - const events = rig.adapter.acquire.mock.calls[0]?.[0].events + const events = rig.adapter.spawn.mock.calls[0]?.[0].events if (!events) { throw new Error('event sink absent') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-recovery-output.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-recovery-output.test.ts index 1c78db236f3e..ebc808c7cfed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-recovery-output.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-recovery-output.test.ts @@ -50,7 +50,7 @@ beforeEach(async () => { adapter: rig.host.deps.adapter } ]) - rig.adapter.acquire.mockImplementation(async ({ fence, spawnToken }) => ({ + rig.adapter.spawn.mockImplementation(async ({ fence, spawnToken }) => ({ acquisitionGeneration: 'acp-generation', process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { @@ -115,7 +115,7 @@ async function beginTurn(workspaceKind: 'git-worktree' | 'folder') { throw new Error('send refused') } await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) - const events = rig.adapter.acquire.mock.calls[0]?.[0].events + const events = rig.adapter.spawn.mock.calls[0]?.[0].events if (!events) { throw new Error('event sink absent') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts index 5ff3dc4aa651..0e0182ada757 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts @@ -111,7 +111,7 @@ it('publishes a real continuation through an overlapping audience exclusion', as }) it('publishes skipped when an offer disappears during reservation without starting its agent', async () => { - const { host, acquire, dispatch } = await interruptedRestart() + const { host, spawn, dispatch } = await interruptedRestart() expect(await host.restartResume.list()).toHaveLength(1) vi.spyOn(AgentSessionRecoveryCapsule.prototype, 'beginResume').mockResolvedValueOnce([]) const phases: (string | undefined)[] = [] @@ -127,7 +127,7 @@ it('publishes skipped when an offer disappears during reservation without starti const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') expect(result).toMatchObject({ skipped: [SESSION], resumed: [], continued: [] }) expect(phases).toEqual(['skipped', undefined]) - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(dispatch).not.toHaveBeenCalled() } finally { release() @@ -138,7 +138,7 @@ it('publishes skipped for an offer started elsewhere and sends no continuation', const state = await interruptedRestart() expect(await state.host.restartResume.list()).toHaveLength(1) await startAgent(state) - state.acquire.mockClear() + state.spawn.mockClear() const summaries: AgentSessionStatusSummary[] = [] const release = state.host.subscribeStatus({ id: 'skipped', @@ -151,7 +151,7 @@ it('publishes skipped for an offer started elsewhere and sends no continuation', try { const result = await state.host.restartResume.continueAfterRestart([SESSION], 'modal') expect(result).toMatchObject({ skipped: [SESSION], resumed: [], continued: [] }) - expect(state.acquire).not.toHaveBeenCalled() + expect(state.spawn).not.toHaveBeenCalled() expect(state.dispatch).not.toHaveBeenCalled() expect(summaries.map((summary) => summary.restartResume?.phase)).toContain('skipped') expect(summaries.at(-1)).not.toHaveProperty('restartResume') @@ -163,7 +163,7 @@ it('publishes skipped for an offer started elsewhere and sends no continuation', it.each(['continued', 'refused', 'unconfirmed'] as const)( 'publishes %s before bookkeeping finishes, then clears progress on return', async (phase) => { - const { host, acquire } = await interruptedRestart() + const { host, spawn } = await interruptedRestart() await host.restartResume.list() const summaries: AgentSessionStatusSummary[] = [] const release = host.subscribeStatus({ @@ -175,7 +175,7 @@ it.each(['continued', 'refused', 'unconfirmed'] as const)( } }) if (phase === 'refused') { - acquire.mockRejectedValueOnce(new Error('start refused')) + spawn.mockRejectedValueOnce(new Error('start refused')) } if (phase === 'unconfirmed') { continuationCannotFold() 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 c585fb2a3c52..431c175d4e46 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 @@ -39,7 +39,13 @@ let root = '' function adapter(): StructuredAgentSessionAdapter { return { - acquire: async ({ fence, spawnToken }) => ({ + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts index 5deb2e482db9..31509b1b9453 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts @@ -62,7 +62,7 @@ async function streamBeforeCrash( throw new Error(`send refused: ${sent.refusal.code}`) } await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) - const events = rig.adapter.acquire.mock.calls[0]?.[0].events + const events = rig.adapter.spawn.mock.calls[0]?.[0].events if (!events) { throw new Error('the provider has no event sink') } @@ -125,7 +125,7 @@ async function expectSettledStream(): Promise { (item) => item.body.kind === 'status' && item.body.text !== 'host notice' ) ).toHaveLength(1) - expect(rig.adapter.acquire).toHaveBeenCalledOnce() + expect(rig.adapter.spawn).toHaveBeenCalledOnce() } describe('a provider stream cut short before its next lease renewal', () => { @@ -209,7 +209,7 @@ describe('a provider stream cut short before its next lease renewal', () => { const turn = snapshot.items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) expect(turn?.state).toBe('unverifiable') expect(turn?.completedAt).toBeUndefined() - expect(rig.adapter.acquire).toHaveBeenCalledOnce() + expect(rig.adapter.spawn).toHaveBeenCalledOnce() }) it('ignores later client actions, recovery revisions, and another owner generation', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts index 3d920db766a4..ba9704574e21 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts @@ -65,8 +65,8 @@ it.each(['approval', 'question', 'completed'])( 'offers only unfinished work when an accepted %s event is queued at quit', async (event) => { await attach() - const { host, root, acquire } = hostTestState() - const events = acquire.mock.calls[0]?.[0].events + const { host, root, spawn } = hostTestState() + const events = spawn.mock.calls[0]?.[0].events if (!events) { throw new Error('missing provider event sink') } @@ -108,8 +108,8 @@ it.each(['approval', 'question', 'completed'] as const)( 'offers work that was running at the stop even when a provider %s arrives during close', async (event) => { await attach() - const { host, root, acquire } = hostTestState() - const events = acquire.mock.calls[0]?.[0].events + const { host, root, spawn } = hostTestState() + const events = spawn.mock.calls[0]?.[0].events if (!events) { throw new Error('missing provider event sink') } @@ -145,8 +145,8 @@ it('marks a settled chat whose subagent was still running', async () => { let children: AgentChildWorkView[] = [] serveHostTestChildWork(() => children) await attach() - const { host, root, acquire } = hostTestState() - const events = acquire.mock.calls[0]?.[0].events + const { host, root, spawn } = hostTestState() + const events = spawn.mock.calls[0]?.[0].events if (!events) { throw new Error('missing provider event sink') } @@ -173,8 +173,8 @@ it('marks a settled chat whose subagent was still running', async () => { // off, and its lease is left for the next launch to adjudicate. it('offers nothing for a chat whose child was not proven stopped', async () => { await attach() - const { host, root, acquire } = hostTestState() - const events = acquire.mock.calls[0]?.[0].events + const { host, root, spawn } = hostTestState() + const events = spawn.mock.calls[0]?.[0].events if (!events) { throw new Error('missing provider event sink') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts index 11d23efa8048..b824d9126db8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-undrivable.test.ts @@ -26,7 +26,7 @@ function codexOnAnotherTransport(): StructuredAgentRegistry { } it('does not offer to resume a chat whose agent this build no longer drives, and keeps the offer', async () => { - const { host, root, acquire } = await interruptedRestart( + const { host, root, spawn } = await interruptedRestart( undefined, undefined, undefined, @@ -38,7 +38,7 @@ it('does not offer to resume a chat whose agent this build no longer drives, and resumed: [], continued: [] }) - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(await new AgentSessionRecoveryCapsule(root).list(NOW + 1)).toHaveLength(1) }) 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 03e6a399cecd..8ef2ed38d827 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -49,7 +49,12 @@ function adapter(): StructuredAgentSessionAdapter { { supportsCreate: (_location, agent) => agent === 'codex', supportsLocation: () => true, - acquire: async (input) => { + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: async (input) => { acquires += 1 sink = input.events! return { 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 4ba833a93f77..e871dbe14061 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts @@ -52,7 +52,12 @@ function adapter(): StructuredAgentSessionAdapter { { supportsCreate: (_location, agent) => agent === 'codex', supportsLocation: () => true, - acquire: async (input) => { + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: async (input) => { acquires += 1 sink = input.events! return { 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 fc5ec2684674..8d85ae7df899 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 @@ -23,7 +23,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionAdapter, - StructuredAgentSessionAcquireInput, + StructuredAgentSessionSpawnInput, AgentSessionDispatchOutcome } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' @@ -49,7 +49,7 @@ let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let sink: StructuredAgentSessionEventSink let adapter: StructuredAgentSessionAdapter -let acquires: StructuredAgentSessionAcquireInput[] +let acquires: StructuredAgentSessionSpawnInput[] const rewind = vi.fn>() const recoverRewind = vi.fn>() @@ -73,7 +73,12 @@ beforeEach(async () => { { supportsCreate: (_location, agent) => agent === 'codex', supportsLocation: () => true, - acquire: async (input) => { + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: async (input) => { acquires.push(input) sink = input.events! return { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts index 631b9cac3c37..0dd8caba5e92 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts @@ -48,7 +48,7 @@ async function relaunchAfterCrashMidTurn( ) { const dying = hostTestState() await attach() - const events = dying.acquire.mock.calls[0]?.[0].events + const events = dying.spawn.mock.calls[0]?.[0].events if (!events) { throw new Error('missing provider event sink') } @@ -68,14 +68,14 @@ async function relaunchAfterCrashMidTurn( filter: (source) => !source.includes('.lock') }) const store = await openTestAgentSessionRecordStore(relaunched) - const acquire = vi.fn(async () => { + const spawn = vi.fn(async () => { throw new Error('claude: command not found') }) const host = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { ...adapter(), acquire }, + adapter: { ...adapter(), spawn }, journalDatabase: openTestJournalHostDatabase(relaunched), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-next', @@ -86,7 +86,7 @@ async function relaunchAfterCrashMidTurn( await host.reconcileRestartLeases() } replaceHostTestState({ store, host }) - return { host, acquire } + return { host, spawn } } async function turnStates(host: StructuredAgentSessionHost) { @@ -96,7 +96,7 @@ async function turnStates(host: StructuredAgentSessionHost) { } // A host that cannot prove the old owner gone leaves its lease in recovery, still claimed, until -// the next acquire resolves it: the open is not that acquire, and the turn is no less gone. Only +// the next spawn resolves it: the open is not that spawn, and the turn is no less gone. Only // the proof decides whether it reads interrupted. const PROBES = [ ['the old owner is proven gone', { outcome: 'pid-absent' }, 'interrupted'], @@ -110,7 +110,7 @@ const PROBES = [ it.each(PROBES)( 'settles a turn a dead generation left running when a send opens the chat and its start fails, when %s', async (_when, probe, settled) => { - const { host, acquire } = await relaunchAfterCrashMidTurn(probe) + const { host, spawn } = await relaunchAfterCrashMidTurn(probe) expect(host.hasSession(SESSION)).toBe(false) const body = hostTestMessage('sent to a chat nobody has open') @@ -126,7 +126,7 @@ it.each(PROBES)( ).toMatchObject({ dispatchState: 'rejected' }) ) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(await turnStates(host)).toEqual([settled]) await host.flushAllStreamedEvents() } 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 089427f5b2ab..12d02d1dbeb2 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 @@ -39,8 +39,8 @@ function eventually(assertion: () => void | Promise): Promise { let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost -let acquire: Mock -let spawnChild: StructuredAgentSessionAdapter['acquire'] +let spawn: Mock +let spawnChild: StructuredAgentSessionAdapter['spawn'] let dispatch: Mock let hostErrors: unknown[] @@ -62,7 +62,7 @@ beforeEach(async () => { observedAt: NOW } }) - acquire = vi.fn(spawnChild) + spawn = vi.fn(spawnChild) dispatch = vi.fn(async () => ({ state: 'accepted' as const, providerIdentity: { @@ -82,7 +82,12 @@ beforeEach(async () => { store, adapter: startsWhenPublished( { - acquire, + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn, dispatch, closeSession: vi.fn(async () => true), releaseAcquisition: vi.fn(async () => true), @@ -94,7 +99,7 @@ beforeEach(async () => { ), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + mintSpawnToken: () => `spawn-${spawn.mock.calls.length}`, now: () => NOW }) expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) @@ -160,7 +165,7 @@ async function loseOwner(): Promise { claimStatus: 'released', ownerProcess: null }) - acquire.mockClear() + spawn.mockClear() } describe('a send with no live owner', () => { @@ -170,16 +175,16 @@ describe('a send with no live owner', () => { await accept(sendParams('after the child died')) await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') }) it('accepts the send before anything restarts, and the restart hands it over', async () => { await loseOwner() const order: string[] = [] - const spawnChild = acquire.getMockImplementation()! - acquire.mockImplementationOnce(async (input) => { - order.push('acquire') + const spawnChild = spawn.getMockImplementation()! + spawn.mockImplementationOnce(async (input) => { + order.push('spawn') return spawnChild(input) }) const admit = store.admitMutationOperation @@ -191,16 +196,16 @@ describe('a send with no live owner', () => { await accept(sendParams('accept first')) await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(order).toEqual(['acquire']) + expect(order).toEqual(['spawn']) }) it('leaves a live owner alone', async () => { - acquire.mockClear() + spawn.mockClear() await accept(sendParams('owner is live')) await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() }) it('restarts an independently retained old-build clear source', async () => { @@ -220,7 +225,7 @@ describe('a send with no live owner', () => { await expect(host.send(CALLER, sendParams('into the retained chat'))).resolves.toMatchObject({ ok: true }) - await eventually(() => expect(acquire).toHaveBeenCalledOnce()) + await eventually(() => expect(spawn).toHaveBeenCalledOnce()) }) it('restarts an owner that exited while the session stayed readable', async () => { @@ -235,11 +240,11 @@ describe('a send with no live owner', () => { }) expect(host.hasSession(SESSION)).toBe(true) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - acquire.mockClear() + spawn.mockClear() await accept(sendParams('after an exit')) await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() }) it('restarts nothing for a resend the journal already answers', async () => { @@ -258,7 +263,7 @@ describe('a send with no live owner', () => { startupUnproven: true }) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - acquire.mockClear() + spawn.mockClear() const resent = { ...params, envelope: { @@ -269,14 +274,14 @@ describe('a send with no live owner', () => { await expect(host.send(CALLER, resent)).resolves.toMatchObject({ ok: true, replayed: true }) - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') // Retry rotates the id: a genuinely new send restarts the owner once. await accept(sendParams('sent once')) await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(2)) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() }) /** A row admitted for this send, then the host died before the journal write, left as `outcome` @@ -308,7 +313,7 @@ describe('a send with no live owner', () => { acquisitionGeneration: 'generation-1' }) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - acquire.mockClear() + spawn.mockClear() return { ...params, envelope: { @@ -327,7 +332,7 @@ describe('a send with no live owner', () => { value: { submission: { dispatchState: 'pending' } } }) await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() }) it('accepts afresh when only an older ledger row exists without a command receipt', async () => { @@ -339,7 +344,7 @@ describe('a send with no live owner', () => { value: { submission: { dispatchState: 'pending' } } }) await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() }) it('accepts a send that arrives while a restart holds the queue, and hands both over in order', async () => { @@ -348,11 +353,11 @@ describe('a send with no live owner', () => { let claimed = () => {} let release = () => {} const claim = new Promise((resolve) => (claimed = resolve)) - const spawn = new Promise((resolve) => (release = resolve)) - const spawnChild = acquire.getMockImplementation() - acquire.mockImplementationOnce(async (input) => { + const spawnGate = new Promise((resolve) => (release = resolve)) + const spawnChild = spawn.getMockImplementation() + spawn.mockImplementationOnce(async (input) => { claimed() - await spawn + await spawnGate return spawnChild!(input) }) @@ -367,7 +372,7 @@ describe('a send with no live owner', () => { expect(await second).toMatchObject({ ok: true }) await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(2)) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([ first, late.envelope.clientOperationId @@ -385,7 +390,7 @@ describe('a send with no live owner', () => { expect(results.map((result) => result.ok)).toEqual([true, true, true]) await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(3)) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() }) it('replays into a closed session without spawning anything', async () => { @@ -399,16 +404,16 @@ describe('a send with no live owner', () => { // The conversation was opened for the answer; the record's lease was left as it was. expect(host.hasSession(SESSION)).toBe(true) - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) it("rejects the accepted message with the restart's own cause, and says so in the chat once", async () => { await loseOwner() - acquire.mockRejectedValue(new Error('Not signed in. Run codex login')) + spawn.mockRejectedValue(new Error('Not signed in. Run codex login')) const params = sendParams('while signed out') - // The acquire's error is Orca's wrapper, not the provider's words: it goes to the log. No exit + // The spawn's error is Orca's wrapper, not the provider's words: it goes to the log. No exit // was observed, so the chat does not say the provider stopped. const cause = "Codex couldn't restart. Send your message to try again." @@ -431,7 +436,7 @@ describe('a send with no live owner', () => { it("keeps Codex's own words behind a refused resume without saying the provider stopped", async () => { await loseOwner() const said = `no rollout found for thread id ${THREAD}` - acquire.mockRejectedValue( + spawn.mockRejectedValue( new CodexAppServerRequestError('thread/resume', -32600, `thread/resume failed: ${said}`, said) ) const id = await accept(sendParams('after the thread went away')) @@ -460,10 +465,10 @@ describe('a send with no live owner', () => { it('restarts again for a Retry under a new id, and replays a resend of the same id', async () => { await loseOwner() - acquire.mockRejectedValue(new Error('Not signed in')) + spawn.mockRejectedValue(new Error('Not signed in')) const params = sendParams('while signed out') await settled(await accept(params)) - expect(acquire).toHaveBeenCalledTimes(1) + expect(spawn).toHaveBeenCalledTimes(1) // A client that resends the same id gets the recorded rejection, and the chat no second row. await expect(host.send(CALLER, params)).resolves.toMatchObject({ @@ -471,21 +476,21 @@ describe('a send with no live owner', () => { replayed: true, value: { submission: { dispatchState: 'rejected' } } }) - expect(acquire).toHaveBeenCalledTimes(1) + expect(spawn).toHaveBeenCalledTimes(1) expect(await errorStatuses()).toHaveLength(1) // The outbox's Retry rotates the id: a fresh attempt, with its own row. expect(await settled(await accept(sendParams('while signed out')))).toMatchObject({ dispatchState: 'rejected' }) - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) expect(await errorStatuses()).toHaveLength(2) expect(dispatch).not.toHaveBeenCalled() }) it('restarts and delivers a later send once the cause clears', async () => { await loseOwner() - acquire.mockRejectedValueOnce(new Error('Not signed in')) + spawn.mockRejectedValueOnce(new Error('Not signed in')) expect(await settled(await accept(sendParams('while signed out')))).toMatchObject({ dispatchState: 'rejected' }) @@ -493,13 +498,13 @@ describe('a send with no live owner', () => { // The user signed in; nothing about the failed attempt is remembered. await accept(sendParams('signed in now')) await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') }) it('suggests a new chat only when this host has nothing to restart the chat from', async () => { await loseOwner() - acquire.mockRejectedValue(new Error('Not signed in')) + spawn.mockRejectedValue(new Error('Not signed in')) const failed = await settled(await accept(sendParams('restart fails'))) expect(failed).toMatchObject({ dispatchState: 'rejected' }) expect(failed?.reason).not.toMatch(/new chat/) @@ -543,7 +548,7 @@ describe('a send with no live owner', () => { reason: cause, rejection: { kind: 'restartFailed', refusal: { code: 'execution_owner_reconciling' } } }) - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(await errorStatuses()).toEqual([cause]) }) @@ -580,7 +585,7 @@ describe('a send with no live owner', () => { await accept(sendParams('latched owner')) await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ handoffStage: null, claimStatus: 'live' @@ -598,7 +603,7 @@ describe('a send with no live owner', () => { await accept(sendParams('owner unverified')) await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ unreconciled: false, claimStatus: 'live' @@ -618,7 +623,7 @@ describe('a write fenced to an owner the pane has not seen replaced', () => { const id = await accept(params) expect(await settled(id)).toMatchObject({ dispatchState: 'accepted' }) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(seenFence + 1) }) @@ -630,7 +635,7 @@ describe('a write fenced to an owner the pane has not seen replaced', () => { const frames: AgentSessionSubscribeEvent[] = [] await host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => frames.push(event) }) await loseOwner() - acquire.mockRejectedValueOnce(new Error('Not signed in')) + spawn.mockRejectedValueOnce(new Error('Not signed in')) const subscribed = frames.length const id = await accept(sendParams('while signed out')) @@ -653,10 +658,10 @@ describe('a write fenced to an owner the pane has not seen replaced', () => { let claimed = () => {} let release = () => {} const claim = new Promise((resolve) => (claimed = resolve)) - const spawn = new Promise((resolve) => (release = resolve)) - acquire.mockImplementationOnce(async (input) => { + const spawnGate = new Promise((resolve) => (release = resolve)) + spawn.mockImplementationOnce(async (input) => { claimed() - await spawn + await spawnGate return spawnChild(input) }) @@ -693,7 +698,7 @@ describe('a write fenced to an owner the pane has not seen replaced', () => { dispatchState: 'rejected' }) // The Stop ended the child still proving its start, so the late send started another. - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) expect(dispatch).toHaveBeenCalledTimes(1) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index b11844207c50..0ae06cf9e099 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 @@ -1,7 +1,7 @@ // A session that published and then lost its child before startup (not signed in, say) keeps a // released lease and a chat the user can still type into. The send is the user asking for the // child back: the host accepts the message, and its delivery restarts the child and hands the -// message to the new owner, instead of parking it behind a lease nothing would ever re-acquire. +// message to the new owner, instead of parking it behind a lease nothing would ever re-spawn. // // A child is published before it has proven its start, and the host holds the send until it does, // then hands it over. When the child exits first, the message was never handed over: it is rejected @@ -32,6 +32,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-ho import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { structuredAgentSessionHeldStarts } from './structured-agent-session-held-start.test-support' const CALLER = { callerKey: 'client-1' } @@ -44,7 +45,8 @@ const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost -let acquire: Mock +const starts = structuredAgentSessionHeldStarts(() => host) +let spawn: Mock let dispatch: Mock let generation = 0 @@ -98,13 +100,7 @@ function currentChild() { } function proveStarted(): Promise { - return host.handleAdapterEvent({ - type: 'started', - ...currentChild(), - reportedOptions: { model: 'sonnet' }, - restoreSkippedOptions: [], - optionRevision: host.collaboratorsForTests().runtimeState.optionRevisions.current(SESSION) - }) + return starts.prove(currentChild()) } function exitBeforeProof(): Promise { @@ -139,12 +135,12 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-send-after-failed-start-')) resetHostTestOperationIds() generation = 0 - acquire = vi.fn(async ({ fence, spawnToken }) => ({ + spawn = vi.fn(async ({ fence, spawnToken }) => ({ process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, handle: codexProviderHandle(THREAD), - // A re-acquire resumes the thread the first child minted, as a real adapter does. + // A re-spawn resumes the thread the first child minted, as a real adapter does. origin: generation === 0 ? ('created' as const) : ('resumed' as const), mintedAtFence: fence, observedAt: NOW @@ -157,15 +153,20 @@ beforeEach(async () => { agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire, + adapter: starts.wrap({ + spawn: starts.held(spawn), releaseAcquisition: vi.fn(async () => true), closeSession: vi.fn(async () => true), dispatch, cancelTurn: vi.fn(async () => ({ cancelled: true })), answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, + setOption: vi.fn(async () => undefined), + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }) + }), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${generation + 1}`, @@ -177,6 +178,7 @@ beforeEach(async () => { }) afterEach(async () => { + starts.clear() await host.flushAllStreamedEvents() await rm(root, { recursive: true, force: true }) }) @@ -186,7 +188,7 @@ describe('a send into a published session whose child ended before startup', () await exitBeforeProof() // The failed start released the lease and no resume ran on its own. expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() }) it('restarts the child and holds the message for it until it proves its start', async () => { @@ -195,7 +197,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. - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) + await eventually(() => expect(spawn).toHaveBeenCalledTimes(2)) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') expect(dispatch).not.toHaveBeenCalled() const current = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -214,7 +216,7 @@ describe('a send into a published session whose child ended before startup', () providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } }) await handedOver(await send('and again', current)) - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) expect(dispatch).toHaveBeenCalledTimes(2) }) @@ -223,7 +225,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 eventually(() => expect(spawn).toHaveBeenCalledTimes(2)) await exitBeforeProof() // The child never proved its start, so it was handed nothing: the message is rejected, so @@ -255,11 +257,11 @@ describe('a send into a published session whose child ended before startup', () expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(releasedFence + 2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') // One spawn per user action: nothing restarted it a second time. - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) // 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') - await eventually(() => expect(acquire).toHaveBeenCalledTimes(3)) + await eventually(() => expect(spawn).toHaveBeenCalledTimes(3)) await proveStarted() await handedOver(retry) expect(dispatch).toHaveBeenCalledOnce() @@ -272,13 +274,13 @@ describe('a send while the child of the first start is still proving itself', () const id = await send('hello') expect(dispatch).not.toHaveBeenCalled() - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() await proveStarted() await handedOver(id) expect(dispatch).toHaveBeenCalledOnce() - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(await journalStatuses()).toEqual([]) }) @@ -295,7 +297,7 @@ describe('a send while the child of the first start is still proving itself', () rejection: STARTUP_FAILURE, recovered: true }) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(await journalStatuses()).toEqual([ 'Codex stopped before it finished starting. Send your message to try again.' ]) @@ -307,7 +309,7 @@ describe('a send while the child of the first start is still proving itself', () await handedOver(await send('hello')) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() 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 c8914204c133..42d2ff640834 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 @@ -53,7 +53,7 @@ vi.mock('../../runtime/agent-session-record-rows', async (importOriginal) => { let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost -let acquire: Mock +let spawn: Mock let releaseAcquisition: Mock> let dispatch: Mock @@ -67,7 +67,12 @@ function accepted(): AgentSessionDispatchOutcome { function adapter(): StructuredAgentSessionAdapter { return startsWhenPublished( { - acquire, + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn, releaseAcquisition, dispatch, cancelTurn: vi.fn(async () => ({ cancelled: true })), @@ -96,7 +101,8 @@ beforeEach(async () => { publishFault.failOnPublish = 0 publishFault.publishCount = 0 resetHostTestOperationIds() - acquire = vi.fn(async ({ fence }) => ({ + spawn = vi.fn(async ({ fence }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -170,7 +176,7 @@ describe('settled attach retry', () => { ).toMatchObject({ status: 'failed' }) await expect(host.attach(CALLER, hostTestAttachParams(2))).resolves.toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', handoffStage: null, @@ -180,12 +186,13 @@ describe('settled attach retry', () => { it('continues a safe same-host pending replay with the reserved token', async () => { const spawnTokens: string[] = [] - acquire.mockImplementation(async ({ fence, spawnToken }) => { + spawn.mockImplementation(async ({ fence, spawnToken }) => { spawnTokens.push(spawnToken) if (spawnTokens.length === 1) { throw new Error('reply lost') } return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -225,12 +232,13 @@ describe('settled attach retry', () => { it('releases a reservation a crash left ownerless at restart, so the next start goes ahead', async () => { const spawnTokens: string[] = [] - acquire.mockImplementation(async ({ fence, spawnToken }) => { + spawn.mockImplementation(async ({ fence, spawnToken }) => { spawnTokens.push(spawnToken) if (spawnTokens.length === 1) { throw new Error('reply lost') } return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -396,7 +404,7 @@ describe('settled attach retry', () => { }) it('records proven acquisition cleanup as durable death evidence', async () => { - acquire.mockRejectedValueOnce(new Error('resume rejected')) + spawn.mockRejectedValueOnce(new Error('resume rejected')) await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: false, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts index 5c82a2185a51..31d14348c8ac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts @@ -53,7 +53,7 @@ beforeEach(() => { adapter: { ...adapter(), closeSession: async () => { - const events = state.acquire.mock.calls.at(-1)?.[0]?.events + const events = state.spawn.mock.calls.at(-1)?.[0]?.events if (!events) { throw new Error('missing provider event sink') } @@ -82,7 +82,7 @@ beforeEach(() => { async function runningTurn(): Promise { await attach() - const events = hostTestState().acquire.mock.calls[0]?.[0].events + const events = hostTestState().spawn.mock.calls[0]?.[0].events if (!events) { throw new Error('missing provider event sink') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts index 204da81a2e6c..5cffae4ac80b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts @@ -35,7 +35,7 @@ async function exitWithUnwrittenSettlement(refusedWrites = 2): Promise { await foundRestTestChat(rig) const open = rig.host.collaboratorsForTests().sessions.get(SESSION)! const running = open.child! - rig.adapter.acquire.mock.calls + rig.adapter.spawn.mock.calls .at(-1)?.[0] .events?.appendItem( { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'working', ordinal: 50 }, @@ -81,7 +81,7 @@ describe('a turn its gone agent left running', () => { expect(JSON.stringify(items)).toContain( 'Codex stopped while this response was in progress. You can continue in this conversation.' ) - expect(rig.adapter.acquire).toHaveBeenCalledOnce() + expect(rig.adapter.spawn).toHaveBeenCalledOnce() }) it('is settled when the idle-closed conversation is opened again, and its reader sees it', async () => { @@ -99,7 +99,7 @@ describe('a turn its gone agent left running', () => { expect(JSON.stringify(reader.events)).toContain( 'Codex stopped while this response was in progress. You can continue in this conversation.' ) - expect(rig.adapter.acquire).toHaveBeenCalledOnce() + expect(rig.adapter.spawn).toHaveBeenCalledOnce() }) it('is settled by a read after a restart that reaches the chat before the restore does', async () => { @@ -111,6 +111,6 @@ describe('a turn its gone agent left running', () => { expect(await workingTurnState()).toBe('interrupted') await rig.host.restoreReadableSessions([SESSION]) expect(await workingTurnState()).toBe('interrupted') - expect(rig.adapter.acquire).toHaveBeenCalledOnce() + expect(rig.adapter.spawn).toHaveBeenCalledOnce() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 9efb8202f880..959909aefbc4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -304,7 +304,7 @@ const PROVEN: AgentSessionDeathEvidence = { lastProvenAliveAt: 100 } -describe('stale session state on a cold acquire', () => { +describe('stale session state on a cold spawn', () => { function journalWith(items: AgentJournalRenderItem[]) { const appendLifecycleBatch = vi.fn(async () => ({ epoch: 'epoch-1', sequence: 9 })) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the settle reads only these journal members. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index 70dcc73a3b46..d90943123b01 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -11,6 +11,27 @@ import type { import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { failedProviderChildStart } from './structured-agent-session-provider-child' +import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' + +/** A queued message whose child died while it waited inherits that child's failure. */ +export function startThatFailedWhileQueued( + session: StructuredAgentSessionHostSession, + oldest: AgentJournalSubmission +): { startKey: string | null; cause: StructuredAgentSessionStartFailureCause } | null { + const ended = failedProviderChildStart(session) + if ( + !ended || + oldest.acceptedSequence === undefined || + ended.endedAt.epoch !== session.journal.cursor().epoch || + ended.endedAt.sequence < oldest.acceptedSequence + ) { + return null + } + const cause = structuredAgentSessionEndedChildFailure(ended) + return cause ? { startKey: ended.generation, cause } : null +} export function refusedStructuredAgentSessionStart( record: AgentSessionRecord | null, 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 b32d8e50119c..efd0d715d526 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 @@ -118,7 +118,8 @@ beforeEach(async () => { logger: createStructuredAgentSessionLogger(), store, adapter: { - acquire: vi.fn(async ({ fence, spawnToken }) => { + open: () => new Promise(() => {}), + spawn: vi.fn(async ({ fence, spawnToken }) => { const child = { process: { hostId: 'local', 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..320e09d59289 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts @@ -31,13 +31,13 @@ export type StructuredAgentSessionStartupAttempt = { readonly options?: Readonly> /** The permission mode the start launches with; absent for an agent with none. */ readonly permissionMode?: AgentChatPermissionMode - /** Provider events may begin before acquisition returns. */ + /** Provider events may begin before spawn returns. */ readonly events?: StructuredAgentSessionEventSink /** Background work this start's child reports, scoped to the attempt. Unset until the host routes * child work by attempt; adapters report through their registration until then. */ readonly childWork?: (evidence: AgentChildWorkEvidence[]) => void /** Aborted by a close, a Stop admitted now, quit, or the startup limit: the adapter stops what it - * started and the acquire fails. */ + * started and spawn fails. */ readonly signal?: AbortSignal /** Any stdout frame or stderr line from the child: proof the start is still moving. */ readonly onOutput?: () => void diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts index b17fb7c9b169..e4744e451cd9 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 @@ -17,7 +17,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionAcquisition, - StructuredAgentSessionAcquireInput, + StructuredAgentSessionSpawnInput, StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' @@ -42,14 +42,22 @@ const eventually = (assertion: () => void | Promise): Promise => let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost -let acquire: Mock +let spawn: Mock let dispatch: Mock let closeSession: Mock> let setOption: Mock let startupLimits: Partial | undefined const providerStarted = vi.fn() +const openGates = new Map void>() -function acquisition(input: StructuredAgentSessionAcquireInput): AgentSessionAcquisition { +async function proveOpen(model = 'default'): Promise { + const generation = `generation-${spawn.mock.calls.length}` + await eventually(() => expect(openGates.has(generation)).toBe(true)) + openGates.get(generation)!(model) + openGates.delete(generation) +} + +function acquisition(input: StructuredAgentSessionSpawnInput): AgentSessionAcquisition { return { process: { hostId: 'local', @@ -57,7 +65,7 @@ function acquisition(input: StructuredAgentSessionAcquireInput): AgentSessionAcq processStartTimeMs: 1_700_000_000_000, spawnToken: input.spawnToken }, - acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + acquisitionGeneration: `generation-${spawn.mock.calls.length}`, link: { linkId: `link-${input.fence}`, handle: codexProviderHandle(THREAD), @@ -68,8 +76,8 @@ function acquisition(input: StructuredAgentSessionAcquireInput): AgentSessionAcq } } -/** An acquire that publishes at spawn, as every one does: the child proves its start later. */ -const publishFirst: StructuredAgentSessionAdapter['acquire'] = async (input) => acquisition(input) +/** A spawn that publishes its child before open proves readiness. */ +const publishFirst: StructuredAgentSessionAdapter['spawn'] = async (input) => acquisition(input) async function startHost(): Promise { host = new StructuredAgentSessionHost({ @@ -77,7 +85,17 @@ async function startHost(): Promise { logger: createStructuredAgentSessionLogger(), store, adapter: { - acquire, + open: ({ acquisitionGeneration }) => + new Promise((resolve) => { + openGates.set(acquisitionGeneration!, (model) => + resolve({ + reportedOptions: { model }, + restoreSkippedOptions: [], + optionRevision: optionRevisionNow() + }) + ) + }), + spawn, dispatch, closeSession, releaseAcquisition: vi.fn(async () => true), @@ -94,18 +112,18 @@ async function startHost(): Promise { }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + mintSpawnToken: () => `spawn-${spawn.mock.calls.length}`, idleSweep: { intervalMs: 60 * 60_000, idleMs: 60 * 60_000 }, ...(startupLimits === undefined ? {} : { startupLimits }), now: () => NOW }) } -/** A fresh host with no child, so the next send starts one through `acquire`. */ -async function restartWith(next: StructuredAgentSessionAdapter['acquire']): Promise { +/** A fresh host with no child, so the next send starts one through `spawn`. */ +async function restartWith(next: StructuredAgentSessionAdapter['spawn']): Promise { await host.close(SESSION, 'evict') await host.flushAllStreamedEvents() - acquire.mockImplementation(next) + spawn.mockImplementation(next) closeSession.mockClear() await startHost() } @@ -115,7 +133,8 @@ beforeEach(async () => { resetHostTestOperationIds() startupLimits = undefined providerStarted.mockReset() - acquire = vi.fn(async (input) => acquisition(input)) + openGates.clear() + spawn = vi.fn(async (input) => acquisition(input)) dispatch = vi.fn(async () => ({ state: 'accepted' as const, providerIdentity: { @@ -171,22 +190,10 @@ async function submission(id: string): Promise { const id = await accept(text) @@ -218,7 +225,7 @@ const HOST_STOPPED = agentSessionFailureWords(agentSessionFailureFact('hostStopp }) describe('a send while the agent starts', () => { - it('is accepted at once and handed over only once the start proves itself', async () => { + it('keeps a spawned child unopened until required protocol open proves readiness', async () => { await restartWith(publishFirst) const id = await heldBehindStart('hello') @@ -226,7 +233,7 @@ describe('a send while the agent starts', () => { expect((await submission(id))?.handedOverAt).toBeUndefined() expect(dispatch).not.toHaveBeenCalled() - await host.handleAdapterEvent(startedEvent()) + await proveOpen() await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) expect(dispatch).toHaveBeenCalledTimes(1) @@ -239,37 +246,19 @@ describe('a send while the agent starts', () => { // The same id again is the same message: it is recorded and handed over once. await accept('first', first) - await host.handleAdapterEvent(startedEvent()) + await proveOpen() await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([first, second]) }) - it('ignores a start proof from a child it no longer holds', async () => { - await restartWith(publishFirst) - const id = await heldBehindStart('hello') - - await host.handleAdapterEvent(startedEvent('generation-stale')) - await host.handleAdapterEvent({ ...startedEvent(), fence: startedEvent().fence + 1 }) - await host.flushStreamedEvents(SESSION) - - expect(dispatch).not.toHaveBeenCalled() - expect((await host.readStatusSummary(SESSION))?.hostExecutionPhase).toBe('starting') - await host.handleAdapterEvent(startedEvent()) - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - }) - it('tells the model catalog once the start proves itself, never for a child it no longer holds', async () => { await restartWith(publishFirst) providerStarted.mockClear() const id = await heldBehindStart('hello') expect(providerStarted).not.toHaveBeenCalled() - await host.handleAdapterEvent(startedEvent('generation-stale')) - await host.flushStreamedEvents(SESSION) - expect(providerStarted).not.toHaveBeenCalled() - - await host.handleAdapterEvent(startedEvent()) + await proveOpen() await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) expect(providerStarted).toHaveBeenCalledTimes(1) expect(providerStarted.mock.calls[0]?.[0]).toMatchObject({ sessionId: SESSION }) @@ -285,7 +274,7 @@ describe('a start that ends before it proves itself', () => { type: 'ended', sessionId: SESSION, fence: store.getRecord(SESSION)!.lease.runtimeFence, - acquisitionGeneration: `generation-${acquire.mock.calls.length}`, + acquisitionGeneration: `generation-${spawn.mock.calls.length}`, reason: 'exited with code 1', cause: 'unexpected-exit', startupUnproven: true @@ -364,7 +353,7 @@ describe('a start that ends before it proves itself', () => { } }) - it('aborts an acquire still in its handshake once it goes silent and fails what it held', async () => { + it('aborts an spawn still in its handshake once it goes silent and fails what it held', async () => { startupLimits = { silenceMs: 50 } await restartWith( (input) => @@ -381,7 +370,7 @@ describe('a start that ends before it proves itself', () => { await eventually(() => expect(startupAttemptOpen()).toBe(false)) }) - it('ends a start whose acquire is still running when the chat closes', async () => { + it('ends a start whose spawn is still running when the chat closes', async () => { let land = (): void => {} const landed = new Promise((resolve) => { land = resolve @@ -391,7 +380,7 @@ describe('a start that ends before it proves itself', () => { return acquisition(input) }) const id = await accept('hello') - await eventually(() => expect(acquire).toHaveBeenCalled()) + await eventually(() => expect(spawn).toHaveBeenCalled()) const closing = host.close(SESSION, 'evict') land() @@ -406,7 +395,7 @@ describe('a start that ends before it proves itself', () => { startupLimits = { silenceMs: 50 } await restartWith(publishFirst) const id = await heldBehindStart('hello') - await host.handleAdapterEvent(startedEvent()) + await proveOpen() await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) await new Promise((resolve) => setTimeout(resolve, 100)) @@ -421,12 +410,16 @@ describe('what a started child reports', () => { it('keeps provider reports out of the saved next-message choice', async () => { await restartWith(publishFirst) const id = await heldBehindStart('hello') - await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'reported' } }) + await proveOpen('reported') await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) await host.handleAdapterEvent({ - ...startedEvent(), type: 'options-reported', - reportedOptions: { model: 'from-settings' } + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${spawn.mock.calls.length}`, + reportedOptions: { model: 'from-settings' }, + restoreSkippedOptions: [], + optionRevision: optionRevisionNow() }) await host.flushStreamedEvents(SESSION) expect(store.getRecord(SESSION)?.options).toBeUndefined() @@ -441,12 +434,16 @@ describe('what a started child reports', () => { options: { model: 'picked' }, now: NOW }) - await host.handleAdapterEvent({ ...startedEvent(), reportedOptions: { model: 'reported' } }) + await proveOpen('reported') await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) await host.handleAdapterEvent({ - ...startedEvent(), type: 'options-reported', - reportedOptions: { model: 'from-settings' } + sessionId: SESSION, + fence: store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: `generation-${spawn.mock.calls.length}`, + reportedOptions: { model: 'from-settings' }, + restoreSkippedOptions: [], + optionRevision: optionRevisionNow() }) await host.flushStreamedEvents(SESSION) expect(store.getRecord(SESSION)?.options).toEqual({ model: 'picked' }) @@ -457,13 +454,13 @@ function startupAttemptOpen(): boolean { return host.collaboratorsForTests().runtimeState.startupAttempts.isOpen(SESSION) } -describe('the attempt an acquire runs under', () => { +describe('the attempt an spawn runs under', () => { it('is minted by the host with the lease, the pinned launch and its progress report', async () => { await restartWith(publishFirst) await heldBehindStart('hello') const record = store.getRecord(SESSION)! - const [input] = acquire.mock.calls.at(-1)! + const [input] = spawn.mock.calls.at(-1)! expect(input).toMatchObject({ fence: record.lease.runtimeFence, launch: { location: record.location, accountHome: record.accountHome } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-registrations.test.ts index 8c55910b3de7..2889760af749 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 @@ -16,7 +16,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-ho import type { StructuredAgentDefinition } from './structured-agent-definition' import { StructuredAgentRegistry } from './structured-agent-registry' import type { - StructuredAgentSessionAcquireInput, + StructuredAgentSessionSpawnInput, StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -55,23 +55,22 @@ function handle(definition: StructuredAgentDefinition): AgentSessionProviderHand async function hostFor(definition: StructuredAgentDefinition): Promise<{ host: StructuredAgentSessionHost store: AgentSessionRecordStore - acquire: Mock + spawn: Mock dispatch: Mock ready: () => void }> { root = await mkdtemp(join(tmpdir(), 'orca-startup-registrations-')) resetHostTestOperationIds() const store = await openTestAgentSessionRecordStore(root) - const acquire: Mock = vi.fn( - async (input: StructuredAgentSessionAcquireInput) => ({ + const spawn: Mock = vi.fn( + async (input: StructuredAgentSessionSpawnInput) => ({ process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken: input.spawnToken }, - acquisitionGeneration: `generation-${acquire.mock.calls.length}`, - providerChildPhase: 'starting' as const, + acquisitionGeneration: `generation-${spawn.mock.calls.length}`, link: { linkId: `link-${input.fence}`, handle: handle(definition), @@ -90,7 +89,7 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ new Promise((resolve) => { ready = () => resolve({ reportedOptions: {}, restoreSkippedOptions: [], optionRevision: 0 }) }), - acquire, + spawn, dispatch, closeSession: vi.fn(async () => true), releaseAcquisition: vi.fn(async () => true), @@ -110,10 +109,10 @@ async function hostFor(definition: StructuredAgentDefinition): Promise<{ adapter, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + mintSpawnToken: () => `spawn-${spawn.mock.calls.length}`, now: () => NOW }) - return { host, store, acquire, dispatch, ready: () => ready() } + return { host, store, spawn, dispatch, ready: () => ready() } } describe.each( @@ -122,7 +121,7 @@ describe.each( ) )('%s starts under the host attempt', (agent, definition) => { it('acquires under a host-minted attempt and is handed nothing until it proves its start', async () => { - const { host, store, acquire, dispatch, ready } = await hostFor(definition) + const { host, store, spawn, dispatch, ready } = await hostFor(definition) try { const attached = await host.attach( CALLER, @@ -130,13 +129,13 @@ describe.each( provider: agent, agent, accountHome: accountHome(definition), - // A new chat: the provider mints its handle at acquire. + // A new chat: the provider mints its handle at spawn. providerHandle: undefined }) ) expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true }) const record = store.getRecord(SESSION)! - expect(acquire.mock.calls[0]?.[0]).toMatchObject({ + expect(spawn.mock.calls[0]?.[0]).toMatchObject({ attemptId: expect.any(String), fence: record.lease.runtimeFence, launch: { location: record.location, accountHome: record.accountHome }, 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 1794e14c38e6..ac4d2975ab7f 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 @@ -120,7 +120,7 @@ describe('the startup clock', () => { expect(expired).toEqual([]) }) - it('fails a spawned agent that says nothing for the silence limit, naming the acquire', () => { + it('fails a spawned agent that says nothing for the silence limit, naming the spawn', () => { const attempt = mint() attempts.track(SESSION, attempt).spawned() @@ -131,7 +131,7 @@ describe('the startup clock', () => { expect(expired).toEqual([{ sessionId: SESSION, attemptId: attempt.attemptId, child: null }]) vi.advanceTimersByTime(CEILING_MS) expect(expired).toHaveLength(1) - // Measured at the limit, recorded once the aborted acquire settles. + // Measured at the limit, recorded once the aborted spawn settles. expect(settled).toEqual([]) attempts.abandon(SESSION, attempt.attemptId) expect(settled).toEqual([{ agent: 'codex', outcome: 'silent', durationMs: SILENCE_MS }]) @@ -234,7 +234,7 @@ describe('an attempt ends', () => { expect(vi.getTimerCount()).toBe(0) }) - it('when an acquire the expiry aborted is abandoned, or publishes late and is expired then', () => { + it('when an spawn the expiry aborted is abandoned, or publishes late and is expired then', () => { const abandoned = mint() attempts.track(SESSION, abandoned).spawned() vi.advanceTimersByTime(SILENCE_MS) 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 b2c170e5a074..e1496999405f 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 @@ -55,11 +55,11 @@ export function isStructuredAgentSessionStartupExpired(reason: unknown): boolean return reason instanceof Error && reason.name === 'StructuredAgentSessionStartupExpiredError' } -/** Where an expired attempt stood: still inside its acquire, or published as a starting child. */ +/** Where an expired attempt stood: still spawning, or published as a starting child. */ export type StructuredAgentSessionExpiredStartup = { sessionId: string attemptId: string - /** Null while the acquire has not returned. */ + /** Null while spawn has not returned. */ child: StructuredAgentSessionProviderChildIdentity | null } @@ -71,7 +71,7 @@ export type StructuredAgentSessionStartupSettled = { durationMs: number } -/** What the host learns about a start while its acquire runs. */ +/** What the host learns about a start while spawn runs. */ export type StructuredAgentSessionStartupProgress = { /** The process exists: the clock starts. */ spawned: () => void @@ -86,7 +86,7 @@ type Tracked = { lastOutputAt: number timer: ReturnType | null expired: boolean - /** The limit's measure, held until an acquire it aborted settles: one that is ready anyway + /** The limit's measure, held until an aborted spawn settles: one that is ready anyway * reports that instead. */ expiry: StructuredAgentSessionStartupSettled | null readiness: PromiseWithResolvers<'ready' | 'ended'> @@ -106,7 +106,7 @@ export class StructuredAgentSessionStartupAttempts { constructor( private readonly deps: { - /** Runs outside the session's queue: an acquire may hold it for the whole handshake. */ + /** Runs outside the session's queue: spawn or open may hold it through the handshake. */ expire: (expired: StructuredAgentSessionExpiredStartup) => void /** Never throws back into the clock. */ settled?: (settled: StructuredAgentSessionStartupSettled) => void @@ -184,7 +184,7 @@ export class StructuredAgentSessionStartupAttempts { } } - /** The acquire returned: its `starting` child stays on the clock until it proves its start, and + /** Spawn returned: its `starting` child stays on the clock until open proves readiness, and * one published after the limit passed is expired now. */ published( sessionId: string, @@ -300,7 +300,7 @@ export class StructuredAgentSessionStartupAttempts { } } - /** `reason` null: a start whose limit already passed inside its acquire was published now. */ + /** `reason` null: a start whose limit already passed inside spawn was published now. */ private expire(sessionId: string, tracked: Tracked, reason: 'silent' | 'ceiling' | null): void { if (this.open.get(sessionId) !== tracked || this.disposed) { return @@ -310,7 +310,7 @@ export class StructuredAgentSessionStartupAttempts { } tracked.expired = true // A published child's stop is under way and ends nothing a newer attempt owns; one still - // acquiring stays tracked until its aborted acquire is abandoned or published. + // spawning stays tracked until its aborted spawn is abandoned or published. if (tracked.child) { this.report(tracked.expiry) this.end(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts index 571664ae003f..e567a2690ee9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-permission-mode.test.ts @@ -14,16 +14,16 @@ import { it('keeps a permission pick while accepting the startup model report', async () => { const rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) try { - const acquire = rig.adapter.acquire.getMockImplementation() - if (!acquire) { + const spawn = rig.adapter.spawn.getMockImplementation() + if (!spawn) { throw new Error('missing test acquisition') } - rig.adapter.acquire.mockImplementationOnce(rig.heldStart(acquire)) + rig.adapter.spawn.mockImplementationOnce(rig.heldStart(spawn)) const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) if (!attached.ok) { throw new Error('test attach refused') } - const child = await rig.adapter.acquire.mock.results[0].value + const child = await rig.adapter.spawn.mock.results[0].value const fields = { key: 'permissionMode', value: 'full-access' } expect( await rig.host.setOption(CALLER, { @@ -41,14 +41,10 @@ it('keeps a permission pick while accepting the startup model report', async () }) ).toMatchObject({ ok: true }) - await rig.host.handleAdapterEvent({ - type: 'started', + await rig.proveStart({ sessionId: SESSION, fence: attached.fence, - acquisitionGeneration: child.acquisitionGeneration!, - reportedOptions: { model: 'provider-model', effort: 'medium' }, - restoreSkippedOptions: [], - optionRevision: 0 + acquisitionGeneration: child.acquisitionGeneration! }) await vi.waitFor(() => expect(rig.store.getRecord(SESSION)?.options).toEqual({ permissionMode: 'full-access' }) @@ -66,11 +62,11 @@ it.each([false, true])( hasOpenDispatch: () => hasOpenDispatch }) try { - const acquire = rig.adapter.acquire.getMockImplementation() - if (!acquire) { + const spawn = rig.adapter.spawn.getMockImplementation() + if (!spawn) { throw new Error('missing test acquisition') } - rig.adapter.acquire.mockImplementationOnce(rig.heldStart(acquire)) + rig.adapter.spawn.mockImplementationOnce(rig.heldStart(spawn)) const writes = vi.spyOn(rig.host.deps.adapter, 'setOption') const attached = await rig.host.attach(CALLER, { ...hostTestAttachParams(null), @@ -79,7 +75,7 @@ it.each([false, true])( if (!attached.ok) { throw new Error('test attach refused') } - const firstChild = await rig.adapter.acquire.mock.results[0].value + const firstChild = await rig.adapter.spawn.mock.results[0].value const sent = await rig.host.send(CALLER, restTestSend('held while starting', attached.fence)) expect(sent.ok).toBe(true) for (const [key, value] of [ @@ -107,14 +103,10 @@ it.each([false, true])( expect(writes).not.toHaveBeenCalled() expect(rig.adapter.dispatch).not.toHaveBeenCalled() - await rig.host.handleAdapterEvent({ - type: 'started', + await rig.proveStart({ sessionId: SESSION, fence: attached.fence, - acquisitionGeneration: firstChild.acquisitionGeneration!, - reportedOptions: { model: 'launched-model', effort: 'low' }, - restoreSkippedOptions: [], - optionRevision: -1 + acquisitionGeneration: firstChild.acquisitionGeneration! }) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledOnce()) expect(writes).not.toHaveBeenCalled() @@ -124,15 +116,15 @@ it.each([false, true])( }) const options = { model: 'picked-model', effort: 'high', permissionMode: 'full-access' } expect(rig.adapter.closeSession).toHaveBeenCalledTimes(hasOpenDispatch ? 0 : 1) - expect(rig.adapter.acquire).toHaveBeenCalledTimes(hasOpenDispatch ? 1 : 2) - expect(rig.adapter.acquire).toHaveBeenLastCalledWith( + expect(rig.adapter.spawn).toHaveBeenCalledTimes(hasOpenDispatch ? 1 : 2) + expect(rig.adapter.spawn).toHaveBeenLastCalledWith( expect.objectContaining( hasOpenDispatch ? { permissionMode: 'ask' } : { permissionMode: 'full-access', options: { model: 'launched-model', effort: 'low' } } ) ) - expect(rig.adapter.acquire.mock.invocationCallOrder[hasOpenDispatch ? 0 : 1]).toBeLessThan( + expect(rig.adapter.spawn.mock.invocationCallOrder[hasOpenDispatch ? 0 : 1]).toBeLessThan( rig.adapter.dispatch.mock.invocationCallOrder[0] ) expect(rig.store.getRecord(SESSION)?.options).toEqual(options) @@ -142,7 +134,7 @@ it.each([false, true])( const nextFence = rig.store.getRecord(SESSION)!.lease.runtimeFence expect((await rig.host.send(CALLER, restTestSend('after restart', nextFence))).ok).toBe(true) await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) - expect(rig.adapter.acquire).toHaveBeenLastCalledWith( + expect(rig.adapter.spawn).toHaveBeenLastCalledWith( expect.objectContaining({ permissionMode: 'full-access', options: { model: 'picked-model', effort: 'high' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts index bd0e0ff0543c..e33844095c88 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-picks.test.ts @@ -11,6 +11,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionProviderOpenResult } from './structured-agent-session-provider-open-contract' import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -32,6 +33,8 @@ let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let acquires = 0 let phases: (string | undefined)[] +let answerOpen: (model: string) => void +let opened: Promise const setOption = vi.fn() const dispatch = vi.fn() const closeSession = vi.fn(async () => true) @@ -43,6 +46,10 @@ beforeEach(async () => { resetHostTestOperationIds() acquires = 0 phases = [] + opened = new Promise((resolve) => { + answerOpen = (model) => + resolve({ reportedOptions: { model }, restoreSkippedOptions: [], optionRevision: 0 }) + }) setOption.mockReset().mockResolvedValue(undefined) dispatch.mockReset().mockResolvedValue({ state: 'unknown', reason: 'unused' }) closeSession.mockClear() @@ -55,7 +62,8 @@ beforeEach(async () => { adapter: { supportsCreate: (_location, agent) => agent === 'codex', supportsLocation: () => true, - acquire: async (input) => { + open: () => opened, + spawn: async (input) => { acquires += 1 return { process: { @@ -140,17 +148,10 @@ async function sendHeld(text: string): Promise { return sent.value.clientMessageId } -/** The child reports its start, as an adapter does once its protocol session answers. */ +/** Answer the host's pending protocol open. */ function started(reportedModel = 'launched-model'): Promise { - return host.handleAdapterEvent({ - type: 'started', - sessionId: SESSION, - fence: store.getRecord(SESSION)!.lease.runtimeFence, - acquisitionGeneration: `generation-${acquires}`, - reportedOptions: { model: reportedModel }, - restoreSkippedOptions: [], - optionRevision: 0 - }) + answerOpen(reportedModel) + return Promise.resolve() } async function dispatchState(clientMessageId: string) { @@ -168,7 +169,7 @@ it('saves a startup pick without writing it to the opening child', async () => { expect(setOption).not.toHaveBeenCalled() expect(store.getRecord(SESSION)?.options?.model).toBe('picked-model') - expect(phases.at(-1)).toBe('ready') + await vi.waitFor(() => expect(phases.at(-1)).toBe('ready')) }) it('hands a held message over with its accepted choice after a later startup pick', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts index b0c22c453fc1..c3b3c457d66b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts @@ -18,12 +18,12 @@ import { } from './structured-agent-session-host-test-data' let host: StructuredAgentSessionHost -let acquire: Mock +let spawn: Mock let cancelTurn: Mock let log: ReturnType['log'] beforeEach(() => { - ;({ host, acquire, cancelTurn, log } = hostTestState()) + ;({ host, spawn, cancelTurn, log } = hostTestState()) vi.spyOn(console, 'warn').mockImplementation(() => undefined) }) @@ -33,7 +33,7 @@ afterEach(() => { async function runningTurn(): Promise { await attach() - acquire.mock.calls + spawn.mock.calls .at(-1)![0] .events!.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 900 }, @@ -51,10 +51,8 @@ async function runningTurn(): Promise { /** A child whose handshake never answers, so a Stop ends the start. */ async function startingChild(): Promise<{ journal: AgentSessionJournal; closeSession: Mock }> { - const handle = host.handleAdapterEvent.bind(host) - vi.spyOn(host, 'handleAdapterEvent').mockImplementation(async (event) => - event.type === 'started' ? undefined : handle(event) - ) + const open: StructuredAgentSessionAdapter['open'] = () => new Promise(() => {}) + Object.assign(host.deps.adapter, { open }) const closeSession = vi.fn(async () => true) Object.assign(host.deps.adapter, { closeSession }) await attach() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts index 86ef2f177c44..0d0ef1148a44 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-opened-turn.test.ts @@ -51,7 +51,12 @@ async function stopWhileTheTurnOpens( agents: NO_STRUCTURED_AGENTS, agent: 'codex', adapter: { - acquire: vi.fn(), + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi.fn(), dispatch: vi.fn(), closeSession: vi.fn(), cancelTurn: vi.fn(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts index a710918fea32..2ea8d2127590 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts @@ -79,7 +79,12 @@ async function stopEndingTheChild(options: { agents: NO_STRUCTURED_AGENTS, agent: 'codex', adapter: { - acquire: vi.fn(), + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi.fn(), dispatch: vi.fn(), closeSession: vi.fn(), cancelTurn: vi.fn(async () => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-journal-retention.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-journal-retention.test.ts index c4d5153251f7..e17335179f14 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-journal-retention.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-journal-retention.test.ts @@ -73,9 +73,9 @@ async function seedFold() { throw new Error('Fixture attach was refused') } await rig.store.setSessionTabVisibility(SESSION, true) - const events = rig.adapter.acquire.mock.calls.at(-1)?.[0].events + const events = rig.adapter.spawn.mock.calls.at(-1)?.[0].events if (!events) { - throw new Error('Fixture provider did not acquire an event sink') + throw new Error('Fixture provider did not spawn an event sink') } const digest = createHash('sha256') for (let index = 0; index < COMMAND_COUNT; index += 1) { @@ -229,7 +229,7 @@ describe('subscriber journal lifetime', () => { const releasedWhileSubscribed = seeded.journal.deref() === undefined await restoreDurableHistory(seeded.digest) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() const sibling = observer() const disposeSibling = await rig.host.subscribe({ id: 'new-reader', 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 5d6c4ae351f6..a2018a49719b 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 @@ -56,7 +56,7 @@ const IDLE_MS = 1_000 let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost -let acquire: Mock +let spawn: Mock let closeSession: Mock> let dispatch: Mock let sink: StructuredAgentSessionEventSink | null @@ -66,7 +66,12 @@ let clock: number function adapter(): StructuredAgentSessionAdapter { return startsWhenPublished( { - acquire, + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn, closeSession, releaseAcquisition: vi.fn(async () => true), dispatch, @@ -91,7 +96,7 @@ function openHost( adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + mintSpawnToken: () => `spawn-${spawn.mock.calls.length}`, idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, now: () => clock, statusSink, @@ -104,7 +109,7 @@ async function reboot(): Promise { await host.flushAllStreamedEvents() store = await openTestAgentSessionRecordStore(root) openHost(async () => ({ outcome: 'pid-absent' })) - acquire.mockClear() + spawn.mockClear() closeSession.mockClear() } @@ -221,7 +226,7 @@ beforeEach(async () => { clock = NOW statusSink = { publish: vi.fn(), forget: vi.fn() } let generation = 0 - acquire = vi.fn(async ({ fence, spawnToken, events }) => { + spawn = vi.fn(async ({ fence, spawnToken, events }) => { sink = events ?? null return { process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, @@ -286,7 +291,7 @@ describe('a chat that closes', () => { emit: () => undefined }) unsubscribe() - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) @@ -434,7 +439,7 @@ describe('startup', () => { await host.restoreReadableSessions() // The record is readable — the tab comes back, history answers — and nothing is running. - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(host.listSessionTabs()).toEqual([ { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } ]) @@ -452,11 +457,11 @@ describe('startup', () => { sessionId: SESSION, emit: () => undefined }) - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() await startAgent() unsubscribe() - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', runtimeKind: 'native', @@ -569,9 +574,9 @@ describe('an unexpected provider exit', () => { replaceFailedSink() // Nothing restarts it on its own; the next send does, and the chat still loads. - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() await startAgent() - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) emitTurnLifecycle('running', 2) await host.flushAllStreamedEvents() await reboot() @@ -619,7 +624,7 @@ describe('an unexpected provider exit', () => { hasUnansweredStructuredAgentSessionDispatch(recoveredHistory.page.submissions) ).toBe(false) // No respawn: the exit is settled and shown, and the conversation waits for work. - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(dispatch).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', @@ -638,7 +643,7 @@ describe('an unexpected provider exit', () => { await vi.waitFor(async () => expect(await submissionState(nextEnvelope.clientOperationId)).toBe('accepted') ) - expect(acquire).toHaveBeenCalledTimes(2) + expect(spawn).toHaveBeenCalledTimes(2) expect(dispatch).toHaveBeenCalledTimes(2) }) @@ -659,7 +664,7 @@ describe('an unexpected provider exit', () => { fence: exitedFence, acquisitionGeneration: 'generation-stale' }) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') await host.handleAdapterEvent({ @@ -670,7 +675,7 @@ describe('an unexpected provider exit', () => { fence: exitedFence, acquisitionGeneration: 'generation-1' }) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', runtimeFence: exitedFence + 1, @@ -692,7 +697,7 @@ describe('an unexpected provider exit', () => { acquisitionGeneration: 'generation-1' }) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(host['sessions'].get(SESSION)?.child).toBeNull() expect(host['sessions'].get(SESSION)?.lastEndedChild).not.toHaveProperty('failure') expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') @@ -737,7 +742,7 @@ describe('an unexpected provider exit', () => { claimStatus: 'released', runtimeFence: exitedFence + 1 }) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(dispatch).toHaveBeenCalledOnce() expect(hostErrors).toContainEqual(expect.objectContaining({ message: 'journal failed' })) const history = await host.history({ sessionId: SESSION, direction: 'tail' }) @@ -812,7 +817,7 @@ describe('an unexpected provider exit', () => { deathEvidence: { kind: 'exit-observed', detail: 'provider exited', observedAt: NOW } }) // The retry recording the exit queues is refused too; once the journal writes again, the next - // acquire re-derives it. + // spawn re-derives it. await host.collaboratorsForTests().serialize(SESSION, async () => {}) refusing.mockRestore() @@ -829,8 +834,8 @@ describe('an unexpected provider exit', () => { await vi.waitFor(async () => expect(await submissionState(sentEnvelope.clientOperationId)).toBe('accepted') ) - expect(acquire).toHaveBeenCalledTimes(2) - // The new child's acquire settled the turn from the release's evidence: ended at the exit's + expect(spawn).toHaveBeenCalledTimes(2) + // The new child's spawn settled the turn from the release's evidence: ended at the exit's // receipt. The evidence is Orca's log text, so the row says only that the provider stopped. const history = await host.history({ sessionId: SESSION, direction: 'tail' }) const items = history.ok ? history.page.items : [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index f278825f5f55..e9858569d07d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -385,7 +385,12 @@ describe('what a conversation Stop reports when the provider stopped nothing', ( agents: NO_STRUCTURED_AGENTS, agent: 'codex', adapter: { - acquire: vi.fn(), + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi.fn(), dispatch: vi.fn(), closeSession: vi.fn(), cancelTurn: vi.fn(async () => outcome), @@ -470,7 +475,12 @@ describe('the note a Stop writes', () => { agents: NO_STRUCTURED_AGENTS, agent: 'codex', adapter: { - acquire: vi.fn(), + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi.fn(), dispatch: vi.fn(), closeSession: vi.fn(), cancelTurn: vi.fn(async () => ({ cancelled: true })), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index da1a4d5c21e0..2c3fca43c99c 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 @@ -14,7 +14,7 @@ import type { AgentSessionExecutionOptions } from '../../../shared/agent-session import { captureStructuredExecutionOptions } from './structured-agent-session-execution-options' import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' import { abortSignalReason } from '../../../shared/abort-signal-reason' -import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' +import type { StructuredAgentSessionProviderWaits } from './structured-agent-session-provider-waits' import { agentSessionFailureWords, type AgentSessionFailureWordsContext @@ -234,7 +234,7 @@ export async function performSend( } export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverContext & { - acquireAborts?: Pick + providerWaits?: Pick } /** @@ -269,7 +269,7 @@ export async function handOverSubmission( }) // The handover row's instant on the host clock; the turn this dispatch opens records it so the // live counter never re-anchors at turn-open. - const attempt = ctx.acquireAborts?.begin(ctx.sessionId, true) + const attempt = ctx.providerWaits?.begin(ctx.sessionId, true) let outcome: AgentSessionDispatchOutcome try { outcome = await dispatchSafely( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 6727b0144c03..99ec02c83f8e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -69,7 +69,7 @@ const DEAD_OWNER: AgentSessionProcessIdentity = { let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost -let acquire: Mock +let spawn: Mock type WedgeOverrides = { claimStatus: AgentSessionClaimStatus @@ -132,14 +132,19 @@ function openHost(overrides: Partial = {}): void logger: createStructuredAgentSessionLogger(), store, adapter: { - acquire, - releaseAcquisition: vi.fn(async () => undefined), + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn, + releaseAcquisition: vi.fn(async () => false), dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), setOption: vi.fn(), supportsCreate: () => true - } as unknown as StructuredAgentSessionAdapter, + }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-new', @@ -157,7 +162,8 @@ function startAgent(): Promise { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-wedged-profile-')) resetHostTestOperationIds() - acquire = vi.fn(async ({ fence }) => ({ + spawn = vi.fn(async ({ fence }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -279,7 +285,7 @@ describe('already-wedged profiles become usable on load', () => { claimStatus: 'released', handoffStage: null }) - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() await host.flushAllStreamedEvents() store = await openTestAgentSessionRecordStore(root) @@ -340,7 +346,7 @@ describe('already-wedged profiles become usable on load', () => { await host.restoreReadableSessions() - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(turnLifecycle('turn-1')).toEqual({ turnId: 'turn-1', startedAt: NOW - 5_000, @@ -384,7 +390,7 @@ describe('already-wedged profiles become usable on load', () => { expect(stale.ok || stale.refusal.code === 'agent_session_checkpoint_stale').toBe(true) expect(stale.ok || (await host.attach(CALLER, hostTestAttachParams(fence))).ok).toBe(true) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) // A pid probe proved the owner gone, so the turn was cut short when it was last proven alive. expect(turnLifecycle('turn-1')).toEqual({ @@ -422,7 +428,7 @@ describe('already-wedged profiles become usable on load', () => { expect(await host.attach(CALLER, hostTestAttachParams(13))).toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) // The exit was observed, so its receipt is the turn's end. expect(turnLifecycle('turn-1')).toEqual({ @@ -445,7 +451,7 @@ describe('already-wedged profiles become usable on load', () => { ['a restart eviction', false], ['a proven eviction by recovery', true] ] as const)( - 'settles the turn %s left at the next acquire when the read restore could not write it', + 'settles the turn %s left at the next spawn when the read restore could not write it', async (_origin, ownerOutlivedRestart) => { await seedStore( wedgedRecord({ claimStatus: 'live', handoffStage: null, ownerProcess: DEAD_OWNER }) @@ -478,7 +484,7 @@ describe('already-wedged profiles become usable on load', () => { await startAgent() - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live' }) // A pid probe proved the owner gone, so the turn was cut short when it was last proven alive. expect(turnLifecycle('turn-1')).toEqual({ @@ -524,7 +530,7 @@ describe('already-wedged profiles become usable on load', () => { deathEvidence: null }) expect(await host.attach(CALLER, hostTestAttachParams(14))).toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() }) it.each([ @@ -548,11 +554,11 @@ describe('already-wedged profiles become usable on load', () => { deathEvidence: null }) expect(await host.attach(CALLER, hostTestAttachParams(14))).toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() } ) - it('interrupts a running turn left behind by a released lease on a cold acquire', async () => { + it('interrupts a running turn left behind by a released lease on a cold spawn', async () => { // No settlement latch: the record was released cleanly, but the journal still says a turn is // running. Nothing observed the child's exit, but the runtime that held it was replaced, and // nothing after its start proves it alive. @@ -562,7 +568,7 @@ describe('already-wedged profiles become usable on load', () => { expect(await host.attach(CALLER, hostTestAttachParams(13))).toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(turnLifecycle('turn-1')).toEqual({ turnId: 'turn-1', state: 'interrupted', @@ -597,7 +603,7 @@ describe('already-wedged profiles become usable on load', () => { // A reconnecting client replays its attach; the same operation admits the live owner. expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true }) - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(turnLifecycle('turn-2')).toEqual({ turnId: 'turn-2', state: 'running', startedAt: NOW }) }) @@ -627,7 +633,7 @@ describe('already-wedged profiles become usable on load', () => { }) // Why NOT acquired here: startup spawning a provider child for every recovered record is the // accumulation this stack removed. Unlatching is the migration's job; spawning is a hold's. - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() }) it('unlatches a released record that reloaded into recovery with nothing outstanding', async () => { @@ -692,9 +698,10 @@ describe('already-wedged profiles become usable on load', () => { [DEAD_OWNER.spawnToken, [12_546]] ]) ) - acquire.mockImplementation(async ({ fence }) => { - order.push('acquire') + spawn.mockImplementation(async ({ fence }) => { + order.push('spawn') return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-new' }, link: { linkId: `link-${fence}`, @@ -716,7 +723,7 @@ describe('already-wedged profiles become usable on load', () => { const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) - expect(order).toEqual(['acquire']) + expect(order).toEqual(['spawn']) }) it('waits out a conflicted owner it cannot verify, signalling nothing, until it is proven gone', async () => { @@ -738,12 +745,12 @@ describe('already-wedged profiles become usable on load', () => { ok: false, refusal: { code: 'agent_session_conflict' } }) - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() // The user quits that terminal: the next start proves it gone and the chat takes over. probe = { outcome: 'pid-absent' } await startAgent() - expect(acquire).toHaveBeenCalledOnce() + expect(spawn).toHaveBeenCalledOnce() expect(stopOwnerProcess).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-child-work.test.ts index c44236ce34ba..4e8b05ffd7ea 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-child-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-child-work.test.ts @@ -48,7 +48,7 @@ function devServer(overrides: Partial = {}): AgentChildWorkV /** The provider ends the command's turn, as the child's journal translator does. */ function finish(): void { const { command } = compact.mock.calls.at(-1)![0] - const events = state.acquire.mock.calls.at(-1)![0].events! + const events = state.spawn.mock.calls.at(-1)![0].events! events.appendLifecycleBatch!( `turn-completed:${command.clientMessageId}`, [ @@ -141,9 +141,9 @@ describe('conversation command admission reads the strip’s child records', () }) it('refuses a queued command at handover once the strip lists live work', async () => { - const acquireChild = state.acquire.getMockImplementation()! + const acquireChild = state.spawn.getMockImplementation()! // Each child names its generation, so the exit below ends exactly the one it names. - state.acquire.mockImplementation(async (input) => ({ + state.spawn.mockImplementation(async (input) => ({ ...(await acquireChild(input)), acquisitionGeneration: `generation-${input.fence}` })) @@ -160,8 +160,8 @@ describe('conversation command admission reads the strip’s child records', () }) await vi.waitFor(() => expect(state.host['sessions'].get(HOST_TEST_SESSION)?.child).toBeNull()) // The records gain live work while that start runs, after the command was accepted. - const start = state.acquire.getMockImplementation()! - state.acquire.mockImplementationOnce(async (input) => { + const start = state.spawn.getMockImplementation()! + state.spawn.mockImplementationOnce(async (input) => { records = [devServer()] return start(input) }) @@ -174,7 +174,7 @@ describe('conversation command admission reads the strip’s child records', () ) expect(submission?.dispatchState).toBe('rejected') }) - expect(state.acquire).toHaveBeenCalledTimes(2) + expect(state.spawn).toHaveBeenCalledTimes(2) expect(compact).not.toHaveBeenCalled() }) }) 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 a6b8999e1f28..6844dc23812e 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -103,21 +103,14 @@ beforeEach(async () => { adapter = { supportsLocation: (location) => location.executionHostId === 'local' && location.wslDistro === null, - acquire: vi.fn(async (input) => { + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi.fn(async (input) => { acquisitions++ const acquisitionGeneration = `acquisition-${acquisitions}` - const starter = host - // The handshake answers at once, reporting what the child runs with. - void starter.handleAdapterEvent({ - type: 'started', - sessionId: input.identity.sessionId, - fence: input.fence, - acquisitionGeneration, - reportedOptions: { model: '' }, - restoreSkippedOptions: [], - // Never admitted, so the empty report persists nothing. - optionRevision: -1 - }) return { acquisitionGeneration, process: { @@ -221,7 +214,7 @@ describe('same-conversation clear', () => { expect(after.cursor.epoch).toBe(before.cursor.epoch) expect(after.items.slice(0, before.items.length)).toEqual(before.items) expect(await clearRows()).toHaveLength(1) - expect(adapter.acquire).toHaveBeenCalledTimes(1) + expect(adapter.spawn).toHaveBeenCalledTimes(1) expect( host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.lastEndedChild?.cause ).toBe('context-clear') @@ -234,7 +227,7 @@ describe('same-conversation clear', () => { params.envelope.expectedRuntimeFence = done.fence expect(await host.send(caller, params)).toMatchObject({ ok: true }) await vi.waitFor(() => expect(adapter.dispatch).toHaveBeenCalledTimes(1)) - const last = vi.mocked(adapter.acquire).mock.calls.at(-1)![0] + const last = vi.mocked(adapter.spawn).mock.calls.at(-1)![0] expect(last.identity.providerHandle).toBeNull() const head = store.getRecord(HOST_TEST_SESSION)!.providerHandleChain.at(-1)! expect(head.handle.nativeId).not.toBe(previous) @@ -256,7 +249,7 @@ describe('same-conversation clear', () => { expect(store.getRecord(HOST_TEST_SESSION)!.providerContextBoundary).not.toEqual(first) expect(await clearRows()).toHaveLength(2) expect(store.listRecords()).toHaveLength(1) - expect(adapter.acquire).toHaveBeenCalledTimes(1) + expect(adapter.spawn).toHaveBeenCalledTimes(1) }) it('gives different callers distinct boundaries even when their operation IDs match', async () => { @@ -314,7 +307,7 @@ describe('same-conversation clear', () => { await expect(host.conversationCommand(caller, commandParams('clear'))).rejects.toThrow() expect(store.getRecord(HOST_TEST_SESSION)!.providerContextBoundary).toBeUndefined() expect(await clearRows()).toHaveLength(0) - expect(adapter.acquire).toHaveBeenCalledTimes(1) + expect(adapter.spawn).toHaveBeenCalledTimes(1) }) it('starts fresh after a restart between clear and first send', async () => { @@ -323,7 +316,7 @@ describe('same-conversation clear', () => { await host.restoreReadableSessions(store.listVisibleSessionIds()) expect(await host.send(caller, sendParams('after restart'))).toMatchObject({ ok: true }) await vi.waitFor(() => expect(adapter.dispatch).toHaveBeenCalledTimes(1)) - expect(vi.mocked(adapter.acquire).mock.calls.at(-1)![0].identity.providerHandle).toBeNull() + expect(vi.mocked(adapter.spawn).mock.calls.at(-1)![0].identity.providerHandle).toBeNull() expect(store.getRecord(HOST_TEST_SESSION)!.providerHandleChain).toHaveLength(1) }) @@ -331,7 +324,7 @@ describe('same-conversation clear', () => { clock += STRUCTURED_AGENT_SESSION_IDLE_MS + 1 await host.collaboratorsForTests().lifetime.idleSweep.tick() await clearCommits() - expect(adapter.acquire).toHaveBeenCalledTimes(1) + expect(adapter.spawn).toHaveBeenCalledTimes(1) }) it('refuses another clear while the first is in flight', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 20d36a830542..70816926bbf4 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -57,7 +57,7 @@ beforeEach(() => { * one result row and its turn's end, in one batch. */ function finish(result: StructuredConversationCommandOutcome): void { const { command } = compact.mock.calls.at(-1)![0] - const events = state.acquire.mock.calls.at(-1)![0].events! + const events = state.spawn.mock.calls.at(-1)![0].events! const turnScope = { kind: 'turn' as const, turnItemId: agentJournalItemKey(command.identity) } const row: AgentJournalItemBody | null = result.outcome === 'success' @@ -370,7 +370,7 @@ it('refuses the command for an agent that does not declare compaction, whatever it('refuses the command at handover when the provider opened a turn meanwhile (B3)', async () => { await attach() - const events = state.acquire.mock.calls.at(-1)?.[0].events + const events = state.spawn.mock.calls.at(-1)?.[0].events const params = compactParams() // Held, the command is accepted, then the provider starts a turn of its own, both ahead of the // handover the acceptance asks for. @@ -415,7 +415,7 @@ it('refuses the command at handover when the provider opened a turn meanwhile (B it('leaves a command whose start failed not sent, beside one start-failure row (B3)', async () => { await attach() await state.host.close(SESSION, 'evict') - state.acquire.mockRejectedValue(new Error('not signed in')) + state.spawn.mockRejectedValue(new Error('not signed in')) const params = compactParams() // The next step is the command again, not a message. @@ -524,7 +524,7 @@ it('ends the command by stopping the child when the provider cannot take the Sto // The next message starts a child of its own. await state.host.send(CALLER, sendParams('after the stop')) await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce(), { interval: 1 }) - expect(state.acquire).toHaveBeenCalledTimes(2) + expect(state.spawn).toHaveBeenCalledTimes(2) }) it('does not stop the child for a Stop naming a command that already ended (B4)', async () => { @@ -626,7 +626,7 @@ it('settles a command whose adapter call threw after the start as unknown (B4)', }) it('writes one exit row when the child dies mid-command, and the loop writes nothing but moves on (B4)', async () => { - state.acquire.mockImplementation(async ({ fence, spawnToken }) => ({ + state.spawn.mockImplementation(async ({ fence, spawnToken }) => ({ process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, acquisitionGeneration: `generation-${fence}`, link: { @@ -663,7 +663,7 @@ it('writes one exit row when the child dies mid-command, and the loop writes not ) // Released from the command, the loop starts a child and delivers what waited behind it. await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce(), { interval: 1 }) - expect(state.acquire).toHaveBeenCalledTimes(2) + expect(state.spawn).toHaveBeenCalledTimes(2) const snapshot = await journal() expect( snapshot.items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error') @@ -676,7 +676,7 @@ it('writes one exit row when the child dies mid-command, and the loop writes not }) it('delivers the next message after a command whose child died and whose settlement could not be written', async () => { - state.acquire.mockImplementation(async ({ fence, spawnToken }) => ({ + state.spawn.mockImplementation(async ({ fence, spawnToken }) => ({ process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, acquisitionGeneration: `generation-${fence}`, link: { @@ -719,7 +719,7 @@ it('delivers the next message after a command whose child died and whose settlem ok: true }) await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce(), { interval: 1 }) - expect(state.acquire).toHaveBeenCalledTimes(2) + expect(state.spawn).toHaveBeenCalledTimes(2) expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).not.toBe('running') }) @@ -776,7 +776,7 @@ it('never lets a provider echo alias the command entry', async () => { await state.host.conversationCommand(CALLER, params) await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 }) finish({ outcome: 'success' }) - const events = state.acquire.mock.calls.at(-1)?.[0].events + const events = state.spawn.mock.calls.at(-1)?.[0].events const echo = { provider: 'codex' as const, threadId: THREAD, turnId: 'later', ordinal: 0 } events?.appendItem( echo, diff --git a/src/main/native-chat/structured-chat-naming-command-first.test.ts b/src/main/native-chat/structured-chat-naming-command-first.test.ts index 730b66be5416..337bfe92bccb 100644 --- a/src/main/native-chat/structured-chat-naming-command-first.test.ts +++ b/src/main/native-chat/structured-chat-naming-command-first.test.ts @@ -22,7 +22,7 @@ describe.each(['claude', 'codex'] as const)('%s command-first naming', (provider it.each(['Please repair login', ''])( 'keeps the first real input eligible after compact: %j', async (input) => { - const { host, store, acquire, dispatch, log } = hostTestState() + const { host, store, spawn, dispatch, log } = hostTestState() const compact = vi.fn>(async () => ({ state: 'accepted', providerIdentity: null @@ -32,7 +32,8 @@ describe.each(['claude', 'codex'] as const)('%s command-first naming', (provider provider === 'claude' ? claudeProviderHandle(HOST_TEST_THREAD, null) : codexProviderHandle(HOST_TEST_THREAD) - acquire.mockImplementation(async ({ fence, spawnToken }) => ({ + spawn.mockImplementation(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -91,7 +92,7 @@ describe.each(['claude', 'codex'] as const)('%s command-first naming', (provider summary.status === 'working' && options.firstInputSubmissionKey === null ) ).toBe(true) - const events = acquire.mock.calls.at(-1)?.[0].events + const events = spawn.mock.calls.at(-1)?.[0].events const command = compact.mock.calls[0]?.[0].command if (!events || !command) { throw new Error('Missing actual compact turn') diff --git a/src/main/native-chat/structured-chat-naming-status-hook.test.ts b/src/main/native-chat/structured-chat-naming-status-hook.test.ts index f00d207591dc..53f62b5dab3c 100644 --- a/src/main/native-chat/structured-chat-naming-status-hook.test.ts +++ b/src/main/native-chat/structured-chat-naming-status-hook.test.ts @@ -27,12 +27,13 @@ describe.each(['claude', 'codex'] as const)('%s first-message naming status hook ] as const)( 'names a live $outcome send (first turn completes during journal read: $completesDuringRead)', async ({ outcome, completesDuringRead }) => { - const { host, store, acquire, dispatch, log } = hostTestState() + const { host, store, spawn, dispatch, log } = hostTestState() const handle = provider === 'claude' ? claudeProviderHandle(HOST_TEST_THREAD, null) : codexProviderHandle(HOST_TEST_THREAD) - acquire.mockImplementation(async ({ fence }) => ({ + spawn.mockImplementation(async ({ fence }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -111,12 +112,12 @@ describe.each(['claude', 'codex'] as const)('%s first-message naming status hook expect(generate).not.toHaveBeenCalled() if (completesDuringRead) { await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) - const events = acquire.mock.calls.at(-1)?.[0].events + const events = spawn.mock.calls.at(-1)?.[0].events const first = (await host.journalSnapshot(HOST_TEST_SESSION)).items.find( (item) => item.body.kind === 'message' && item.body.role === 'user' ) if (!events || !first) { - throw new Error('Missing acquired first turn') + throw new Error('Missing spawnd first turn') } events.appendItem( provider === 'claude' diff --git a/src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts b/src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts index e86bff86da67..33fe246825af 100644 --- a/src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts +++ b/src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts @@ -52,7 +52,7 @@ function idleMonitor() { } function providerEvents() { - const events = rig.adapter.acquire.mock.calls.at(-1)?.[0].events + const events = rig.adapter.spawn.mock.calls.at(-1)?.[0].events if (!events) { throw new Error('no provider was started') } diff --git a/src/main/pi/rpc-configured-default.test.ts b/src/main/pi/rpc-configured-default.test.ts index c6e684dc4827..2809db1edc2e 100644 --- a/src/main/pi/rpc-configured-default.test.ts +++ b/src/main/pi/rpc-configured-default.test.ts @@ -197,7 +197,7 @@ async function piHost() { logger: { warn: vi.fn(), error: vi.fn() } }) adapters.push(adapter) - const acquired = await adapter.acquire({ + const spawned = await adapter.spawn({ identity: { sessionId, workspaceId: 'ws-1', @@ -210,21 +210,21 @@ async function piHost() { options: opts.saved ?? {}, events: rig.eventSink }) - const started = await adapter + const opened = await adapter .open({ sessionId, fence: 1, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: spawned.acquisitionGeneration, signal: new AbortController().signal }) .catch(async (error: unknown) => { await adapter.closeSession(sessionId) throw error }) - if (!started.catalogListing) { + if (!opened.catalogListing) { throw new Error('a Pi start hands the host its listing') } - catalog.recordLiveListing(sessionId, started.catalogListing) + catalog.recordLiveListing(sessionId, opened.catalogListing) if (opts.afterStart && child) { await opts.afterStart({ child, adapter, sessionId }) } @@ -235,7 +235,7 @@ async function piHost() { } } await read() - return { listing: started.catalogListing, read } + return { listing: opened.catalogListing, read } } return { catalog, chat, store, clock } } diff --git a/src/main/pi/rpc-location.test.ts b/src/main/pi/rpc-location.test.ts index b3c500ba45ff..72b0ddf0a903 100644 --- a/src/main/pi/rpc-location.test.ts +++ b/src/main/pi/rpc-location.test.ts @@ -27,7 +27,7 @@ describe('Pi execution host support', () => { vi.mocked(isWindowsProcessStartTimeAvailable).mockReturnValue(proof) const adapter = new PiRpcSessionAdapter({ resolveLaunch: async () => { - throw new Error('A location check must not acquire a process') + throw new Error('A location check must not spawn a process') }, onLifecycle: vi.fn(), onSettled: vi.fn(), diff --git a/src/main/pi/rpc-session-adapter.test-fixture.ts b/src/main/pi/rpc-session-adapter.test-fixture.ts index 10810ec4abf0..a97d79173ecc 100644 --- a/src/main/pi/rpc-session-adapter.test-fixture.ts +++ b/src/main/pi/rpc-session-adapter.test-fixture.ts @@ -1,6 +1,6 @@ import { expect, vi, type Mock } from 'vitest' import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' -import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionSpawnInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { closeProviderTimelineRigs, openProviderTimelineRig @@ -131,7 +131,7 @@ export async function setup( const onSpawned = vi.fn(async (_process: AgentSessionProcessIdentity) => { expect(connections.at(-1)?.requests).toEqual([]) }) - const input: StructuredAgentSessionAcquireInput = { + const input: StructuredAgentSessionSpawnInput = { identity: { sessionId, workspaceId: 'folder-1', @@ -167,11 +167,11 @@ export async function setup( onIdle: idle, logger: { warn: vi.fn(), error: vi.fn() } }) - const acquired = await adapter.acquire(input) + const acquired = await adapter.spawn(input) const started = await adapter.open({ sessionId, fence: 7, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) lifecycle.mockClear() @@ -204,7 +204,7 @@ export async function setup( export async function restart( h: Awaited>, configure: (connection: FakeConnection) => void, - input: Partial = {} + input: Partial = {} ) { await h.adapter.closeSession(sessionId) await h.adapter.drainObservedExits() @@ -215,7 +215,7 @@ export async function restart( h.connections.push(connection) return connection }) - const acquired = await h.adapter.acquire({ ...h.input, fence: 8, ...input }) + const acquired = await h.adapter.spawn({ ...h.input, fence: 8, ...input }) const child = h.connections.at(-1) if (!child) { throw new Error('child missing') diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 4e0dd224f75e..0cec0121ffa0 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -19,7 +19,7 @@ describe('Pi RPC session ownership and delivery', () => { const h = await setup() await h.adapter.closeSession(sessionId) h.resolveLaunch.mockRejectedValueOnce(agentSessionLaunchFolderMissing('/gone/floating')) - await expect(h.adapter.acquire({ ...h.input, fence: 8 })).rejects.toMatchObject({ + await expect(h.adapter.spawn({ ...h.input, fence: 8 })).rejects.toMatchObject({ name: 'AgentSessionPreSpawnError', reason: 'launchFolderMissing' }) @@ -33,12 +33,12 @@ describe('Pi RPC session ownership and delivery', () => { const resolving = Promise.withResolvers>() h.resolveLaunch.mockImplementationOnce(() => resolving.promise) const controller = new AbortController() - const started = h.adapter.acquire({ ...h.input, fence: 8, signal: controller.signal }) + const started = h.adapter.spawn({ ...h.input, fence: 8, signal: controller.signal }) await Promise.resolve() controller.abort(new Error('Pi closed while starting')) await expect(started).rejects.toThrow('closed while starting') expect(h.connections).toHaveLength(1) - await expect(h.adapter.acquire({ ...h.input, fence: 9 })).resolves.toMatchObject({ + await expect(h.adapter.spawn({ ...h.input, fence: 9 })).resolves.toMatchObject({ process: { pid: 4123 } }) resolving.resolve(launch!) @@ -46,7 +46,7 @@ describe('Pi RPC session ownership and delivery', () => { expect(h.connections).toHaveLength(2) }) - it('closes a published child during a stalled handshake without reporting started', async () => { + it('closes a published child during a stalled handshake', async () => { const h = await setup() await h.adapter.closeSession(sessionId) const opened = Promise.withResolvers() @@ -60,7 +60,7 @@ describe('Pi RPC session ownership and delivery', () => { opened.resolve(connection) return connection }) - const acquired = h.adapter.acquire({ ...h.input, fence: 8 }) + const acquired = h.adapter.spawn({ ...h.input, fence: 8 }) const child = await opened.promise await Promise.resolve() const owned = await acquired @@ -69,7 +69,7 @@ describe('Pi RPC session ownership and delivery', () => { .open({ sessionId, fence: 8, - acquisitionGeneration: owned.acquisitionGeneration ?? null, + acquisitionGeneration: owned.acquisitionGeneration, signal: new AbortController().signal }) .catch((error: unknown) => error) @@ -85,9 +85,6 @@ describe('Pi RPC session ownership and delivery', () => { cause: 'requested-close' }) ) - expect(h.lifecycle).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'started', fence: 8 }) - ) expect(child.rootVerdict).toBe('exited') expect(await opening).toBeInstanceOf(Error) }) @@ -133,7 +130,7 @@ describe('Pi RPC session ownership and delivery', () => { h.connection.deferExit = true await h.adapter.closeSession(sessionId) h.adapter.acknowledgeSessionRelease(sessionId) - await expect(h.adapter.acquire({ ...h.input, fence: 8 })).resolves.toMatchObject({ + await expect(h.adapter.spawn({ ...h.input, fence: 8 })).resolves.toMatchObject({ process: { pid: 4123 } }) let drained = false @@ -572,12 +569,12 @@ describe('Pi RPC start without a model', () => { h.connections.push(connection) return connection }) - const acquired = await h.adapter.acquire({ ...h.input, fence: 8 }) + const acquired = await h.adapter.spawn({ ...h.input, fence: 8 }) await expect( h.adapter.open({ sessionId, fence: 8, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) ).rejects.toMatchObject({ reason: 'notSignedIn' }) diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index e34fc01905c8..07bb6851c063 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -6,7 +6,7 @@ import { AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionExitProvenError, type AgentSessionAcquisition, - type StructuredAgentSessionAcquireInput, + type StructuredAgentSessionSpawnInput, type StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { providerSpawnedProcessIdentity } from '../provider-process/provider-spawned-process-identity' @@ -21,7 +21,7 @@ import { supportsSupervisedProviderChildLocation } from '../provider-process/sup export type PiRpcSessionAdapterDeps = PiRpcSessionDeps & { resolveLaunch: (input: { - identity: StructuredAgentSessionAcquireInput['identity'] + identity: StructuredAgentSessionSpawnInput['identity'] spawnToken: string }) => Promise readProcessStartTime?: (pid: number) => Promise @@ -40,7 +40,7 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { agent ) => agent === 'pi' && this.supportsLocation(location) - async acquire(input: StructuredAgentSessionAcquireInput): Promise { + async spawn(input: StructuredAgentSessionSpawnInput): Promise { const id = input.identity.sessionId if (this.acquiring.has(id)) { throw new AgentSessionPreSpawnError(new Error('Pi session already owns a child')) diff --git a/src/main/pi/rpc-session-startup.test.ts b/src/main/pi/rpc-session-startup.test.ts index eb36ecfcd1cf..8de8612da48b 100644 --- a/src/main/pi/rpc-session-startup.test.ts +++ b/src/main/pi/rpc-session-startup.test.ts @@ -11,7 +11,7 @@ import { afterEach(closePiRpcAdapterFixtures) describe('Pi host awaited open', () => { - it('owns the process before RPC and leaves message choices for input', async () => { + it('owns the process before RPC and leaves saved choices for message input', async () => { const h = await setup() const stateReply = Promise.withResolvers() const { acquired, child } = await restart( @@ -31,7 +31,7 @@ describe('Pi host awaited open', () => { const opening = h.adapter.open({ sessionId, fence: 8, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) expect(child.requests).toEqual(['get_state']) @@ -41,7 +41,6 @@ describe('Pi host awaited open', () => { }) expect(child.requests).not.toContain('set_model') expect(child.requests).not.toContain('set_thinking_level') - expect(h.lifecycle).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) }) it('rejects a known empty listing once, then opens a fresh child after sign-in', async () => { @@ -54,19 +53,19 @@ describe('Pi host awaited open', () => { h.adapter.open({ sessionId, fence: 8, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) ).rejects.toMatchObject({ reason: 'notSignedIn' }) expect(child.closed).toBe(false) expect(child.sent).toEqual([]) await h.adapter.closeSession(sessionId) - const again = await h.adapter.acquire({ ...h.input, fence: 9 }) + const again = await h.adapter.spawn({ ...h.input, fence: 9 }) await expect( h.adapter.open({ sessionId, fence: 9, - acquisitionGeneration: again.acquisitionGeneration ?? null, + acquisitionGeneration: again.acquisitionGeneration, signal: new AbortController().signal }) ).resolves.toMatchObject({ reportedOptions: { model: 'anthropic/model-1' } }) @@ -85,7 +84,7 @@ describe('Pi host awaited open', () => { h.adapter.open({ sessionId, fence: 8, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) ).rejects.toMatchObject({ reason: 'notSignedIn' }) @@ -104,7 +103,7 @@ describe('Pi host awaited open', () => { h.adapter.open({ sessionId, fence: 8, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: new AbortController().signal }) ).resolves.toMatchObject({ reportedOptions: { model: 'anthropic/model-1' } }) @@ -123,7 +122,7 @@ describe('Pi host awaited open', () => { .open({ sessionId, fence: 8, - acquisitionGeneration: acquired.acquisitionGeneration ?? null, + acquisitionGeneration: acquired.acquisitionGeneration, signal: controller.signal }) .catch((error: unknown) => error) @@ -132,6 +131,5 @@ describe('Pi host awaited open', () => { await h.adapter.closeSession(sessionId) expect(await opening).toBeInstanceOf(Error) expect(child.rootVerdict).toBe('exited') - expect(h.lifecycle).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'started' })) }) }) diff --git a/src/main/pi/rpc-session-startup.ts b/src/main/pi/rpc-session-startup.ts index a1b2badef6c4..f089f120f680 100644 --- a/src/main/pi/rpc-session-startup.ts +++ b/src/main/pi/rpc-session-startup.ts @@ -7,7 +7,7 @@ import type { } from '../../shared/agent-session-wire' import { AgentSessionAcquisitionRefusal, - type StructuredAgentSessionAcquireInput + type StructuredAgentSessionSpawnInput } 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 { @@ -32,7 +32,7 @@ export class PiRpcSessionStartup { options?: AgentSessionOptionsResult constructor( - private readonly input: StructuredAgentSessionAcquireInput, + private readonly input: StructuredAgentSessionSpawnInput, private readonly connection: PiRpcConnection, private readonly deps: PiRpcSessionDeps, /** A new Pi session starts on its config's model; a resumed or forked one keeps its own. */ diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts index 2a93c34da9b6..c8538d1ade18 100644 --- a/src/main/pi/rpc-session.ts +++ b/src/main/pi/rpc-session.ts @@ -4,7 +4,7 @@ import { providerDiagnosticOf } from '../../shared/agent-session-failure' import type { - StructuredAgentSessionAcquireInput, + StructuredAgentSessionSpawnInput, StructuredAgentSessionLifecycleEvent, AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -73,7 +73,7 @@ export class PiRpcSession { private releaseAfterExit?: () => void constructor( - readonly input: StructuredAgentSessionAcquireInput, + readonly input: StructuredAgentSessionSpawnInput, readonly generation: string, launch: ProviderProcessLaunch, private readonly deps: PiRpcSessionDeps, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 4a82cb904752..9a9dccf49220 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -34,9 +34,15 @@ let host: StructuredAgentSessionHost function adapter(): StructuredAgentSessionAdapter { return { supportsCreate: () => true, - acquire: vi - .fn() + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi + .fn() .mockImplementation(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, @@ -233,7 +239,7 @@ describe('committed adopting create RPC replay', () => { } } }) - expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) + expect(sessionAdapter.spawn).toHaveBeenCalledTimes(1) expect(internal.publishStructuredAgentSessionTab).toHaveBeenCalledTimes(2) expect(selectAccountHome).toHaveBeenCalledTimes(1) @@ -246,7 +252,7 @@ describe('committed adopting create RPC replay', () => { ok: true, result: { ok: false, refusal: { code: 'agent_session_identity_required' } } }) - expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) + expect(sessionAdapter.spawn).toHaveBeenCalledTimes(1) expect(internal.publishStructuredAgentSessionTab).toHaveBeenCalledTimes(2) }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 1048e7ed03e2..457c0cd62619 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -68,7 +68,7 @@ async function restingChat(): Promise { }) await rig.restart() setStructuredAgentSessionHost(rig.host) - rig.adapter.acquire.mockClear() + rig.adapter.spawn.mockClear() rig.adapter.dispatch.mockClear() rig.adapter.readOptions.mockClear() } @@ -105,7 +105,7 @@ describe('opening a chat at rest (P2-01)', () => { const [status] = await call('agentSession.handoffStatus', { sessionId: SESSION }) const [held] = await call('agentSession.hold', { sessionId: SESSION, holderId: 'pane' }) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() expect(history).toMatchObject({ ok: true, result: { ok: true } }) const snapshot = frames.find((frame) => frame.ok && 'result' in frame) expect(snapshot).toMatchObject({ result: { type: 'snapshot', commands: null } }) @@ -143,7 +143,7 @@ describe('opening a chat at rest (P2-01)', () => { } // Awaits the provider's answer itself rather than polling for it, however slow the start. await rig.host.waitForSendSettlement(SESSION, sent.value.clientMessageId) - expect(rig.adapter.acquire).toHaveBeenCalledOnce() + expect(rig.adapter.spawn).toHaveBeenCalledOnce() expect(rig.adapter.dispatch).toHaveBeenCalledOnce() }) }) @@ -153,7 +153,7 @@ describe('the accessor', () => { // Written by the provider alone, so nothing but the readers below ever opens it here. const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) expect(attached.ok).toBe(true) - rig.adapter.acquire.mock.calls + rig.adapter.spawn.mock.calls .at(-1)?.[0] .events?.appendItem( { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'turn-1', ordinal: 1 }, @@ -163,7 +163,7 @@ describe('the accessor', () => { await rig.host.flushStreamedEvents(SESSION) await rig.restart() setStructuredAgentSessionHost(rig.host) - rig.adapter.acquire.mockClear() + rig.adapter.spawn.mockClear() const open = vi.spyOn(rig.host.collaboratorsForTests().conversationDelivery, 'open') await Promise.all([ ...Array.from({ length: 5 }, () => @@ -175,7 +175,7 @@ describe('the accessor', () => { await Promise.all(open.mock.results.map((result) => result.value)) ) expect(openedJournals.size).toBe(1) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() }) it('opens nothing once quit began, for a read that was already waiting on the lock', async () => { @@ -327,13 +327,13 @@ describe('the accessor', () => { it('subscribes an old mobile client that holds first even when no agent can start (P2-06)', async () => { await restingChat() - rig.adapter.acquire.mockRejectedValue(new Error('auth expired')) + rig.adapter.spawn.mockRejectedValue(new Error('auth expired')) const [held] = await call('agentSession.hold', { sessionId: SESSION, holderId: 'mobile' }) const frames = await call('agentSession.subscribe', { sessionId: SESSION }) expect(held).toMatchObject({ ok: true }) expect(frames.find((frame) => frame.ok)).toMatchObject({ result: { type: 'snapshot' } }) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() }) }) @@ -356,12 +356,12 @@ describe('options at rest', () => { }) expect(picked).toMatchObject({ ok: true, result: { ok: true } }) expect(rig.store.getRecord(SESSION)?.options).toMatchObject({ model: 'gpt-picked' }) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence await rig.host.send(CALLER, restTestSend('use the new model', fence)) - await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) - expect(rig.adapter.acquire.mock.calls[0]?.[0]).toMatchObject({ + await vi.waitFor(() => expect(rig.adapter.spawn).toHaveBeenCalledOnce()) + expect(rig.adapter.spawn.mock.calls[0]?.[0]).toMatchObject({ options: expect.objectContaining({ model: 'gpt-picked' }) }) }) @@ -379,7 +379,7 @@ describe('options at rest', () => { ok: true, result: { threadGoal: { current: null }, rewind: { supported: true } } }) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() expect(rig.adapter.readOptions).not.toHaveBeenCalled() }) }) @@ -397,12 +397,12 @@ describe('an agent exit', () => { acquisitionGeneration: running.generation! }) await new Promise((resolve) => setTimeout(resolve, 50)) - expect(rig.adapter.acquire).toHaveBeenCalledOnce() + expect(rig.adapter.spawn).toHaveBeenCalledOnce() const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') await rig.host.send(CALLER, restTestSend('again', fence)) - await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(rig.adapter.spawn).toHaveBeenCalledTimes(2)) }) it('whose settlement write failed is settled by the next send, which is delivered', async () => { @@ -410,7 +410,7 @@ describe('an agent exit', () => { const open = rig.host.collaboratorsForTests().sessions.get(SESSION)! const running = open.child! // A turn in flight, so the exit has something to settle. - rig.adapter.acquire.mock.calls + rig.adapter.spawn.mock.calls .at(-1)?.[0] .events?.appendItem( { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'working', ordinal: 50 }, @@ -430,7 +430,7 @@ describe('an agent exit', () => { fence: running.fence, acquisitionGeneration: running.generation! }) - // Released with its death evidence, not latched: the next acquire settles from that evidence. + // Released with its death evidence, not latched: the next spawn settles from that evidence. await vi.waitFor(() => expect(rig.store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', @@ -492,6 +492,6 @@ describe('every close withdraws what is queued (P2-29)', () => { ) expect(JSON.stringify(reader)).toContain('"rejection":{"kind":"chatClosed"}') expect(rig.host.hasSession(SESSION)).toBe(false) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts index 0a1058c9e820..06d01c121117 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts @@ -58,13 +58,13 @@ function params(options?: Readonly>, withMessage = false) }) } -it('a capable client creates a blank chat without acquire', async () => { +it('a capable client creates a blank chat without spawn', async () => { expect(await call('agentSession.create', params(), capable)).toMatchObject({ ok: true, result: { ok: true, value: { page: { submissions: [] } } } }) expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledOnce() }) @@ -76,7 +76,7 @@ it.each([ async ({ client }) => { const input = params(undefined, true) const entered = Promise.withResolvers() - rig.adapter.acquire.mockImplementationOnce( + rig.adapter.spawn.mockImplementationOnce( ({ signal }) => new Promise((_resolve, reject) => { entered.resolve() @@ -121,13 +121,13 @@ it.each([ ) it('the old client retains acquired create and waits for acquisition before publishing', async () => { - const acquired = rig.adapter.acquire.getMockImplementation() + const acquired = rig.adapter.spawn.getMockImplementation() if (!acquired) { - throw new Error('test acquire missing') + throw new Error('test spawn missing') } const entered = Promise.withResolvers() const release = Promise.withResolvers() - rig.adapter.acquire.mockImplementationOnce(async (input) => { + rig.adapter.spawn.mockImplementationOnce(async (input) => { entered.resolve() await release.promise return acquired(input) @@ -137,13 +137,13 @@ it('the old client retains acquired create and waits for acquisition before publ answered = true return result }) - await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(rig.adapter.spawn).toHaveBeenCalledOnce()) await entered.promise expect(answered).toBe(false) expect(runtimeCalls.publishStructuredAgentSessionTab).not.toHaveBeenCalled() release.resolve() expect(await creating).toMatchObject({ ok: true, result: { ok: true } }) - expect(rig.adapter.acquire).toHaveBeenCalledOnce() + expect(rig.adapter.spawn).toHaveBeenCalledOnce() }) it('replays an acquired create after the client negotiates create-message support', async () => { @@ -156,7 +156,7 @@ it('replays an acquired create after the client negotiates create-message suppor ok: true, result: { ok: true, replayed: true, value: { sessionId: SESSION } } }) - expect(rig.adapter.acquire).toHaveBeenCalledOnce() + expect(rig.adapter.spawn).toHaveBeenCalledOnce() }) it('keeps an at-rest replay at rest when the client capability list changes', async () => { @@ -169,7 +169,7 @@ it('keeps an at-rest replay at rest when the client capability list changes', as ok: true, result: { ok: true, replayed: true } }) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) @@ -187,7 +187,7 @@ it.each(['firstMessage', 'options'] as const)( result: { ok: false, refusal: { code: 'agent_session_operation_conflict' } } }) expect((await rig.host.journalSnapshot(SESSION)).submissions).toEqual([]) - expect(rig.adapter.acquire).toHaveBeenCalledOnce() + expect(rig.adapter.spawn).toHaveBeenCalledOnce() } ) @@ -198,12 +198,12 @@ it('merges held option overrides with host seeds before the first acquisition', result: { ok: true } }) expect(rig.store.getRecord(SESSION)?.options).toEqual({ model: 'held-model', effort: 'medium' }) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() expect(await rig.host.send({ callerKey: 'client-1' }, restTestSend('first work'))).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(rig.adapter.acquire).toHaveBeenCalledOnce()) - expect(rig.adapter.acquire).toHaveBeenCalledWith( + await vi.waitFor(() => expect(rig.adapter.spawn).toHaveBeenCalledOnce()) + expect(rig.adapter.spawn).toHaveBeenCalledWith( expect.objectContaining({ options: { model: 'held-model', effort: 'medium' } }) ) }) @@ -229,7 +229,7 @@ it('keeps the seeded permission mode beside explicit picks on an acquired create resolveStructuredAgentSessionCreateIntent }) ).toMatchObject({ ok: true, result: { ok: true } }) - expect(rig.adapter.acquire).toHaveBeenCalledWith( + expect(rig.adapter.spawn).toHaveBeenCalledWith( expect.objectContaining({ options: { model: 'held-model', permissionMode: 'full-access' } }) ) }) @@ -243,7 +243,7 @@ it('rejects an explicit option the resting agent would reject before founding', }) expect(rig.store.getRecord(SESSION)).toBeNull() expect(rig.store.listOperationRows()).toEqual([]) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() }) it.each(['options', 'firstMessage'] as const)( @@ -280,6 +280,6 @@ it.each(['options', 'firstMessage'] as const)( ok: true, result: { ok: false, refusal: { code: 'agent_session_operation_conflict' } } }) - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() } ) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 25467e57248d..a474807e4613 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -49,7 +49,7 @@ let host: StructuredAgentSessionHost let runtime: OrcaRuntimeService let dispatcher: RpcDispatcher let closeSession: Mock> -let acquire: Mock +let spawn: Mock let requests = 0 let structuredNativeChatEnabled = true @@ -70,7 +70,8 @@ beforeEach(async () => { requests = 0 structuredNativeChatEnabled = true closeSession = vi.fn(async () => true) - acquire = vi.fn(async ({ fence, spawnToken }) => ({ + spawn = vi.fn(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, link: { linkId: `link-${fence}`, @@ -88,9 +89,14 @@ beforeEach(async () => { logger: createStructuredAgentSessionLogger(), store, adapter: { - acquire, + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn, // A failed acquisition is proven gone, as the real adapters prove it; without this an - // acquire that throws leaves an unverifiable owner nothing may replace. + // spawn that throws leaves an unverifiable owner nothing may replace. releaseAcquisition: vi.fn(async () => true), closeSession, dispatch: async () => ({ @@ -136,13 +142,13 @@ describe('the hold surface, for clients that still call it', () => { await host.close(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) const registered = vi.spyOn(runtime, 'registerOwnedSubscriptionCleanup') - const acquiresBefore = acquire.mock.calls.length + const acquiresBefore = spawn.mock.calls.length expect( await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) ).toMatchObject({ ok: true, result: { held: true } }) - expect(acquire.mock.calls.length).toBe(acquiresBefore) + expect(spawn.mock.calls.length).toBe(acquiresBefore) expect(registered).not.toHaveBeenCalled() expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) @@ -174,13 +180,13 @@ describe('the hold surface, for clients that still call it', () => { it('answers a hold even when no agent could be started', async () => { await host.close(SESSION, 'evict') - acquire.mockRejectedValue(new Error('provider unavailable')) - const acquiresBefore = acquire.mock.calls.length + spawn.mockRejectedValue(new Error('provider unavailable')) + const acquiresBefore = spawn.mock.calls.length expect( await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) ).toMatchObject({ ok: true, result: { held: true } }) - expect(acquire.mock.calls.length).toBe(acquiresBefore) + expect(spawn.mock.calls.length).toBe(acquiresBefore) }) }) @@ -188,7 +194,7 @@ describe('a stream', () => { it('reads a closed conversation without starting its agent', async () => { await host.close(SESSION, 'evict') expect(host.hasSession(SESSION)).toBe(false) - const acquiresBefore = acquire.mock.calls.length + const acquiresBefore = spawn.mock.calls.length const frames: unknown[] = [] await dispatcher.dispatchStreaming( @@ -205,7 +211,7 @@ describe('a stream', () => { expect(frames).toContainEqual( expect.objectContaining({ result: expect.objectContaining({ type: 'snapshot' }) }) ) - expect(acquire.mock.calls.length).toBe(acquiresBefore) + expect(spawn.mock.calls.length).toBe(acquiresBefore) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts index 56db442ce2c2..ca6bca97eff7 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import type { AgentSessionUnavailable } from '../../../../shared/agent-session-availability' import type { AgentSessionRecord } from '../../../../shared/agent-session-record' import type { AgentSessionModelCatalogResult } from '../../../../shared/agent-session-wire' import { agentSessionRecordFixture } from '../../../../shared/agent-session-record.test-fixture' @@ -176,11 +175,8 @@ const PI_CLIENT = { ] } -describe("agentSession.modelCatalog with what the running agent's start said", () => { +describe('agentSession.modelCatalog with a running Pi chat', () => { const read = vi.fn(async (): Promise => ({ origin: 'unknown' })) - const startUnavailable = vi.fn((_sessionId: string): AgentSessionUnavailable | undefined => ({ - reason: 'notSignedIn' - })) const record: AgentSessionRecord = { ...agentSessionRecordFixture(), provider: 'pi' } const readFor = (agent: string) => call('agentSession.modelCatalog', { agent, sessionId: record.sessionId }, PI_CLIENT) @@ -188,22 +184,19 @@ describe("agentSession.modelCatalog with what the running agent's start said", ( beforeEach(() => { read.mockReset() read.mockResolvedValue({ origin: 'unknown' }) - startUnavailable.mockClear() setStructuredAgentSessionHost( Object.assign(hostStub(), { deps: { modelCatalog: { read }, - store: { getRecord: () => record }, - adapter: { startUnavailable } + store: { getRecord: () => record } } }) ) }) - it("answers a Pi chat's catalog with its signed-out start", async () => { - const reply = await readFor('pi') - expect(startUnavailable).toHaveBeenCalledWith(record.sessionId) - expect(reply).toMatchObject({ + it('uses the catalog sign-in result without a retained child failure', async () => { + read.mockResolvedValue({ origin: 'unknown', unavailable: { reason: 'notSignedIn' } }) + expect(await readFor('pi')).toMatchObject({ ok: true, result: { origin: 'unknown', unavailable: { reason: 'notSignedIn' } } }) @@ -217,17 +210,9 @@ describe("agentSession.modelCatalog with what the running agent's start said", ( }) }) - it("never lends one agent's start to another agent's read", async () => { - const reply = await readFor('codex') - expect(startUnavailable).not.toHaveBeenCalled() - expect(reply).toMatchObject({ ok: true, result: { origin: 'unknown' } }) - expect(reply).not.toMatchObject({ result: { unavailable: expect.anything() } }) - }) - - it('stays quiet while the start said nothing', async () => { - startUnavailable.mockReturnValueOnce(undefined) + it('returns unknown when the catalog has no availability evidence', async () => { const reply = await readFor('pi') - expect(reply).toMatchObject({ ok: true }) + expect(reply).toMatchObject({ ok: true, result: { origin: 'unknown' } }) expect(reply).not.toMatchObject({ result: { unavailable: expect.anything() } }) }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index 346b17ee8f0f..3d18c9b9dc31 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -88,13 +88,7 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ .catch(() => null) return catalog.read({ ...params, workspacePath }) } - const answer = await read() - // The chat's running agent can say what no listing does: a Pi that started with no model. - const started = - record?.provider === params.agent - ? host.deps.adapter.startUnavailable?.(record.sessionId) - : undefined - return started && !answer.unavailable ? { ...answer, unavailable: started } : answer + return read() } }) ] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts index 0f6ccc398fbe..1812b67dc44a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-workspace-scope.test.ts @@ -113,7 +113,7 @@ async function chatAtRest(repoConfig: { configured: boolean }): Promise { }) await rig.restart({ modelCatalog }) setStructuredAgentSessionHost(rig.host) - rig.adapter.acquire.mockClear() + rig.adapter.spawn.mockClear() } beforeEach(async () => { @@ -145,7 +145,7 @@ describe('options at rest in a repo whose config may pick the model', () => { result: { current: { model: 'gpt-5.5' }, conversationCommands: ['clear'] } }) expect(reply).not.toHaveProperty('result.current.effort') - expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(rig.adapter.spawn).not.toHaveBeenCalled() }) it('names no model or effort for a client that takes an answer without one', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts index c66a6bcdf380..a919f161107d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-unregistered-agent.test.ts @@ -113,7 +113,7 @@ it.each(['empty', 'claude-codex'] as const)( const agents = registrations === 'empty' ? new StructuredAgentRegistry([]) : claudeAndCodexDeclared() const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) - const acquire = vi.spyOn(router, 'acquire') + const spawn = vi.spyOn(router, 'spawn') host = new StructuredAgentSessionHost({ store, agents, @@ -145,7 +145,7 @@ it.each(['empty', 'claude-codex'] as const)( expect(await capsule.list(NOW + 1)).toMatchObject([{ sessionId: PENDING }]) expect(await capsule.listFailed(NOW + 1)).toMatchObject([{ marker: { sessionId: FAILED } }]) } - expect(acquire).not.toHaveBeenCalled() + expect(spawn).not.toHaveBeenCalled() expect(await call('agentSession.restartResumable', {}, NEW_CLIENT)).toMatchObject({ ok: true, diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts index 8e7bdf86c223..0cc63e1637cf 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts @@ -55,12 +55,18 @@ function providerAdapter(): StructuredAgentSessionAdapter { return { supportsLocation: (location) => location.executionHostId === 'local' && location.wslDistro === null, - acquire: vi.fn(async (input) => { + open: async () => ({ + reportedOptions: { model: '' }, + restoreSkippedOptions: [], + optionRevision: -1 + }), + spawn: vi.fn(async (input) => { if (acquireFails) { throw new Error('provider failed to start') } acquisitions++ return { + acquisitionGeneration: globalThis.crypto.randomUUID(), process: { hostId: 'local', pid: 4000 + acquisitions, diff --git a/src/main/runtime/structured-agent-launch-parity.test.ts b/src/main/runtime/structured-agent-launch-parity.test.ts index 7f4faca8242f..83627017e2d1 100644 --- a/src/main/runtime/structured-agent-launch-parity.test.ts +++ b/src/main/runtime/structured-agent-launch-parity.test.ts @@ -132,7 +132,7 @@ import { ORCA_AGENT_SESSION_CALLER_ENV_KEYS } from '../ipc/pty/host-env/spawn-env-keys' import { NATIVE_CHAT_VISUALS_DIR_ENV } from '../native-chat/native-chat-visuals-delivery' -import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionSpawnInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { createClaudeCliFlagSupport } from '../claude/claude-cli-flag-support' @@ -628,7 +628,7 @@ describe.skipIf(process.platform === 'win32')('native-chat launch parity', () => }) } const adapter: StructuredAgentRuntimeAdapter = registration.createAdapter(adapterContext()) - const input: StructuredAgentSessionAcquireInput = { + const input: StructuredAgentSessionSpawnInput = { identity: { sessionId, workspaceId: record.location.workspaceId, @@ -648,7 +648,7 @@ describe.skipIf(process.platform === 'win32')('native-chat launch parity', () => capture.launches.length = 0 pins.length = 0 try { - const outcome = await adapter.acquire(input).then( + const outcome = await adapter.spawn(input).then( () => null, (error: unknown) => error ) diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 9e3cca17b155..73b1c00d1636 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -155,11 +155,7 @@ function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredA onPrimaryThreadStoppedRunning: followUps.releaseUnansweredDispatches, logger: deps.logger, onEvent: (event) => { - if ( - event.type === 'started' || - event.type === 'options-reported' || - (event.type === 'ended' && 'cause' in event) - ) { + if (event.type === 'options-reported' || (event.type === 'ended' && 'cause' in event)) { context.deliverLifecycle(event) } } diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts index a9ba53700dd3..051339259ba0 100644 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -94,7 +94,7 @@ describe('failures the desktop host used to drop', () => { ) }) - it('logs a provider start or exit the host could not take', async () => { + it('logs a provider report or exit the host could not take', async () => { const log = recordingStructuredAgentSessionLogger() const error = new Error('host refused the event') const lifecycle = createStructuredAgentSessionLifecycleDelivery({ @@ -106,7 +106,7 @@ describe('failures the desktop host used to drop', () => { }) lifecycle.deliver({ - type: 'started', + type: 'options-reported', sessionId: SESSION, fence: 1, acquisitionGeneration: 'generation-1', @@ -125,7 +125,7 @@ describe('failures the desktop host used to drop', () => { await lifecycle.drain() expect(log.entries.map((entry) => entry.fields)).toEqual([ - { scope: 'lifecycle-started', sessionId: SESSION, error }, + { scope: 'lifecycle-options-reported', sessionId: SESSION, error }, { scope: 'lifecycle-exit', sessionId: SESSION, error } ]) }) From cfbe7c18e8b61162ec3f42c7f2c34119fa5c626f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:17:46 -0700 Subject: [PATCH 3/9] Match startup and interrupt fixtures to the required opening contract --- .../structured-agent-session-append-delivery.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 673017d7cd50..7abddf6130be 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -133,9 +133,7 @@ beforeEach(async () => { logger: createStructuredAgentSessionLogger(), store, adapter: { - open: async () => ({ - reportedOptions: { model: '' } - }), + open: () => new Promise(() => {}), acquire, releaseAcquisition: vi.fn(async () => true), closeSession: vi.fn(async () => true), From 5b22a1b0080efb741a007173d8922adafdc6947a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:30:32 -0700 Subject: [PATCH 4/9] Keep one startup failure reader after the final carry --- .../structured-agent-session-delivery-loop.ts | 1 - ...uctured-agent-session-start-failure-row.ts | 21 ------------------- 2 files changed, 22 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index cb8f79b283b8..ccf4fdfafa16 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -31,7 +31,6 @@ import type { } from './structured-agent-session-host-types' import { oldestQueuedSubmission, - startThatFailedWhileQueued, recordStructuredAgentSessionStartFailure, refusedStructuredAgentSessionStart, startThatFailedWhileQueued 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 5a5ef6d20148..d90943123b01 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -1,5 +1,3 @@ -import { failedProviderChildStart } from './structured-agent-session-provider-child' -import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' @@ -129,22 +127,3 @@ export function oldestQueuedSubmission( } return oldest } - -/** A start that died while this message waited on it — a view's, say — is the message's failed - * start: settled with it, under its key, rather than started again into the same failure. */ -export function startThatFailedWhileQueued( - session: StructuredAgentSessionHostSession, - oldest: NonNullable> -): { startKey: string | null; cause: StructuredAgentSessionStartFailureCause } | null { - const ended = failedProviderChildStart(session) - if ( - !ended || - oldest.acceptedSequence === undefined || - ended.endedAt.epoch !== session.journal.cursor().epoch || - ended.endedAt.sequence < oldest.acceptedSequence - ) { - return null - } - const cause = structuredAgentSessionEndedChildFailure(ended) - return cause ? { startKey: ended.generation, cause } : null -} From 623f5aceb47bb472e2f984aec5bcf8b49609c743 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:22:50 -0700 Subject: [PATCH 5/9] Publish generations from cross-version provider fixtures --- .../cross-version-agent-session-wire.unit.test.ts | 1 + tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts | 1 + .../cross-version-wire/structured-agent-session-host-fixture.ts | 1 + 3 files changed, 3 insertions(+) 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 3ada0679c7f8..46f931c7fa19 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -703,6 +703,7 @@ describe('cross-version structured agent sessions', () => { starts += 1 await startGate return { + acquisitionGeneration: `cursor-${fence}`, process: { hostId: 'local', pid: 4242, diff --git a/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts b/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts index a7340834d773..a2341b1539d7 100644 --- a/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts +++ b/tests/e2e/cross-version-wire/cross-version-stop-note-test-rig.ts @@ -96,6 +96,7 @@ export function createReleasedStopNoteRig(port: ScenarioPort, ref: string) { { supportsCreate: () => true, acquire: async ({ fence, spawnToken }) => ({ + acquisitionGeneration: `stop-note-${fence}`, process: { hostId: 'local', pid: 4242, diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index a24e11d902f3..9dcff82c307d 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -184,6 +184,7 @@ export function readyCodexProviderAdapter(input: { return startsWhenPublished( { acquire: async ({ fence }) => ({ + acquisitionGeneration: `wire-host-${fence}`, process: { hostId: 'local', pid: 4242, From e4fdc29a7741d4586c742bfe87f6096d7004d607 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:13:46 -0700 Subject: [PATCH 6/9] Keep cleanup recovery contracts and remove duplicate fixture cases --- .../claude-structured-session-prompts.test.ts | 159 --- .../codex-structured-question-order.test.ts | 271 ------ ...-agent-session-acquisition-options.test.ts | 660 ------------- ...-session-acquisition-runtime-stamp.test.ts | 131 --- ...nt-session-adapter-router-registry.test.ts | 217 ----- .../structured-agent-session-adapter.test.ts | 96 -- ...red-agent-session-at-rest-commands.test.ts | 329 ------- ...d-agent-session-conversation-close.test.ts | 264 ----- ...ructured-agent-session-drivability.test.ts | 352 ------- ...session-failed-create-sink-release.test.ts | 150 --- ...tured-agent-session-grouped-prompt.test.ts | 287 ------ ...nt-session-host-held-child-renewal.test.ts | 119 --- ...ed-agent-session-host-test-abandon.test.ts | 130 --- ...ssion-main-agent-working-agreement.test.ts | 260 ----- ...ed-agent-session-option-settlement.test.ts | 235 ----- ...ent-session-pre-spawn-first-answer.test.ts | 254 ----- ...gent-session-provider-child-record.test.ts | 908 ------------------ ...red-agent-session-provider-restore.test.ts | 115 --- ...t-session-provider-started-catalog.test.ts | 117 --- ...red-agent-session-provider-started.test.ts | 266 ----- ...ctured-agent-session-refusal-retry.test.ts | 334 ------- ...gent-session-registered-definition.test.ts | 203 ---- ...uctured-agent-session-repeat-press.test.ts | 243 ----- ...session-restart-status-publication.test.ts | 165 ---- ...red-agent-session-send-preparation.test.ts | 701 -------------- ...agent-session-settled-attach-retry.test.ts | 424 -------- ...ured-agent-session-startup-attempt.test.ts | 239 ----- ...red-agent-session-surface-lifetime.test.ts | 875 ----------------- ...ructured-chat-naming-command-first.test.ts | 152 --- ...structured-chat-naming-status-hook.test.ts | 159 --- src/main/pi/rpc-configured-default.test.ts | 423 -------- .../agent-session-record-options.test.ts | 87 -- .../structured-agent-session-hold.test.ts | 241 ----- ...ured-agent-session-failure-logging.test.ts | 343 ------- 34 files changed, 9909 deletions(-) delete mode 100644 src/main/claude/claude-structured-session-prompts.test.ts delete mode 100644 src/main/codex/codex-structured-question-order.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts delete mode 100644 src/main/native-chat/structured-chat-naming-command-first.test.ts delete mode 100644 src/main/native-chat/structured-chat-naming-status-hook.test.ts delete mode 100644 src/main/pi/rpc-configured-default.test.ts delete mode 100644 src/main/runtime/agent-session-record-options.test.ts delete mode 100644 src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts delete mode 100644 src/main/runtime/structured-agent-session-failure-logging.test.ts diff --git a/src/main/claude/claude-structured-session-prompts.test.ts b/src/main/claude/claude-structured-session-prompts.test.ts deleted file mode 100644 index f66ba0c804af..000000000000 --- a/src/main/claude/claude-structured-session-prompts.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { AgentSessionPromptAnswerRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' -import { acquired, fakeClaude, tick } from './claude-structured-session-test-support' -import { invokeCanUseTool } from './claude-can-use-tool-test-support' - -describe('ClaudeStructuredSessionAdapter prompts', () => { - it('turns can_use_tool into an addressable durable approval that settles the SDK callback', async () => { - const claude = fakeClaude() - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = await acquired(claude, {}, events) - const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-1', 'tool-1', { - input: { command: 'git status' }, - suggestions: [{ type: 'addRules' }] - }) - expect(events.at(-1)).toMatchObject({ - type: 'prompt', - prompt: { kind: 'approval', toolName: 'Bash', promptKey: 'permission-1' } - }) - - adapter.bindPromptItemId('session-1', 'journal-approval', 'permission-1') - await adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-approval', - kind: 'approval', - response: { kind: 'option', optionId: 'allowForSession' }, - fence: 7, - commit: async () => undefined - }) - // The answer resolves the SDK's own callback promise; the SDK writes the wire response. - await expect(answered.promise).resolves.toEqual({ - behavior: 'allow', - updatedInput: { command: 'git status' }, - updatedPermissions: [{ type: 'addRules' }], - toolUseID: 'tool-1' - }) - }) - - it('settles the one AskUserQuestion callback from structured answers, including a long typed answer', async () => { - const claude = fakeClaude() - const adapter = await acquired(claude) - const answered = invokeCanUseTool( - claude.connections[0], - 'AskUserQuestion', - 'question-1', - 'tool-question', - { - input: { - questions: [ - { question: 'Library?', options: [{ label: 'Luxon' }] }, - { question: 'Ship now?', options: [{ label: 'Yes' }] } - ] - } - } - ) - adapter.bindPromptItemId('session-1', 'journal-question', 'question-1') - const typed = 'Wait for the capture to finish first. '.repeat(60) - - await adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-question', - kind: 'question', - response: { - kind: 'answers', - answers: [ - { questionId: 'q1', optionIds: ['q1:choice-1'] }, - { questionId: 'q2', optionIds: [], other: typed } - ] - }, - fence: 7, - commit: async () => undefined - }) - await expect(answered.promise).resolves.toMatchObject({ - behavior: 'allow', - updatedInput: { answers: { 'Library?': 'Luxon', 'Ship now?': typed.trim() } }, - toolUseID: 'tool-question' - }) - }) - - it('refuses answers Claude cannot take before the journal commits them', async () => { - const claude = fakeClaude() - const adapter = await acquired(claude) - const answered = invokeCanUseTool( - claude.connections[0], - 'AskUserQuestion', - 'question-1', - 'tool-question', - { input: { questions: [{ question: 'Library?', options: [{ label: 'Luxon' }] }] } } - ) - adapter.bindPromptItemId('session-1', 'journal-question', 'question-1') - const commit = vi.fn(async () => undefined) - - await expect( - adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-question', - kind: 'question', - response: { kind: 'option', optionId: 'allow' }, - fence: 7, - commit - }) - ).rejects.toBeInstanceOf(AgentSessionPromptAnswerRejectedError) - expect(commit).not.toHaveBeenCalled() - - await adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-question', - kind: 'question', - response: { kind: 'answers', answers: [{ questionId: 'q1', optionIds: ['q1:choice-1'] }] }, - fence: 7, - commit - }) - await expect(answered.promise).resolves.toMatchObject({ - updatedInput: { answers: { 'Library?': 'Luxon' } } - }) - }) - - it('leaves a prompt cancelled and unanswerable once the SDK abort signal fires', async () => { - const claude = fakeClaude() - const events: ClaudeStructuredSessionEvent[] = [] - const adapter = await acquired(claude, {}, events) - const controller = new AbortController() - const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-9', 'tool-9', { - input: { command: 'rm -rf /' }, - signal: controller.signal - }) - adapter.bindPromptItemId('session-1', 'journal-9', 'permission-9') - - controller.abort() - // A cancelled request is forgotten and settled with null — never an authorization. - await expect(answered.promise).resolves.toBeNull() - expect(events.at(-1)).toMatchObject({ type: 'prompt-cancelled', promptKey: 'permission-9' }) - // A late answer after the abort must not authorize the wrong tool. - await expect( - adapter.answerPrompt({ - sessionId: 'session-1', - itemId: 'journal-9', - kind: 'approval', - response: { kind: 'option', optionId: 'allow' }, - fence: 7, - commit: async () => undefined - }) - ).rejects.toThrow(/no longer waiting/) - }) - - it('settles an in-flight permission callback when the session closes, leaving no dangling promise', async () => { - const claude = fakeClaude() - const adapter = await acquired(claude) - const answered = invokeCanUseTool(claude.connections[0], 'Bash', 'permission-close', 'tool-c', { - input: { command: 'ls' } - }) - await tick() - expect(answered.settled()).toBe(false) - - await adapter.closeSession('session-1') - - await expect(answered.promise).resolves.toBeNull() - }) -}) diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts deleted file mode 100644 index fdbfdc678d96..000000000000 --- a/src/main/codex/codex-structured-question-order.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -// A Codex ask with several questions, driven from the real host journal through -// the client's session reducer to the rows the transcript list draws. -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalRenderItem -} from '../../shared/agent-session-journal-types' -import { - EMPTY_STRUCTURED_AGENT_SESSION, - reduceStructuredAgentSession, - type StructuredAgentSessionState -} from '../../shared/structured-agent-session-reducer' -import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' -import { projectNativeChatTranscriptMessages } from '../../shared/native-chat-transcript-projection' -import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' -import { CodexJournalPrompts } from './codex-structured-journal-prompts' -import { CODEX_USER_INPUT_METHOD } from './codex-structured-prompt-replies' -import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' -import { - HOST_TEST_NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { - projectStructuredQuestionMessages, - structuredQuestionTranscript -} from '../../renderer/src/components/native-chat/structured-agent-question-projection' -import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' -import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } - -type Asked = readonly { id: string; question: string }[] - -// Codex's question ids are the model's own words, so their text order is not -// the order it asked in. These two asks spell the two orders live QA saw. -const ASKED: Asked = [ - { id: 'scope', question: 'Which files are in scope?' }, - { id: 'priority', question: 'What matters most?' }, - { id: 'deadline', question: 'When is it due?' } -] -const ASKED_OUT_OF_ORDER: Asked = [ - { id: 'format', question: 'Which format?' }, - { id: 'audience', question: 'Who reads it?' }, - { id: 'length', question: 'How long?' } -] - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let sink: StructuredAgentSessionEventSink | null -let client: StructuredAgentSessionState -let clock: number - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-codex-question-order-')) - resetHostTestOperationIds() - sink = null - clock = HOST_TEST_NOW - const adapter: StructuredAgentSessionAdapter = { - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire: vi.fn(async ({ fence, events }) => { - sink = events ?? null - return { - acquisitionGeneration: globalThis.crypto.randomUUID(), - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: HOST_TEST_NOW - } - } - }), - releaseAcquisition: vi.fn(async () => true), - dispatch: vi.fn(), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async ({ commit }) => commit()), - setOption: vi.fn(async () => undefined) - } - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - // Every write lands on its own millisecond, as it does live. - now: () => (clock += 1) - }) - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - if (!page.ok) { - throw new Error('no history page') - } - client = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { - type: 'history-page', - page: page.page - }) - host.subscribe({ - id: 'client', - sessionId: SESSION, - cursor: page.page.liveCursor ?? page.page.window.nextCursor, - emit: (event) => { - client = reduceStructuredAgentSession(client, { type: 'event', event }) - } - }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function codexPrompts(): CodexJournalPrompts { - if (!sink) { - throw new Error('session was never acquired') - } - return new CodexJournalPrompts( - { sink, attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }, - () => null, - () => 'turn-1' - ) -} - -async function ask(prompts: CodexJournalPrompts, asked: Asked = ASKED): Promise { - prompts.handle({ - threadId: THREAD, - method: CODEX_USER_INPUT_METHOD, - codexItemId: 'codex-item-1', - promptKey: '7', - params: { - threadId: THREAD, - turnId: 'turn-1', - questions: asked.map((question) => ({ - ...question, - options: [ - { label: 'Yes', description: '' }, - { label: 'No', description: '' } - ] - })) - } - }) - await host.flushStreamedEvents(SESSION) -} - -function questionItem(question: string): AgentJournalRenderItem { - const item = client.items.find( - (candidate) => candidate.body.kind === 'question' && candidate.body.question === question - ) - if (!item || item.body.kind !== 'question') { - throw new Error(`no journal item for ${question}`) - } - return item -} - -async function answer(question: string): Promise { - const item = questionItem(question) - if (item.body.kind !== 'question') { - return - } - const fields = { - itemId: item.itemId, - expectedRevision: item.revision, - optionId: item.body.options[0]!.id - } - const result = await host.respondToPrompt(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.respondTo:question', - sessionId: SESSION, - fields - }) - }, - kind: 'question', - ...fields - }) - expect(result.ok).toBe(true) - await host.flushStreamedEvents(SESSION) -} - -/** The prompt rows the transcript list draws, top to bottom, as the questions each one shows. */ -function drawnPromptRows(): string[][] { - const { receipts } = structuredQuestionTranscript(client.items) - // The desktop list's projection: its comparator adds only a rank for rows the host never writes. - const rows = projectNativeChatTranscriptMessages( - projectStructuredAgentSessionMessages( - client.items, - [], - client.submissions, - projectStructuredQuestionMessages - ) - ) - return rows.flatMap((row) => { - const prompt = receipts.get(row.id) - if (!prompt || prompt.kind !== 'question') { - return [] - } - const questions = prompt.questions?.length ? prompt.questions : [prompt] - return [questions.map(({ question }) => `${question} (${prompt.resolution.state})`)] - }) -} - -describe('a Codex ask with several questions', () => { - it('keeps the pending rest of the ask below the question already answered', async () => { - await ask(codexPrompts()) - await answer(ASKED[0]!.question) - - expect(drawnPromptRows()).toEqual([ - ['Which files are in scope? (resolved)'], - ['What matters most? (pending)', 'When is it due? (pending)'] - ]) - }) - - it('draws every answered question in the order Codex asked it', async () => { - await ask(codexPrompts()) - for (const { question } of ASKED) { - await answer(question) - } - - expect(drawnPromptRows()).toEqual([ - ['Which files are in scope? (resolved)'], - ['What matters most? (resolved)'], - ['When is it due? (resolved)'] - ]) - // Mobile draws the shared projection in journal order, one row per question. - expect( - projectStructuredAgentSessionMessages(client.items, [], client.submissions).map( - ({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null) - ) - ).toEqual(ASKED.map(({ question }) => question)) - }) - - it('draws a cancelled ask in the order Codex asked it', async () => { - const prompts = codexPrompts() - await ask(prompts, ASKED_OUT_OF_ORDER) - prompts.cancel(questionItem(ASKED_OUT_OF_ORDER[0]!.question).itemId) - await host.flushStreamedEvents(SESSION) - - expect(drawnPromptRows()).toEqual([ - ['Which format? (cancelled)'], - ['Who reads it? (cancelled)'], - ['How long? (cancelled)'] - ]) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts deleted file mode 100644 index 8bc85d3082f1..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ /dev/null @@ -1,660 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { digestPayload } from '../agent-session-journal/journal-payload-bounds' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' -import { - attachFingerprintFields, - type AgentSessionAttachParams -} from './structured-agent-session-attach' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import { performAttach } from './structured-agent-session-attach-flow' -import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const NOW = 1_800_000_000_000 -const SESSION = 'legacy-session' -const CREATE_OPERATION = `${NOW}-${'1'.padStart(32, '0')}` -const RESUME_OPERATION = `${NOW}-${'2'.padStart(32, '0')}` -let root: string | null = null - -afterEach(async () => { - if (root) { - await rm(root, { recursive: true, force: true }) - } - root = null -}) - -function attachParams( - operationId: string, - expectedRuntimeFence: number | null, - options?: Readonly> -): AgentSessionAttachParams { - const params: AgentSessionAttachParams = { - envelope: { - sessionId: SESSION, - clientOperationId: operationId, - expectedRuntimeFence, - payloadFingerprint: '' - }, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'codex', - agent: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - runtimeKind: 'native', - ...(options ? { options } : {}), - providerHandle: { kind: 'codex', threadId: 'legacy-thread' } - } - return { - ...params, - envelope: { - ...params.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(params) - }) - } - } -} - -function adapter(input: { - origin: 'created' | 'resumed' - options?: AgentSessionOptionsResult -}): StructuredAgentSessionAdapter { - return { - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire: vi - .fn() - .mockImplementation(async ({ fence, spawnToken }) => ({ - acquisitionGeneration: globalThis.crypto.randomUUID(), - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW, - spawnToken - }, - link: { - linkId: `${input.origin}-link`, - handle: codexProviderHandle('legacy-thread'), - origin: input.origin, - mintedAtFence: fence, - observedAt: NOW - } - })), - ...(input.options ? { readOptions: vi.fn(async () => input.options!) } : {}), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } -} - -function expectSettledAttachLease(record: AgentSessionRecord | null): void { - expect(record).not.toBeNull() - const lease = record!.lease - const durableState = lease.handoffStage ?? lease.claimStatus - expect(['live', 'released', 'recovering']).toContain(durableState) - expect(lease.handoffStage).not.toBe('new-owner-proving') -} - -describe('structured session acquisition options', () => { - it('samples provider history before acquiring a replacement child', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-history-before-acquire-')) - const initialStore = await openTestAgentSessionRecordStore(root) - let childAcquired = false - const historyWindow = (): ProviderHistoryWindow => ({ - items: [], - boundaryConsistent: true, - turnInFlight: childAcquired - }) - const withHistory = (origin: 'created' | 'resumed'): StructuredAgentSessionAdapter => { - const sessionAdapter = adapter({ origin }) - const acquire = vi.mocked(sessionAdapter.acquire) - acquire.mockImplementation(async (input) => { - childAcquired = true - return { - acquisitionGeneration: globalThis.crypto.randomUUID(), - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW, - spawnToken: input.spawnToken - }, - link: { - linkId: `${origin}-link`, - handle: codexProviderHandle('legacy-thread'), - origin, - mintedAtFence: input.fence, - observedAt: NOW - } - } - }) - sessionAdapter.providerHistoryWindow = vi.fn(async () => historyWindow()) - return sessionAdapter - } - - let firstJournal: AgentSessionJournal | undefined - const first = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store: initialStore, - adapter: withHistory('created'), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null), - now: () => NOW, - onAttached: (attached) => { - firstJournal = attached.journal - } - }) - expect(first).toMatchObject({ ok: true }) - await firstJournal!.appendSubmission({ - clientMessageId: 'crashed-send', - payloadFingerprint: digestPayload('deploy the thing'), - body: { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: 'deploy the thing' }] - } satisfies AgentJournalMessageItem, - fence: 1 - }) - await firstJournal!.close() - const store = await openTestAgentSessionRecordStore(root) - await store.reconcileOnRestart({ - probe: async () => ({ outcome: 'pid-absent' }), - now: NOW + 1 - }) - childAcquired = false - const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - const second = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: withHistory('resumed'), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-b', - claimKeyId: 'key-1', - handoffOperationId: RESUME_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(RESUME_OPERATION, releasedFence), - now: () => NOW + 1, - onAttached: () => {} - }) - - expect(second).toMatchObject({ ok: true, value: { unconfirmedClientMessageIds: [] } }) - expect(second).toMatchObject({ - value: { - page: { submissions: [{ clientMessageId: 'crashed-send', dispatchState: 'rejected' }] } - } - }) - }) - - it('persists create defaults before the first provider acquisition', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-create-options-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter = adapter({ origin: 'created' }) - const options = { model: 'gpt-5.6-sol', effort: 'medium', fastMode: 'false' } - const recordPhase = vi.fn() - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, options), - now: () => NOW, - recordPhase, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(sessionAdapter.acquire).toHaveBeenCalledWith( - expect.objectContaining({ options, recordPhase }) - ) - expect(store.getRecord(SESSION)?.options).toEqual(options) - }) - - it('replays a create retried after the host re-resolved different options', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-create-retry-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter = adapter({ origin: 'created' }) - const attempt = async (options: Readonly>, spawnToken: string) => - performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken, - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, options), - now: () => NOW, - onAttached: () => {} - }) - - const created = await attempt({ model: 'gpt-5.6-sol', effort: 'medium' }, 'spawn-a') - // Why: the user may reselect a model between an unknown-outcome create and the - // retry that reuses its operation id; the retry must replay, not conflict. - const retried = await attempt({ model: 'gpt-5.5', effort: 'high' }, 'spawn-b') - - expect(created).toMatchObject({ ok: true }) - expect(retried).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5.6-sol', effort: 'medium' }) - }) - - it('preserves saved next-message choices when a provider reports its live options', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-')) - const store = await openTestAgentSessionRecordStore(root) - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter({ origin: 'created' }), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null), - now: () => NOW, - onAttached: () => {} - }) - expect(created).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toBeUndefined() - await store.replaceSessionOptions({ - sessionId: SESSION, - fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, - options: { approvalPolicy: 'on-request', personality: 'concise', fastMode: 'true' }, - now: NOW - }) - - const resumedStore = await openTestAgentSessionRecordStore(root) - await resumedStore.reconcileOnRestart({ - probe: async () => ({ outcome: 'pid-absent' }), - now: NOW + 1 - }) - const releasedFence = resumedStore.getRecord(SESSION)?.lease.runtimeFence ?? 0 - const resumed = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store: resumedStore, - adapter: adapter({ - origin: 'resumed', - options: { - current: { model: 'gpt-5.6-terra', effort: 'medium', fastMode: false }, - models: [] - } - }), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-b', - claimKeyId: 'key-1', - handoffOperationId: RESUME_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(RESUME_OPERATION, releasedFence), - now: () => NOW + 1, - onAttached: () => {} - }) - - expect(resumed).toMatchObject({ ok: true }) - const reopened = await openTestAgentSessionRecordStore(root) - expect(reopened.getRecord(SESSION)?.options).toEqual({ - approvalPolicy: 'on-request', - personality: 'concise', - fastMode: 'true' - }) - }) - - it('keeps a saved Fast choice without an acquisition report', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-fast-restore-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter = adapter({ - origin: 'created', - options: { current: { model: 'gpt-standard' }, models: [] } - }) - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, { - model: 'gpt-standard', - fastMode: 'true' - }), - now: () => NOW, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-standard', fastMode: 'true' }) - }) - - it('proves an opened Codex owner without replacing saved choices from readback', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-known-options-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter: StructuredAgentSessionAdapter = { - ...adapter({ origin: 'created' }), - readOptions: vi.fn(async () => { - throw new Error('model list unavailable') - }) - } - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, { model: 'gpt-saved', effort: 'medium' }), - now: () => NOW, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(sessionAdapter.readOptions).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-saved', effort: 'medium' }) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) - - it('retains saved options when an opened Codex thread reports no effective model', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-acquisition-saved-options-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter: StructuredAgentSessionAdapter = { - ...adapter({ origin: 'created' }), - readOptions: vi.fn(async () => { - throw new Error('model list unavailable') - }) - } - const options = { model: 'gpt-saved', effort: 'medium' } - - const created = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, options), - now: () => NOW, - onAttached: () => {} - }) - - expect(created).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options).toEqual(options) - expect(sessionAdapter.readOptions).not.toHaveBeenCalled() - }) - - describe.each([ - ['adapter acquire', 'acquire'], - ['identity commit', 'identity'], - ['owner proof', 'proof'], - ['journal attach', 'journal'] - ] as const)('%s failure', (_label, failurePoint) => { - it.each([ - ['proven cleanup', true], - ['unproven cleanup', false], - ['cleanup error', 'throws'] - ] as const)('atomically settles the lease and operation after %s', async (_case, cleanup) => { - const exitProven = cleanup === true - root = await mkdtemp(join(tmpdir(), `orca-acquisition-${failurePoint}-`)) - const store = await openTestAgentSessionRecordStore(root) - const base = adapter({ - origin: 'created', - options: { current: { model: 'gpt-5.6-terra' }, models: [] } - }) - const injected = new Error(`${failurePoint} failed`) - const acquire = vi.mocked(base.acquire) - if (failurePoint === 'acquire') { - acquire.mockRejectedValueOnce(injected) - } else if (failurePoint === 'identity') { - vi.spyOn(store, 'commitProcessIdentity').mockRejectedValueOnce(injected) - } else if (failurePoint === 'proof') { - vi.spyOn(store, 'proveOwner').mockRejectedValueOnce(injected) - } else if (failurePoint === 'journal') { - acquire.mockImplementation(async ({ fence, spawnToken }) => ({ - acquisitionGeneration: globalThis.crypto.randomUUID(), - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW, - spawnToken - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle('legacy-thread'), - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW - } - })) - } - const releaseAcquisition = vi.fn(async () => { - if (cleanup === 'throws') { - throw new Error('cleanup failed') - } - return cleanup - }) - const failingAdapter = { ...base, acquire, releaseAcquisition } - let journalFailure = failurePoint === 'journal' ? injected : null - const perform = ( - target: AgentSessionRecordStore, - operationId: string, - fence: number | null - ) => - performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store: target, - adapter: failingAdapter, - openConversation: async (record) => { - const failure = journalFailure - journalFailure = null - if (failure) { - throw failure - } - return openTestAttachConversation(openTestJournalHostDatabase(root!))(record) - }, - authority: { - spawnToken: operationId === CREATE_OPERATION ? 'spawn-a' : 'spawn-b', - claimKeyId: 'key-1', - handoffOperationId: operationId, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(operationId, fence), - now: () => NOW, - onAttached: () => {} - }) - - // A proven exit before the journal opens is answered once, as the refusal its replay gives; - // no exit was observed, so it names no situation. - const failed = perform(store, CREATE_OPERATION, null) - await (exitProven && failurePoint !== 'journal' - ? expect(failed).resolves.toEqual({ - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: "Codex couldn't restart. Send your message to try again." - } - }) - : expect(failed).rejects.toThrow( - exitProven ? injected.message : 'agent_session_acquisition_exit_unproven' - )) - - const reopened = await openTestAgentSessionRecordStore(root) - const failedRecord = reopened.getRecord(SESSION) - expectSettledAttachLease(failedRecord) - expect( - reopened.listOperationRows().find((row) => row.operationId === CREATE_OPERATION)?.outcome - ).toMatchObject({ status: 'failed' }) - - await reopened.reconcileOnRestart({ - probe: async (record) => - exitProven || record.lease.ownerProcess === null - ? exitProven - ? { outcome: 'reservation-unused' } - : { outcome: 'indeterminate', reason: 'owner identity was never committed' } - : { outcome: 'identity-matched', matchedOn: ['process-start-time'] }, - now: NOW + 1 - }) - - if (exitProven) { - expect(failedRecord?.lease).toMatchObject({ - runtimeFence: 2, - claimStatus: 'released', - handoffStage: null, - handoffOperationId: null, - ownerProcess: null, - reservedSpawnToken: null - }) - await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: true }) - expectSettledAttachLease(reopened.getRecord(SESSION)) - } else if (failurePoint === 'proof' || failurePoint === 'journal') { - // A recorded owner goes to recovery, which concludes about it before the next start. - expect(failedRecord?.lease).toMatchObject({ - runtimeFence: 1, - claimStatus: failurePoint === 'journal' ? 'live' : 'reserved', - handoffStage: 'recovering', - // The settled operation must not stay named by the lease as an in-flight transfer. - handoffOperationId: null, - reservedSpawnToken: 'spawn-a' - }) - await expect(perform(reopened, RESUME_OPERATION, 1)).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_ownership_unknown' } - }) - } else { - // No owner was recorded, and the adapter closed the stdio of anything it spawned: released, - // with no death evidence, since nothing proved one. - expect(failedRecord?.lease).toMatchObject({ - runtimeFence: 2, - claimStatus: 'released', - handoffStage: null, - handoffOperationId: null, - ownerProcess: null, - reservedSpawnToken: null, - deathEvidence: null - }) - await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: true }) - } - }) - }) -}) - -describe('the tab a create reserves', () => { - async function openStore() { - root = await mkdtemp(join(tmpdir(), 'orca-surface-tab-id-')) - return openTestAgentSessionRecordStore(root) - } - - function attachWith(store: AgentSessionRecordStore, surfaceTabId?: string) { - return performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter({ origin: 'created' }), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - // Beside the fingerprinted fields, like `options`: which tab shows the chat is not which - // conversation this attaches to. - params: { - ...attachParams(CREATE_OPERATION, null), - ...(surfaceTabId ? { surfaceTabId } : {}) - }, - now: () => NOW, - onAttached: () => {} - }) - } - - it('takes no tab at attach, then answers a retry naming another tab with the one it was given', async () => { - const store = await openStore() - const created = await attachWith(store, 'chat-tab-1') - // Publishing the tab takes the id, so a create that never gets there leaves nothing behind. - expect(created.ok && created.value.tabId).toBeUndefined() - expect(store.getSessionTabId(SESSION)).toBeNull() - - await store.setSessionTabVisibility(SESSION, true, 'chat-tab-1') - expect(await attachWith(store, 'chat-tab-2')).toMatchObject({ - ok: true, - value: { tabId: 'chat-tab-1' } - }) - expect(store.getSessionTabId(SESSION)).toBe('chat-tab-1') - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts deleted file mode 100644 index 6ce76cbb9fe9..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -// A chat starts whatever the store stamped on its owner: the Orca runtime holding the process is -// recorded beside its identity, and the start compares the process, not the stamp. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { agentSessionRuntimeIncarnation } from '../../runtime/agent-session-runtime-attribution' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { - attachFingerprintFields, - type AgentSessionAttachParams -} from './structured-agent-session-attach' -import { performAttach } from './structured-agent-session-attach-flow' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const NOW = 1_800_000_000_000 -const SESSION = 'stamped-owner-session' -const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` -let root: string | null = null - -afterEach(async () => { - if (root) { - await rm(root, { recursive: true, force: true }) - } - root = null -}) - -function attachParams(): AgentSessionAttachParams { - const params: AgentSessionAttachParams = { - envelope: { - sessionId: SESSION, - clientOperationId: OPERATION, - expectedRuntimeFence: null, - payloadFingerprint: '' - }, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'codex', - agent: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - runtimeKind: 'native', - providerHandle: { kind: 'codex', threadId: 'thread-1' } - } - return { - ...params, - envelope: { - ...params.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(params) - }) - } - } -} - -it('starts a chat whose recorded owner carries the runtime stamp', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-stamped-owner-')) - const store = await openTestAgentSessionRecordStore(root) - // The record as the store published it, stamp included, rather than the transition's own copy. - const commit = store.commitProcessIdentity.bind(store) - vi.spyOn(store, 'commitProcessIdentity').mockImplementation(async (input) => { - await commit(input) - const published = store.getRecord(input.sessionId) - if (!published) { - throw new Error('the committed record is missing') - } - return published - }) - const adapter: StructuredAgentSessionAdapter = { - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire: vi.fn( - async ({ fence, spawnToken, onSpawned }) => { - const process = { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken } - await onSpawned?.(process) - return { - acquisitionGeneration: globalThis.crypto.randomUUID(), - process, - link: { - linkId: 'created-link', - handle: codexProviderHandle('thread-1'), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - } - } - ), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } - - const attached = await performAttach({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(), - now: () => NOW, - onAttached: () => {} - }) - - expect(attached).toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.lease.ownerProcess).toMatchObject({ - pid: 4242, - runtime: agentSessionRuntimeIncarnation() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts deleted file mode 100644 index 0b6144ee8f1c..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router-registry.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' -import type { - AgentSessionAcquisition, - StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' -import { StructuredAgentRegistry } from './structured-agent-registry' -import type { StructuredAgentDefinition } from './structured-agent-definition' - -const LOCAL: AgentSessionExecutionLocation = { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' -} - -function identity(sessionId: string, agent: string): AgentSessionJournalIdentity { - return { - sessionId, - workspaceId: 'workspace-1', - hostId: 'local', - agent, - providerHandle: claudeProviderHandle('provider-session-1', null) - } -} - -function acquisition(fence: number, spawnToken: string): AgentSessionAcquisition { - return { - acquisitionGeneration: globalThis.crypto.randomUUID(), - process: { hostId: 'local', pid: 1, processStartTimeMs: 1, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: claudeProviderHandle('provider-session-1', null), - origin: 'created', - mintedAtFence: fence, - observedAt: 1 - } - } -} - -function fakeAdapter( - overrides: Partial = {} -): StructuredAgentSessionAdapter { - return { - supportsLocation: () => true, - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire: vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn(), - ...overrides - } -} - -/** An agent this build does not ship, declared the way a new adapter would declare itself. */ -const PILOT: StructuredAgentDefinition = { - agent: 'grok', - handleTransport: 'acp', - accountHomeVariable: 'GROK_HOME', - capabilities: { - rewind: false, - compact: false, - threadGoal: false, - contextUsage: false, - imagePrompts: false, - steering: 'queue', - approvalEnforcement: 'orca' - }, - restingOptions: { - acceptsKey: () => false, - fallbackModels: () => null, - effortDefaultsToModel: false - } -} - -/** A double with every method Claude's and Codex's declarations need. */ -function declaringAdapter( - overrides: Partial = {} -): StructuredAgentSessionAdapter { - return fakeAdapter({ - compact: vi.fn(), - changeThreadGoal: vi.fn(), - rewind: vi.fn(), - recoverRewind: vi.fn(), - ...overrides - }) -} - -function claudeAndCodex( - adapters: { claude?: StructuredAgentSessionAdapter; codex?: StructuredAgentSessionAdapter } = {} -): StructuredAgentRegistry { - return new StructuredAgentRegistry([ - { definition: CLAUDE_STRUCTURED_AGENT, adapter: adapters.claude ?? declaringAdapter() }, - { definition: CODEX_STRUCTURED_AGENT, adapter: adapters.codex ?? declaringAdapter() } - ]) -} - -describe('StructuredAgentSessionAdapterRouter registry', () => { - it('routes a registered agent the router has no code for, and refuses an unregistered one', async () => { - const pilot = fakeAdapter() - const router = new StructuredAgentSessionAdapterRouter( - new StructuredAgentRegistry([{ definition: PILOT, adapter: pilot }]), - async () => {} - ) - - expect(router.supportsCreate(LOCAL, 'grok')).toBe(true) - expect(router.supportsCreate(LOCAL, 'claude')).toBe(false) - await router.acquire({ identity: identity('session-1', 'grok'), fence: 1, spawnToken: 's-1' }) - expect(pilot.acquire).toHaveBeenCalledOnce() - await expect( - router.acquire({ identity: identity('session-2', 'claude'), fence: 1, spawnToken: 's-2' }) - ).rejects.toThrow('structured sessions do not support claude') - }) - - it('refuses two registrations for one agent', () => { - expect( - () => - new StructuredAgentRegistry([ - { definition: PILOT, adapter: fakeAdapter() }, - { definition: PILOT, adapter: fakeAdapter() } - ]) - ).toThrow('structured agent grok is registered twice') - }) - - it.each([ - ['compact', 'compact'], - ['threadGoal', 'changeThreadGoal'], - ['rewind', 'rewind'], - ['rewind', 'recoverRewind'] - ] as const)('refuses a declared %s whose adapter has no %s', (capability, method) => { - const declared: StructuredAgentDefinition = { - ...PILOT, - capabilities: { ...PILOT.capabilities, [capability]: true } - } - const adapter = declaringAdapter({ [method]: undefined }) - - expect(() => new StructuredAgentRegistry([{ definition: declared, adapter }])).toThrow( - `structured agent grok declares a capability its adapter has no ${method} for` - ) - expect(() => new StructuredAgentRegistry([{ definition: PILOT, adapter }])).not.toThrow() - }) - - it('answers what each registered agent declares, and nothing for an unregistered one', () => { - const agents = claudeAndCodex() - - expect(agents.capabilities('codex')).toBe(CODEX_STRUCTURED_AGENT.capabilities) - expect(agents.capabilities('claude')).toBe(CLAUDE_STRUCTURED_AGENT.capabilities) - expect(agents.capabilities('grok')).toBeNull() - expect(agents.definition('grok')).toBeNull() - expect(agents.definitions()).toEqual([CLAUDE_STRUCTURED_AGENT, CODEX_STRUCTURED_AGENT]) - }) - - it('lets a session narrow a declared rewind but never widen an undeclared one', async () => { - const narrowing = declaringAdapter({ - rewindSupport: () => ({ supported: false, reason: 'history-not-paginated' }) - }) - const widening = declaringAdapter({ rewindSupport: () => ({ supported: true }) }) - const router = new StructuredAgentSessionAdapterRouter( - claudeAndCodex({ codex: narrowing, claude: widening }), - async () => {} - ) - const unsupported = { supported: false, reason: 'unsupported' } - - expect(router.rewindSupport('session-1', 'codex')).toEqual({ - supported: false, - reason: 'history-not-paginated' - }) - // Claude declares no rewind; its adapter's answer cannot claim one, at rest or live. - expect(router.rewindSupport('session-1', 'claude')).toEqual(unsupported) - await router.acquire({ identity: identity('session-1', 'claude'), fence: 1, spawnToken: 's' }) - expect(router.rewindSupport('session-1')).toEqual(unsupported) - expect(router.rewindSupport('session-2', 'grok')).toEqual(unsupported) - }) -}) - -describe('structured agent definitions', () => { - it('declares what Claude and Codex structured chats already did', () => { - expect(CLAUDE_STRUCTURED_AGENT.capabilities).toEqual({ - rewind: false, - compact: true, - threadGoal: false, - contextUsage: true, - imagePrompts: true, - steering: 'inject', - approvalEnforcement: 'provider' - }) - expect(CODEX_STRUCTURED_AGENT.capabilities).toEqual({ - rewind: true, - compact: true, - threadGoal: true, - contextUsage: false, - imagePrompts: true, - steering: 'inject', - approvalEnforcement: 'provider' - }) - }) - - it('keeps each agent’s resting option rules with its definition', () => { - const claude = CLAUDE_STRUCTURED_AGENT.restingOptions - const codex = CODEX_STRUCTURED_AGENT.restingOptions - expect(claude.fallbackModels()?.length).toBeGreaterThan(0) - expect(codex.fallbackModels()).toBeNull() - expect(claude.effortDefaultsToModel).toBe(true) - expect(codex.effortDefaultsToModel).toBe(false) - expect(claude.acceptsKey('model')).toBe(true) - expect(codex.acceptsKey('model')).toBe(true) - expect(claude.acceptsKey('no-such-option')).toBe(false) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts deleted file mode 100644 index 0c79badd56c9..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, expectTypeOf, it, vi } from 'vitest' - -import { - AgentSessionAcquisitionExitProvenError, - AgentSessionAcquisitionExitUnprovenError, - AgentSessionAcquisitionRefusal, - AgentSessionAcquisitionRootExitObservedError, - type AgentSessionAcquisition, - type StructuredAgentSessionAdapter, - type StructuredAgentSessionLifecycleEvent -} from './structured-agent-session-adapter' -import { rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-provider-exit-proof' - -it('requires owned spawn identity and protocol open, without a started event', () => { - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf< - NonNullable - >() - expectTypeOf< - Extract - >().toEqualTypeOf() -}) - -describe('failed agent-session acquisition cleanup', () => { - it('names a failure exit-proven after proven cleanup, keeping its diagnostic and cause', async () => { - const cause = new Error('proof failed') - const thrown = await rethrowAfterAgentSessionAcquisitionCleanup( - { releaseAcquisition: vi.fn(async () => true) }, - 'session-1', - cause - ).catch((error: unknown) => error) - - expect(thrown).toBeInstanceOf(AgentSessionAcquisitionExitProvenError) - expect(thrown).toMatchObject({ message: 'proof failed', cause }) - }) - - it.each([ - ['a refusal', new AgentSessionAcquisitionRefusal('not signed in')], - ['a root exit', new AgentSessionAcquisitionRootExitObservedError(new Error('exited'))], - ['a host store code', new Error('agent_session_checkpoint_stale')] - ])('keeps %s that already names its verdict after proven cleanup', async (_label, cause) => { - await expect( - rethrowAfterAgentSessionAcquisitionCleanup( - { releaseAcquisition: vi.fn(async () => true) }, - 'session-1', - cause - ) - ).rejects.toBe(cause) - }) - - it('reports unproven exit when cleanup returns false', async () => { - await expect( - rethrowAfterAgentSessionAcquisitionCleanup( - { releaseAcquisition: vi.fn(async () => false) }, - 'session-1', - new Error('proof failed') - ) - ).rejects.toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) - }) - - it('keeps a first-hand root exit that cleanup observed, with the provider diagnostic', async () => { - const cause = new Error('proof failed') - const exit = new AgentSessionAcquisitionRootExitObservedError( - new Error('claude stream-json exited (code 1): crashed') - ) - const error = await rethrowAfterAgentSessionAcquisitionCleanup( - { - releaseAcquisition: vi.fn(async () => { - throw exit - }) - }, - 'session-1', - cause - ).catch((thrown: unknown) => thrown) - - expect(error).toBeInstanceOf(AgentSessionAcquisitionRootExitObservedError) - expect(error).not.toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) - expect((error as Error).message).toBe('claude stream-json exited (code 1): crashed') - expect((error as Error).cause).toMatchObject({ errors: [cause, exit] }) - }) - - it('reports unproven exit when cleanup throws', async () => { - const error = await rethrowAfterAgentSessionAcquisitionCleanup( - { - releaseAcquisition: vi.fn(async () => { - throw new Error('cleanup failed') - }) - }, - 'session-1', - new Error('proof failed') - ).catch((cause: unknown) => cause) - - expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) - expect(error).toMatchObject({ cause: expect.any(AggregateError) }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts deleted file mode 100644 index 28ec07ecf2cb..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -// A Claude chat whose Claude is not running offers the `/` commands and skills Claude would read -// from its folders on the host that runs it: after a /clear, after a relaunch, as files change. - -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { - AgentSessionSlashCommand, - AgentSessionSubscribeEvent -} from '../../../shared/agent-session-wire' -import { - CLAUDE_AT_REST_COMMANDS_TTL_MS, - ClaudeAtRestCommandCatalog -} from '../../claude/claude-at-rest-commands' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' -import { - HOST_TEST_LOCATION, - HOST_TEST_NOW, - HOST_TEST_SESSION as SESSION, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const caller = { callerKey: 'desktop' } -const CLAUDE_SESSION = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' - -let directory: string -let workspace: string -let account: string -let clock: number -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost - -/** Claude is never running here, so the only `/` surface is the one read from its folders. */ -function adapter(catalog: ClaudeAtRestCommandCatalog): StructuredAgentSessionAdapter { - return { - supportsLocation: () => true, - supportsCreate: () => true, - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire: vi.fn(async (input) => ({ - acquisitionGeneration: globalThis.crypto.randomUUID(), - process: { - hostId: 'local', - pid: 4001, - processStartTimeMs: HOST_TEST_NOW, - spawnToken: input.spawnToken - }, - link: { - linkId: `link-${input.fence}`, - mintedAtFence: input.fence, - observedAt: HOST_TEST_NOW, - origin: 'created' as const, - handle: claudeProviderHandle(CLAUDE_SESSION, null) - } - })), - atRestCommands: catalog, - dispatch: vi.fn(async () => ({ state: 'unknown' as const, reason: 'test' })), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => {}, - setOption: async () => {}, - releaseAcquisition: async () => true, - closeSession: async () => true - } -} - -function catalogFor(workspacePath: string): ClaudeAtRestCommandCatalog { - return new ClaudeAtRestCommandCatalog({ - resolveWorkspacePath: async () => workspacePath, - now: () => clock, - // Only the folders this test writes, never the machine's own home. - discover: async (args) => { - const { discoverSkills } = await import('../../skills/discovery') - return discoverSkills({ ...args, homeDir: join(directory, 'home'), refresh: true }) - } - }) -} - -async function openHost(catalog = catalogFor(workspace)): Promise { - store = await openTestAgentSessionRecordStore(directory) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - store, - adapter: adapter(catalog), - journalDatabase: openTestJournalHostDatabase(directory), - claimKeyId: 'key', - logger: recordingStructuredAgentSessionLogger().logger, - now: () => clock, - probeOwner: async () => ({ outcome: 'pid-absent' }) - }) -} - -async function writeSkill(root: string, name: string): Promise { - await mkdir(join(root, name), { recursive: true }) - await writeFile(join(root, name, 'SKILL.md'), `---\nname: ${name}\ndescription: A skill\n---\n`) -} - -async function writeCommand(root: string, file: string): Promise { - await mkdir(join(root, file, '..'), { recursive: true }) - await writeFile(join(root, file), '---\ndescription: A command\n---\nDo it.\n') -} - -/** The `/` surface a pane subscribed to `sessionId` shows once the scan has landed. */ -async function menuOf(sessionId: string): Promise { - let commands: AgentSessionSlashCommand[] | null | undefined - const emit = (event: AgentSessionSubscribeEvent) => { - if ('commands' in event) { - commands = event.commands - } - } - const unsubscribe = await host.subscribe({ id: `pane-${sessionId}`, sessionId, emit }) - await vi.waitFor(() => expect(commands).toBeTruthy()) - unsubscribe() - return commands ?? null -} - -const namesOf = (menu: AgentSessionSlashCommand[] | null, kind: 'skill' | 'command') => - (menu ?? []).filter((entry) => entry.kind === kind).map((entry) => entry.name) - -beforeEach(async () => { - resetHostTestOperationIds() - clock = HOST_TEST_NOW - directory = await mkdtemp(join(tmpdir(), 'orca-at-rest-commands-')) - workspace = join(directory, 'workspace') - account = join(directory, 'claude-account') - await writeSkill(join(workspace, '.claude', 'skills'), 'review-pr') - await writeCommand(join(account, 'commands'), 'deploy.md') - await openHost() - const attached = await host.attach( - caller, - hostTestAttachParams(null, { - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: account }, - providerHandle: { kind: 'claude', sessionId: CLAUDE_SESSION, leafUuid: null } - }) - ) - expect(attached).toMatchObject({ ok: true }) - await host.setSessionTabVisibility(SESSION, true) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(directory, { recursive: true, force: true }) -}) - -describe("a Claude chat whose Claude isn't running shows the `/` surface from its folders", () => { - it('after /clear, before the fresh agent context starts', async () => { - const cleared = await host.conversationCommand(caller, { - command: 'clear', - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)!.lease.runtimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.conversationCommand', - sessionId: SESSION, - fields: { command: 'clear' } - }) - } - }) - expect(cleared).toMatchObject({ ok: true }) - const replacement = SESSION - const menu = await menuOf(replacement) - expect(namesOf(menu, 'skill')).toEqual(['review-pr']) - // Everything the menu offered before a list existed, then what the folders add. - expect(namesOf(menu, 'command')).toEqual(['model', 'effort', 'clear', 'compact', 'deploy']) - }) - - it('keeps a slow scan once it answers, never a staler one after a newer, and runs at most two', async () => { - const pending: ((skills: string[]) => void)[] = [] - const catalog = new ClaudeAtRestCommandCatalog({ - resolveWorkspacePath: async () => workspace, - now: () => clock, - discover: () => - new Promise((resolve) => { - pending.push((names) => - resolve({ - skills: names.map((name) => ({ - id: name, - name, - description: null, - providers: ['claude'], - sourceKind: 'repo', - sourceLabel: 'repo', - rootPath: workspace, - directoryPath: workspace, - skillFilePath: workspace, - installed: true, - updatedAt: null - })), - sources: [], - scannedAt: clock - }) - ) - }) - }) - const record = store.getRecord(SESSION)! - const skills = () => namesOf(catalog.read(record) ?? null, 'skill') - const changed = vi.fn() - catalog.onChange(changed) - catalog.read(record) - await vi.waitFor(() => expect(pending).toHaveLength(1)) - // Overdue: a second scan starts beside the first; a third never does while both run. - clock += 30_000 - catalog.read(record) - await vi.waitFor(() => expect(pending).toHaveLength(2)) - clock += 30_000 - catalog.read(record) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(pending).toHaveLength(2) - // The second answers first; the first, older one landing after it changes nothing. - pending[1](['newer']) - await vi.waitFor(() => expect(skills()).toEqual(['newer'])) - pending[0](['older']) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(skills()).toEqual(['newer']) - expect(changed).toHaveBeenCalledTimes(1) - // A slow scan is not thrown away: the next one, however long it takes, is kept. - clock += CLAUDE_AT_REST_COMMANDS_TTL_MS - catalog.read(record) - await vi.waitFor(() => expect(pending).toHaveLength(3)) - clock += 35_000 - pending[2](['slow']) - await vi.waitFor(() => expect(skills()).toEqual(['slow'])) - }) - - it('waits out the window from when a scan lands, even one that failed', async () => { - let calls = 0 - let failNext = true - const catalog = new ClaudeAtRestCommandCatalog({ - resolveWorkspacePath: async () => workspace, - now: () => clock, - discover: async (args) => { - calls += 1 - // Each scan takes 15 s of the clock, longer than the window. - clock += 15_000 - if (failNext) { - failNext = false - throw new Error('unreadable') - } - const { discoverSkills } = await import('../../skills/discovery') - return discoverSkills({ ...args, homeDir: join(directory, 'home'), refresh: true }) - } - }) - const record = store.getRecord(SESSION)! - const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - try { - catalog.read(record) - await vi.waitFor(() => expect(warned).toHaveBeenCalledOnce()) - catalog.read(record) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(calls).toBe(1) - clock += CLAUDE_AT_REST_COMMANDS_TTL_MS - catalog.read(record) - await vi.waitFor(() => - expect(namesOf(catalog.read(record) ?? null, 'skill')).toEqual(['review-pr']) - ) - clock += CLAUDE_AT_REST_COMMANDS_TTL_MS - 1 - catalog.read(record) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(calls).toBe(2) - } finally { - warned.mockRestore() - } - }) - - it('after a relaunch, with nothing remembered from before it', async () => { - await host.flushAllStreamedEvents() - await openHost() - const menu = await menuOf(SESSION) - expect(namesOf(menu, 'skill')).toEqual(['review-pr']) - expect(namesOf(menu, 'command')).toContain('deploy') - }) - - it('with a command added on disk, pushed to a pane already showing the chat', async () => { - let commands: AgentSessionSlashCommand[] | null | undefined - await host.subscribe({ - id: 'pane', - sessionId: SESSION, - emit: (event) => { - if ('commands' in event) { - commands = event.commands - } - } - }) - await vi.waitFor(() => expect(namesOf(commands ?? null, 'command')).toContain('deploy')) - await writeCommand(join(workspace, '.claude', 'commands'), 'frontend/test.md') - clock += CLAUDE_AT_REST_COMMANDS_TTL_MS - // Anything that reads the surface after the window scans again; the pane is told the answer. - host.readCommands(SESSION) - await vi.waitFor(() => - expect(namesOf(commands ?? null, 'command')).toEqual( - expect.arrayContaining(['deploy', 'frontend:test']) - ) - ) - }) - - it('from the folders of the host that runs it, never another host’s', async () => { - const remoteWorkspace = join(directory, 'remote-workspace') - await writeSkill(join(remoteWorkspace, '.claude', 'skills'), 'remote-only') - const remote = catalogFor(remoteWorkspace) - const record = store.getRecord(SESSION)! - remote.read(record) - await vi.waitFor(() => - expect(namesOf(remote.read(record) ?? null, 'skill')).toEqual(['remote-only']) - ) - // A chat this host does not run is answered by nothing here, not by this host's folders. - const local = catalogFor(workspace) - const elsewhere = { - ...record, - location: { ...HOST_TEST_LOCATION, executionHostId: 'ssh:box' as const } - } - local.read(elsewhere) - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(local.read(elsewhere)).toBeUndefined() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts deleted file mode 100644 index 1c8407d1fc27..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -// Stopping an agent and closing its conversation's handle are two things, and a reader, a session -// list and a send each see only the one that happened. Ticks are driven by hand. - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import { readStructuredSessionGateFacts } from '../../runtime/orchestration/structured-mailbox-pointer-host' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import { setStructuredAgentSessionHost } from './structured-agent-session-registry' -import { - collectSubscriber, - createRestTestRig, - foundRestTestChat, - IDLE_MS, - readerSaw, - REST_TEST_CALLER as CALLER, - REST_TEST_SESSION as SESSION, - restTestSend, - sweepOnce, - type RestTestRig -} from './structured-agent-session-rest-test-rig' -import { hostTestAttachParams } from './structured-agent-session-host-test-data' - -let rig: RestTestRig - -const COLD_START = { timeout: 10_000 } - -beforeEach(async () => { - rig = await createRestTestRig({ idleSweep: { intervalMs: 3_600_000 } }) - setStructuredAgentSessionHost(rig.host) -}) - -afterEach(async () => { - setStructuredAgentSessionHost(null) - await rig.dispose() -}) - -function fence(): number { - return rig.store.getRecord(SESSION)?.lease.runtimeFence ?? 1 -} - -function openSession(): StructuredAgentSessionHostSession | undefined { - return rig.host.collaboratorsForTests().sessions.get(SESSION) -} - -function statusRows(): AgentSessionStatusSummary[] { - return rig.statusEvents - .flatMap((event) => (event.type === 'status' ? [event.session] : [])) - .filter((summary) => summary.sessionId === SESSION) -} - -describe('a stop that fails', () => { - it('is retried on the next tick (P2-11 a)', async () => { - await foundRestTestChat(rig) - rig.adapter.closeSession.mockResolvedValueOnce(false) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - // The child stays on record with its close unproven, and the next tick joins it. - expect(openSession()?.child?.close).toMatchObject({ cause: 'evict' }) - await sweepOnce(rig.host) - - expect(rig.adapter.closeSession).toHaveBeenCalledTimes(2) - expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - expect(statusRows().at(-1)?.hostExecutionOwned).toBeUndefined() - }) - - it('re-derives a release its wind-down could not write before the handle closes (P2-11 b)', async () => { - await foundRestTestChat(rig) - rig.clock.now += IDLE_MS + 1 - // The child is proven gone and its work settled, then handing the lease back fails once. - const transition = vi - .spyOn(rig.store, 'transitionHandoff') - .mockRejectedValueOnce(new Error('store unavailable')) - - await sweepOnce(rig.host) - - // Reported, never retried as a stop: the handle's close writes the release from the proof. - expect(transition).toHaveBeenCalledTimes(2) - expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - expect(rig.adapter.closeSession).toHaveBeenCalledOnce() - expect(rig.host.hasSession(SESSION)).toBe(false) - }) -}) - -describe('closing the handle', () => { - it('keeps a tabbed chat listed and its reader open, and a send reopens it for that reader (P2-14)', async () => { - await foundRestTestChat(rig) - const reader = collectSubscriber() - await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - expect(rig.host.hasSession(SESSION)).toBe(false) - // The row stays in the agent-status store, idle and no longer running here, and a session - // list that connects now still gets it. - expect(rig.sink.forget).not.toHaveBeenCalled() - expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION }) - expect(statusRows().at(-1)?.hostExecutionOwned).toBeUndefined() - const late: { type: string; sessions?: AgentSessionStatusSummary[] }[] = [] - rig.host.subscribeStatus({ id: 'late', emit: (event) => late.push(event) }) - expect(late[0]?.sessions?.map((summary) => summary.sessionId)).toContain(SESSION) - expect(reader.events.some((event) => event.type === 'end')).toBe(false) - - const sent = await rig.host.send(CALLER, restTestSend('after the close', fence())) - expect(sent.ok).toBe(true) - // A cold start: reopen, reconcile and acquire. - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), COLD_START) - // The reader opened on the first handle is fed by the second. - await vi.waitFor(() => expect(readerSaw(reader.events).texts).toContain('after the close')) - }) - - it('drops the agent-status row of a chat whose tab is gone (P2-14, retired)', async () => { - await foundRestTestChat(rig) - await rig.store.setSessionTabVisibility(SESSION, false) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - expect(rig.host.hasSession(SESSION)).toBe(false) - expect(rig.sink.forget).toHaveBeenCalled() - }) - - it('forgets the row of a resting chat once its tab closes (P2-32)', async () => { - await foundRestTestChat(rig) - rig.clock.now += IDLE_MS + 1 - await sweepOnce(rig.host) - expect(rig.sink.forget).not.toHaveBeenCalled() - - await rig.host.setSessionTabVisibility(SESSION, false) - expect(rig.sink.forget).toHaveBeenCalled() - }) - - it('keeps the row when a chat with an open tab is evicted, and forgets it once the tab closes', async () => { - await foundRestTestChat(rig) - - await rig.host.close(SESSION, 'evict') - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) - // The stop says not-running; the row belongs to the tab, so nothing forgets it. - expect(rig.sink.forget).not.toHaveBeenCalled() - expect(rig.sink.publish.mock.calls.at(-1)?.[0]).toMatchObject({ sessionId: SESSION }) - expect(rig.sink.publish.mock.calls.at(-1)?.[0].hostExecutionOwned).toBeUndefined() - - await rig.host.setSessionTabVisibility(SESSION, false) - expect(rig.sink.forget).toHaveBeenCalledOnce() - }) - - it('never hands a reader a handle it is closing (P2-25)', async () => { - await foundRestTestChat(rig) - const journal = openSession()?.journal - if (!journal) { - throw new Error('the conversation should be open') - } - const closing = Promise.withResolvers() - const gate = Promise.withResolvers() - const close = journal.close.bind(journal) - vi.spyOn(journal, 'close').mockImplementation(async () => { - await close() - closing.resolve() - await gate.promise - }) - rig.clock.now += IDLE_MS + 1 - const tick = sweepOnce(rig.host) - await closing.promise - - const history = rig.host.history({ sessionId: SESSION, direction: 'tail' }) - const facts = readStructuredSessionGateFacts(SESSION) - gate.resolve() - await tick - - const page = await history - expect(page.ok && page.page.items.length).toBeGreaterThan(0) - await expect(facts).resolves.not.toBeNull() - // And the handle a write finds is a live one. - const sent = await rig.host.send(CALLER, restTestSend('after the close', fence())) - expect(sent.ok).toBe(true) - // A cold start: reopen, reconcile and acquire. - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2), COLD_START) - }) - - it('never re-enters the session lock: a send and a Stop right after still finish (P2-28)', async () => { - await foundRestTestChat(rig) - rig.clock.now += IDLE_MS + 1 - const tick = sweepOnce(rig.host) - const within = (work: Promise) => - Promise.race([ - work, - new Promise((_, reject) => setTimeout(() => reject(new Error('wedged')), 2_000)) - ]) - const sent = within(rig.host.send(CALLER, restTestSend('right after', fence()))) - - await within(tick) - expect(await sent).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2)) - await expect(within(sweepOnce(rig.host))).resolves.toBeUndefined() - }) -}) - -describe('the sweep and the lease (P2-20)', () => { - it('reads only open conversations and never probes or resolves a lease', async () => { - await foundRestTestChat(rig) - const probeOwner = vi.fn(async () => ({ outcome: 'pid-absent' as const })) - await rig.restart({ probeOwner }) - setStructuredAgentSessionHost(rig.host) - await rig.store.transitionHandoff(SESSION, (current) => ({ - ...current, - lease: { ...current.lease, handoffStage: 'recovering' } - })) - const resolveRecovery = vi.spyOn( - rig.host.collaboratorsForTests().runtimeState, - 'resolveRecovery' - ) - // Open, at rest, on a lease still recovering. - await rig.host.journalSnapshot(SESSION) - expect(rig.host.hasSession(SESSION)).toBe(true) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - expect(rig.host.hasSession(SESSION)).toBe(false) - expect(probeOwner).not.toHaveBeenCalled() - expect(resolveRecovery).not.toHaveBeenCalled() - }) -}) - -describe('a start that never finishes (P2-15)', () => { - it('is stopped by the sweep, and the message held for it carries its one row and rejection', async () => { - const stopReason = 'Codex never finished starting, so Orca stopped it.' - const acquire = rig.adapter.acquire.getMockImplementation()! - rig.adapter.acquire.mockImplementationOnce(rig.heldStart(acquire)) - // Held for the starting child, which never proves its start. - const reader = collectSubscriber() - const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) - expect(attached.ok).toBe(true) - await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) - const sent = await rig.host.send(CALLER, restTestSend('stuck behind the start', fence())) - expect(sent.ok).toBe(true) - await vi.waitFor(() => - expect(rig.host.collaboratorsForTests().sessions.get(SESSION)?.child?.phase).toBe('starting') - ) - rig.clock.now += IDLE_MS + 1 - - await sweepOnce(rig.host) - expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION) - await vi.waitFor(() => - expect(readerSaw(reader.events).submissions).toContainEqual( - expect.objectContaining({ dispatchState: 'rejected', reason: stopReason }) - ) - ) - const errorRows = reader.events.flatMap((event) => - event.type === 'batch' - ? event.batch.items.filter( - (item) => item.body.kind === 'status' && item.body.tone === 'error' - ) - : [] - ) - expect(errorRows.map((item) => (item.body.kind === 'status' ? item.body.text : null))).toEqual([ - stopReason - ]) - - const again = await rig.host.send(CALLER, restTestSend('try again', fence())) - expect(again.ok).toBe(true) - expect(rig.adapter.dispatch).not.toHaveBeenCalled() - await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(1), COLD_START) - await expect(sweepOnce(rig.host)).resolves.toBeUndefined() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts deleted file mode 100644 index bddc0a7f854b..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts +++ /dev/null @@ -1,352 +0,0 @@ -// A record this build cannot drive (its agent's definition now names another transport or account -// variable, or the record pins another agent's variable) stays readable; only starting its agent is -// refused, at the one launch admission every agent passes through. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionAccountHome } from '../../../shared/agent-session-record' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { - attachFingerprintFields, - type AgentSessionAttachParams -} from './structured-agent-session-attach' -import { performAttach } from './structured-agent-session-attach-flow' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import type { StructuredAgentDefinition } from './structured-agent-definition' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { StructuredAgentRegistry } from './structured-agent-registry' -import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' -import { StructuredAgentSessionHost } from './structured-agent-session-host' - -const NOW = 1_800_000_000_000 -const SESSION = 'grok-session' -const GROK_HOME: AgentSessionAccountHome = { variable: 'GROK_HOME', path: '/home/dev/.grok' } -let root: string | null = null - -afterEach(async () => { - if (root) { - await rm(root, { recursive: true, force: true }) - } - root = null -}) - -const GROK: StructuredAgentDefinition = { - ...CODEX_STRUCTURED_AGENT, - agent: 'grok', - handleTransport: 'acp', - accountHomeVariable: 'GROK_HOME', - capabilities: { - ...CODEX_STRUCTURED_AGENT.capabilities, - rewind: false, - compact: false, - threadGoal: false - }, - restingOptions: { - acceptsKey: () => false, - fallbackModels: () => null, - effortDefaultsToModel: false - } -} - -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry reads only the methods a declaration needs; this one declares none of them. -const NO_METHODS = {} as StructuredAgentSessionAdapter -const grokRegistered = (definition: StructuredAgentDefinition = GROK) => - new StructuredAgentRegistry([{ definition, adapter: NO_METHODS }]) - -function params( - operation: string, - expectedRuntimeFence: number | null, - accountHome = GROK_HOME -): AgentSessionAttachParams { - const attach: AgentSessionAttachParams = { - envelope: { - sessionId: SESSION, - clientOperationId: `${NOW}-${operation.padStart(32, '0')}`, - expectedRuntimeFence, - payloadFingerprint: '' - }, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'grok', - agent: 'grok', - accountHome, - runtimeKind: 'native' - } - return { - ...attach, - envelope: { - ...attach.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(attach) - }) - } - } -} - -function grokAdapter(): StructuredAgentSessionAdapter { - return { - supportsLocation: () => true, - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire: vi - .fn() - .mockImplementation(async ({ fence, spawnToken }) => ({ - acquisitionGeneration: globalThis.crypto.randomUUID(), - process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, - link: { - linkId: `grok-link-${fence}`, - handle: { transport: 'acp', agent: 'grok', nativeId: 'grok-native-1' }, - origin: fence === 1 ? 'created' : 'resumed', - mintedAtFence: fence, - observedAt: NOW - } - })), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } -} - -async function attach(input: { - agents: StructuredAgentRegistry - adapter: StructuredAgentSessionAdapter - params: AgentSessionAttachParams - spawnToken: string - store?: AgentSessionRecordStore -}) { - const store = input.store ?? (await openTestAgentSessionRecordStore(root!)) - const result = await performAttach({ - agents: input.agents, - logger: createStructuredAgentSessionLogger(), - store, - adapter: input.adapter, - openConversation: async (record) => { - const journal = await openTestAttachConversation(openTestJournalHostDatabase(root!))(record) - return journal - }, - authority: { - spawnToken: input.spawnToken, - claimKeyId: 'key-1', - handoffOperationId: input.params.envelope.clientOperationId, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: input.params, - now: () => NOW, - onAttached: async (attached) => { - await attached.journal.close() - } - }) - return { result, store } -} - -/** Persist and reopen a Grok chat whose original child is gone. */ -async function createdGrokChat() { - root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) - const created = await attach({ - agents: grokRegistered(), - adapter: grokAdapter(), - params: params('1', null), - spawnToken: 'spawn-a' - }) - expect(created.result).toMatchObject({ ok: true }) - const store = await openTestAgentSessionRecordStore(root) - await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW + 1 }) - return { store, fence: store.getRecord(SESSION)!.lease.runtimeFence } -} - -const UNSUPPORTED = { - ok: false, - refusal: { code: 'structured_agent_session_unsupported', details: { reason: 'hostUnsupported' } } -} - -it('refuses an unregistered agent before creating a durable record', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) - const adapter = grokAdapter() - const { result, store } = await attach({ - agents: new StructuredAgentRegistry([]), - adapter, - params: params('1', null), - spawnToken: 'spawn-unregistered' - }) - expect(result).toMatchObject(UNSUPPORTED) - expect(adapter.acquire).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)).toBeNull() -}) - -it('keeps saved history readable without a registration and resumes when it returns', async () => { - const created = await createdGrokChat() - await created.store.setSessionTabVisibility(SESSION, true) - const journal = await openTestAttachConversation(openTestJournalHostDatabase(root!))( - created.store.getRecord(SESSION)! - ) - await journal.appendItem( - { provider: 'orca', clientMessageId: 'saved-reply' }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Saved reply' }] }, - { fence: created.fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.close() - - const store = await openTestAgentSessionRecordStore(root!) - const agents = new StructuredAgentRegistry([]) - const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) - const acquire = vi.spyOn(router, 'acquire') - const host = new StructuredAgentSessionHost({ - store, - agents, - adapter: router, - journalDatabase: openTestJournalHostDatabase(root!), - claimKeyId: 'key-1', - probeOwner: async () => ({ outcome: 'pid-absent' }), - logger: createStructuredAgentSessionLogger(), - now: () => NOW + 2 - }) - try { - expect(router.supportsCreate(params('2', created.fence).location, 'grok')).toBe(false) - expect(store.listVisibleSessionIds()).toEqual([SESSION]) - await host.restoreReadableSessions([SESSION]) - expect(host.hasSession(SESSION)).toBe(true) - await expect(host.revealSession(SESSION)).resolves.toMatchObject({ - sessionId: SESSION, - agent: 'grok', - readable: true - }) - const history = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(history).toMatchObject({ - ok: true, - page: { - items: expect.arrayContaining([ - expect.objectContaining({ - body: { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'Saved reply' }] - } - }) - ]) - } - }) - const before = store.getRecord(SESSION)!.lease.runtimeFence - await expect( - host.attach({ callerKey: 'client-1' }, params('2', before)) - ).resolves.toMatchObject(UNSUPPORTED) - expect(acquire).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)!.lease.runtimeFence).toBe(before) - } finally { - await host.flushAllStreamedEvents() - } - - const adapter = grokAdapter() - const { result } = await attach({ - agents: grokRegistered(), - adapter, - params: params('3', store.getRecord(SESSION)!.lease.runtimeFence), - spawnToken: 'spawn-restored', - store - }) - expect(result).toMatchObject({ ok: true }) - expect(adapter.acquire).toHaveBeenCalledOnce() - expect(store.listVisibleSessionIds()).toEqual([SESSION]) -}) - -it.each([ - ['its transport', { ...GROK, handleTransport: 'grok-native' }], - ['its account variable', { ...GROK, accountHomeVariable: 'GROK_CONFIG_DIR' }] -])( - 'keeps a chat readable after its agent changes %s, and refuses to start it', - async (_change, changed) => { - const { store, fence } = await createdGrokChat() - const adapter = grokAdapter() - - const { result } = await attach({ - agents: grokRegistered(changed), - adapter, - params: params('2', fence), - spawnToken: 'spawn-b', - store - }) - - expect(result).toMatchObject(UNSUPPORTED) - expect(adapter.acquire).not.toHaveBeenCalled() - // Still listed and readable: its tab publishes and its history opens. - expect(store.getRecord(SESSION)).toMatchObject({ provider: 'grok', accountHome: GROK_HOME }) - expect(store.getRecord(SESSION)?.providerHandleChain[0]?.handle.transport).toBe('acp') - } -) - -it("refuses to start a chat that pins another agent's account variable", async () => { - root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) - const adapter = grokAdapter() - - const { result } = await attach({ - agents: grokRegistered(), - adapter, - params: params('1', null, { variable: 'CODEX_HOME', path: '/home/dev/.codex' }), - spawnToken: 'spawn-a' - }) - - expect(result).toMatchObject(UNSUPPORTED) - expect(adapter.acquire).not.toHaveBeenCalled() -}) - -it('starts the same chat again once its agent is declared as it was', async () => { - const { store, fence } = await createdGrokChat() - const adapter = grokAdapter() - - const { result } = await attach({ - agents: grokRegistered(), - adapter, - params: params('2', fence), - spawnToken: 'spawn-b', - store - }) - - expect(result).toMatchObject({ ok: true }) - expect(adapter.acquire).toHaveBeenCalledOnce() -}) - -it('refuses an attach whose agent is not the session it names', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-drivability-')) - const adapter = grokAdapter() - const mismatched = { ...params('1', null), agent: 'codex' } - - const { result, store } = await attach({ - agents: grokRegistered(), - adapter, - params: { - ...mismatched, - envelope: { - ...mismatched.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(mismatched) - }) - } - }, - spawnToken: 'spawn-a' - }) - - expect(result).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid', details: { reason: 'requestMalformed' } } - }) - expect(adapter.acquire).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)).toBeNull() -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts deleted file mode 100644 index f0e16d7ccf9e..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -// A child that dies between spawn and journal attach can still write through the host's event -// sink, which attach unbound and never re-bound. That queue must die with the failed create, or -// the next attach's drain barrier and shutdown's flush wait on it forever. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { - AgentSessionPreSpawnError, - type StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } -const EXIT_REASON = 'claude stream-json exited (code 1): claude: not signed in' - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-failed-create-sink-')) - resetHostTestOperationIds() - acquire = vi.fn(async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - // A resume continues the chain the first start created. - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW - }, - acquisitionGeneration: `generation-${fence}` - })) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: { - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire, - releaseAcquisition: vi.fn(async () => true), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) -}) - -afterEach(async () => { - await rm(root, { recursive: true, force: true }) -}) - -describe('a create that fails after its child wrote through the unbound sink', () => { - it.each([ - // The common failed start: answered as a refusal. - ['refused', new Error(EXIT_REASON)], - // A failure the attach cannot classify still throws, and must release the sink too. - ['thrown', new AgentSessionPreSpawnError(new Error(EXIT_REASON))] - ])( - 'releases the sink when %s, so a new create and shutdown both proceed', - async (_how, cause) => { - acquire.mockImplementationOnce(async ({ events }) => { - // The published child's exit reached the translator before any journal was attached. - events?.setActivity?.(null) - throw cause - }) - - const failed = host.attach(CALLER, hostTestAttachParams(null)) - await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") - : expect(failed).resolves.toMatchObject({ - ok: false, - refusal: { message: "Codex couldn't restart. Send your message to try again." } - })) - - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: true - }) - await expect(host.flushAllStreamedEvents()).resolves.toBeUndefined() - expect(acquire).toHaveBeenCalledTimes(2) - } - ) - - it.each([ - ['refused', new Error(EXIT_REASON)], - ['thrown', new AgentSessionPreSpawnError(new Error(EXIT_REASON))] - ])('releases the sink when a resume of a still-indexed session is %s', async (_how, cause) => { - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: true - }) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: `generation-${exitedFence}` - }) - const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - acquire.mockImplementationOnce(async ({ events }) => { - events?.setActivity?.(null) - throw cause - }) - - // The session stays indexed across this failure: it is a resume, not a create. - const failed = host.attach(CALLER, hostTestAttachParams(releasedFence)) - await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") - : expect(failed).resolves.toMatchObject({ ok: false })) - - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await expect(host.attach(CALLER, hostTestAttachParams(fence))).resolves.toMatchObject({ - ok: true - }) - expect(acquire).toHaveBeenCalledTimes(3) - // Only the adopted child's sink still takes writes: the failed attempt's closed with it, and - // the exited generation's closed when the resume replaced it. - const [exited, failedAttempt, resumed] = acquire.mock.calls.map(([input]) => input.events) - expect(failedAttempt?.tryPublish?.()).toEqual({ accepted: false, reason: 'closed' }) - expect(exited?.tryPublish?.()).toEqual({ accepted: false, reason: 'closed' }) - expect(resumed?.tryPublish?.()).toEqual({ accepted: true }) - await expect(host.flushAllStreamedEvents()).resolves.toBeUndefined() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts deleted file mode 100644 index ce7088cf7e9c..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import { encodeAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { - AgentSessionDispatchOutcome, - StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } - -function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -const attachParams = (): AgentSessionAttachParams => hostTestAttachParams(null) - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let answerPrompt: Mock -let ordinal = 0 - -function adapter(): StructuredAgentSessionAdapter { - const dispatch = vi.fn(async (): Promise => { - ordinal += 1 - return { - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal } - } - }) - return { - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire, - releaseAcquisition: vi.fn(async () => true), - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt, - setOption: vi.fn(async () => undefined) - } -} - -async function seedGroupedQuestion(): Promise<{ itemId: string; revision: number }> { - const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 100 } - const events = acquire.mock.calls.at(-1)?.[0].events - if (!events) { - throw new Error('seedGroupedQuestion requires an acquired session') - } - events.appendItem( - identity, - { - kind: 'question', - question: '2 grouped questions from Claude', - options: [], - questions: [ - { - id: 'q1', - question: 'Targets', - multiSelect: true, - options: [ - { id: 'target-web', label: 'Web' }, - { id: 'target-mobile', label: 'Mobile' } - ] - }, - { - id: 'q2', - question: 'Host', - multiSelect: false, - options: [], - freeTextQuestionId: 'q2' - } - ], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await host.flushStreamedEvents(SESSION) - const itemId = agentJournalItemKey(identity) - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - const appended = page.ok ? page.page.items.find((item) => item.itemId === itemId) : null - if (!appended) { - throw new Error('provider question was not written to the journal') - } - return { itemId, revision: appended.revision } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-wire-grouped-')) - resetHostTestOperationIds() - ordinal = 0 - acquire = vi.fn(async ({ fence }) => ({ - acquisitionGeneration: globalThis.crypto.randomUUID(), - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW - } - })) - answerPrompt = vi.fn(async ({ commit }) => commit()) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -describe('grouped question admission', () => { - it('reads the packed answer an older client sends into structured answers', async () => { - const attached = await host.attach(CALLER, attachParams()) - expect(attached.ok).toBe(true) - const prompt = await seedGroupedQuestion() - const answers = [ - { questionId: 'q1', optionIds: ['target-web', 'target-mobile'] }, - { questionId: 'q2', optionIds: [], other: 'SSH host' } - ] - const optionId = encodeAgentSessionQuestionAnswers(answers) - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId } - const result = await host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:question', fields), - kind: 'question', - ...fields - }) - expect(result).toMatchObject({ - ok: true, - value: { resolution: { state: 'resolved', selectedOptionId: optionId, answers } } - }) - expect(answerPrompt).toHaveBeenCalledWith( - expect.objectContaining({ itemId: prompt.itemId, response: { kind: 'answers', answers } }) - ) - }) - - it('takes structured answers past the old option-id bound and keeps the packed form for older readers', async () => { - const attached = await host.attach(CALLER, attachParams()) - expect(attached.ok).toBe(true) - const prompt = await seedGroupedQuestion() - const typed = 'Proceed with the replacement, but wait for the capture. '.repeat(40) - const answers = [ - { questionId: 'q1', optionIds: ['target-web'] }, - { questionId: 'q2', optionIds: [], other: typed } - ] - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, answers } - const result = await host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:question', fields), - kind: 'question', - ...fields - }) - - expect(typed.length).toBeGreaterThan(1024) - expect(result).toMatchObject({ - ok: true, - value: { - resolution: { - state: 'resolved', - selectedOptionId: encodeAgentSessionQuestionAnswers(answers), - answers - } - } - }) - expect(answerPrompt).toHaveBeenCalledWith( - expect.objectContaining({ response: { kind: 'answers', answers } }) - ) - }) - - it('refuses answers that do not match the questions without reaching the provider', async () => { - const attached = await host.attach(CALLER, attachParams()) - expect(attached.ok).toBe(true) - const prompt = await seedGroupedQuestion() - const answers = [{ questionId: 'q1', optionIds: ['target-web'] }] - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, answers } - const result = await host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:question', fields), - kind: 'question', - ...fields - }) - - expect(result).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid' } - }) - expect(answerPrompt).not.toHaveBeenCalled() - }) -}) - -describe('a grouped question answered again', () => { - const answers = [ - { questionId: 'q1', optionIds: ['target-web'] }, - { questionId: 'q2', optionIds: [], other: 'SSH host' } - ] - - async function answer( - prompt: { itemId: string; revision: number }, - given: typeof answers - ): ReturnType { - const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, answers: given } - return host.respondToPrompt(CALLER, { - envelope: envelope('agentSession.respondTo:question', fields), - kind: 'question', - ...fields - }) - } - - it('answers a re-click with the same answers from the resolution it holds', async () => { - expect((await host.attach(CALLER, attachParams())).ok).toBe(true) - const prompt = await seedGroupedQuestion() - const first = await answer(prompt, answers) - - // The reply to the first was lost, so the re-click still names the revision it saw. - const again = await answer(prompt, answers) - - expect(first.ok).toBe(true) - expect(again).toEqual({ ...first, replayed: false }) - expect(answerPrompt).toHaveBeenCalledTimes(1) - }) - - it('refuses different answers to a resolved question as moved on', async () => { - expect((await host.attach(CALLER, attachParams())).ok).toBe(true) - const prompt = await seedGroupedQuestion() - await answer(prompt, answers) - - const different = await answer(prompt, [ - { questionId: 'q1', optionIds: ['target-mobile'] }, - { questionId: 'q2', optionIds: [], other: 'SSH host' } - ]) - - expect(different).toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_item_revision_stale', - details: { reason: 'promptMoved' }, - resolution: { answers } - } - }) - expect(answerPrompt).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts deleted file mode 100644 index d79d0c8fafb4..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-held-child-renewal.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { probeAgentSessionProcessIdentity } from '../../runtime/agent-session-process-identity-probe' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { - closeTestJournalHostDatabase, - openTestJournalHostDatabase -} from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams -} from './structured-agent-session-host-test-data' -import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' - -const RENEW_INTERVAL_MS = 10_000 -const roots: string[] = [] - -afterEach(async () => { - vi.useRealTimers() - for (const root of roots) { - closeTestJournalHostDatabase(root) - } - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -/** A real host whose adapter spawned a child it could not read a start time for. */ -async function hostWithStartTimeLessChild(platform: NodeJS.Platform) { - const root = await mkdtemp(join(tmpdir(), 'orca-host-held-renewal-')) - roots.push(root) - const store = await openTestAgentSessionRecordStore(root) - const clock = { now: NOW } - const process = { live: true } - const holdsLiveProviderProcess = vi.fn( - (_sessionId: string, generation: string) => process.live && generation === 'gen-1' - ) - const adapter: StructuredAgentSessionAdapter = { - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire: async ({ fence }) => ({ - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: null, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - }, - acquisitionGeneration: 'gen-1' - }), - holdsLiveProviderProcess, - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn() - } - // The real PID probe with the pid present: no start time and no token echo to match. - const probeOwner = vi.fn((record: AgentSessionRecord) => - probeAgentSessionProcessIdentity({ - identity: record.lease.ownerProcess!, - deps: { platform, isPidPresent: () => true } - }) - ) - const host = new StructuredAgentSessionHost({ - logger: recordingProductionStructuredAgentSessionLogger().logger, - store, - adapter, - agents: claudeAndCodexDeclared(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - probeOwner, - now: () => clock.now - }) - return { host, store, clock, process, probeOwner, holdsLiveProviderProcess } -} - -describe.each(['darwin', 'win32'] as const)('host lease renewal on %s', (platform) => { - it('renews a held child without a PID probe until the adapter sees its root exit', async () => { - vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) - const { host, store, clock, process, probeOwner, holdsLiveProviderProcess } = - await hostWithStartTimeLessChild(platform) - const attached = await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null)) - expect(attached.ok).toBe(true) - const fence = store.getRecord(SESSION)!.lease.runtimeFence - - clock.now = NOW + RENEW_INTERVAL_MS - await vi.advanceTimersByTimeAsync(RENEW_INTERVAL_MS) - await vi.waitFor(() => - expect(store.getRecord(SESSION)?.lease.lastRenewedAt).toBe(NOW + RENEW_INTERVAL_MS) - ) - expect(probeOwner).not.toHaveBeenCalled() - expect(holdsLiveProviderProcess).toHaveBeenCalledWith(SESSION, 'gen-1') - - // The adapter saw the root exit; the host has not settled it, so its child is still on record. - process.live = false - clock.now = NOW + 2 * RENEW_INTERVAL_MS - await vi.advanceTimersByTimeAsync(RENEW_INTERVAL_MS) - await vi.waitFor(() => expect(probeOwner).toHaveBeenCalledOnce()) - - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(fence) - expect(store.getRecord(SESSION)?.lease.lastRenewedAt).toBe(NOW + RENEW_INTERVAL_MS) - await host.flushAllStreamedEvents() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts deleted file mode 100644 index 409311e7baa9..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -// The helper is what the restart specs remove their temp directory behind, so what it waits for is -// load-bearing: a store commit that lands afterwards re-creates the directory it just removed. - -import { existsSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } - -describe('abandoning a structured agent-session host', () => { - it('waits for the restart the delivery loop woke for an accepted send', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-abandon-')) - const store = await openTestAgentSessionRecordStore(root) - // Holds the restart inside its provider acquisition, so the point teardown must not run past - // is exact rather than a timing window. - let gate: Promise | null = null - let openGate = (): void => {} - let reportEntered = (): void => {} - const entered = new Promise((resolve) => { - reportEntered = resolve - }) - const adapter: StructuredAgentSessionAdapter = { - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire: async ({ fence }) => { - if (gate) { - reportEntered() - await gate - } - return { - acquisitionGeneration: globalThis.crypto.randomUUID(), - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW - 1_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - } - }, - dispatch: async () => ({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => undefined, - releaseAcquisition: async () => true, - setOption: async () => undefined - } - const host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter, - probeOwner: async () => ({ - outcome: 'identity-matched', - matchedOn: ['process-start-time'] - }), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - // The conversation stays and its provider child does not, so the next send makes the delivery - // loop start one — the shape the refusal-oracle spec ends on. - await host.close(SESSION, 'evict') - gate = new Promise((resolve) => { - openGate = resolve - }) - - const body = hostTestMessage('delivery loop') - expect( - await host.send(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: `${NOW}-000000000000000000000000000003e9`, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body - }) - ).toMatchObject({ ok: true }) - await entered - - let abandoned = false - const abandoning = abandonStructuredAgentSessionHost(host).then(() => { - abandoned = true - }) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(abandoned).toBe(false) - - openGate() - await abandoning - - // Nothing is left to put the directory back after this returns. - await rm(root, { recursive: true }) - await new Promise((resolve) => setTimeout(resolve, 200)) - expect(existsSync(root)).toBe(false) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts deleted file mode 100644 index 97116e7b4915..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -// The chat's own Stop and every session list read whether the main agent is working through one -// rule, but over two copies of the host's journal: the chat reduces its stream, and a list reads the -// status feed. Both leave the host from one publication edge, so once each has landed they must say -// the same thing. Against the real host, store, journal, stream and status feed. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemBody -} from '../../../shared/agent-session-journal-types' -import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' -import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' -import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' -import { - EMPTY_STRUCTURED_AGENT_SESSION, - reduceStructuredAgentSession, - type StructuredAgentSessionState -} from '../../../shared/structured-agent-session-reducer' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } -const PROVIDER_ROW = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1' } - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let dispatch: Mock -let events: StructuredAgentSessionEventSink | undefined -/** The session's child records, as the host's store serves them to every surface. */ -let records: AgentChildWorkView[] -let chat: StructuredAgentSessionState -let listed: AgentSessionStatusSummary | undefined - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-main-agent-working-')) - resetHostTestOperationIds() - events = undefined - records = [] - chat = EMPTY_STRUCTURED_AGENT_SESSION - listed = undefined - // Written, and the provider has neither opened a turn for it nor answered it. - dispatch = vi.fn(async () => ({ state: 'admitted' as const })) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: startsWhenPublished( - { - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire: async (input) => { - events = input.events - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: input.spawnToken - }, - acquisitionGeneration: 'generation-1', - link: { - linkId: `link-${input.fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created' as const, - mintedAtFence: input.fence, - observedAt: NOW - } - } - }, - dispatch, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - () => host - ), - statusSink: { publish: () => {}, forget: () => {}, readChildWork: () => records }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-1', - now: () => NOW - }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - host.subscribe({ - id: 'chat-1', - sessionId: SESSION, - emit: (event) => { - chat = reduceStructuredAgentSession(chat, { type: 'event', event: structuredClone(event) }) - } - }) - host.subscribeStatus({ - id: 'list-1', - emit: (event) => { - if (event.type === 'status' && event.session.sessionId === SESSION) { - listed = structuredClone(event.session) - } else if (event.type === 'snapshot') { - listed = event.sessions.find((session) => session.sessionId === SESSION) ?? listed - } - } - }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -async function send(text: string): Promise { - const body = hostTestMessage(text) - const clientOperationId = hostTestOperationId() - expect( - await host.send(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId, - expectedRuntimeFence: 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body - }) - ).toMatchObject({ ok: true }) - return clientOperationId -} - -async function provider(ordinal: number, body: AgentJournalItemBody): Promise { - events!.appendItem({ ...PROVIDER_ROW, ordinal }, body, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - events!.publish() - await host.flushStreamedEvents(SESSION) -} - -/** What the chat's Stop reads: the transport state's rule over the chat's reduced stream. */ -function chatReadsWorking(): boolean { - return isStructuredAgentSessionMainAgentWorking( - activeStructuredAgentSessionTurnId(chat.items), - chat.submissions, - chat.fence - ) -} - -/** What a session list's row reads for the main agent, and the whole row, from the status feed. */ -function listReads(): { mainAgent: string; row: string } { - const summary = listed - if (!summary?.status) { - return { mainAgent: 'none', row: 'none' } - } - const status = structuredAgentSessionAgentStatus({ - ...summary, - status: summary.status, - childWork: summary.children - }) - return { mainAgent: status.mainAgent.state, row: status.state } -} - -describe('the main agent read by the chat and by a session list', () => { - it('agrees while a rate-limited request retries with no turn open', async () => { - const id = await send('hello') - await vi.waitFor(async () => - expect((await host.journalSnapshot(SESSION)).submissions[0]?.handedOverAt).toBeDefined() - ) - // What Claude writes for an HTTP 429 retry: a status row, no echo, no turn. - await provider(1, { kind: 'status', text: 'rate_limit', tone: 'error' }) - await provider(2, { kind: 'status', text: 'rate_limit', tone: 'error' }) - - expect(chat.submissions.map((submission) => submission.clientMessageId)).toEqual([id]) - expect(activeStructuredAgentSessionTurnId(chat.items)).toBeNull() - expect(chatReadsWorking()).toBe(true) - expect(listReads().mainAgent).toBe('working') - }) - - it('agrees once the handed-over message is answered and only a subagent still runs', async () => { - dispatch.mockResolvedValueOnce({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } - }) - await send('fan out') - await vi.waitFor(async () => - expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('accepted') - ) - await provider(1, { kind: 'turn', turnId: 'turn-1', state: 'running' }) - expect(chatReadsWorking()).toBe(true) - expect(listReads().mainAgent).toBe('working') - - await provider(1, { kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'success' }) - records = [ - { - id: 'child-1', - providerId: 'task-1', - kind: 'agent', - name: 'deep_review', - state: 'working', - membership: 'live', - firstObservedAt: NOW, - observedAt: NOW, - stoppable: false, - invocation: { invocationId: 'task-1', generation: 1 } - } - ] - host.publishChildWorkEvidence(SESSION, []) - await host.flushStreamedEvents(SESSION) - - expect(chat.backgroundTasks?.children).toHaveLength(1) - // The row reads Working for the subagent; the main agent, and so Stop, does not. - expect(listReads()).toEqual({ mainAgent: 'done', row: 'working' }) - expect(chatReadsWorking()).toBe(false) - }) - - it('agrees when the child that was handed the message exits', async () => { - await send('hello') - await vi.waitFor(async () => - expect((await host.journalSnapshot(SESSION)).submissions[0]?.handedOverAt).toBeDefined() - ) - expect(chatReadsWorking()).toBe(true) - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - fence: store.getRecord(SESSION)!.lease.runtimeFence, - acquisitionGeneration: 'generation-1', - reason: 'codex app-server crashed', - cause: 'unexpected-exit' - }) - await host.flushStreamedEvents(SESSION) - - expect(chat.fence).toBe(store.getRecord(SESSION)!.lease.runtimeFence) - expect(chatReadsWorking()).toBe(false) - expect(listReads().mainAgent).not.toBe('working') - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts deleted file mode 100644 index 64866f8c2936..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { - AgentSessionMutationEnvelope, - AgentSessionStatusEvent -} from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' - -const CALLER = { callerKey: 'client-1' } -const DEFAULT_MODEL = 'gpt-default' - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let router: StructuredAgentSessionAdapter -let acquire: Mock -let closeNativeSession: Mock> -let activeModel: string -let activeEffort: string | null -let optionFailure: Error | null -/** What the adapter's closeSession reports about the child's exit. */ -let closeSessionExit = true -const dispatchedModels: string[] = [] - -function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -function adapter(): StructuredAgentSessionAdapter { - acquire = vi.fn(async ({ fence, spawnToken, options }) => { - activeModel = options?.model ?? DEFAULT_MODEL - activeEffort = options?.effort ?? null - const acquisitionGeneration = `generation-${acquire.mock.calls.length}` - return { - acquisitionGeneration, - process: { - hostId: 'local', - pid: 4200 + acquire.mock.calls.length, - processStartTimeMs: NOW, - spawnToken - }, - link: { - linkId: `native-link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: acquire.mock.calls.length === 1 ? 'created' : 'resumed', - mintedAtFence: fence, - observedAt: NOW - } - } - }) - closeNativeSession = vi.fn(async () => { - activeModel = DEFAULT_MODEL - return closeSessionExit - }) - return { - supportsLocation: () => true, - open: async () => ({ - reportedOptions: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) } - }), - acquire, - dispatch: vi.fn(async ({ executionOptions }) => { - dispatchedModels.push(executionOptions?.model ?? activeModel) - return { - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - } - }), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async ({ key, value }) => { - if (optionFailure) { - const error = optionFailure - optionFailure = null - throw error - } - if (key === 'model') { - activeModel = value - } else if (key === 'effort') { - activeEffort = value - } - return { - model: activeModel, - ...(activeEffort ? { effort: activeEffort } : {}) - } - }), - readOptions: vi.fn(async () => ({ - current: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) }, - models: [] - })), - closeSession: closeNativeSession - } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-option-settlement-')) - resetHostTestOperationIds() - activeModel = DEFAULT_MODEL - activeEffort = null - optionFailure = null - closeSessionExit = true - dispatchedModels.length = 0 - const accountHome = join(root, 'codex-home') - store = await openTestAgentSessionRecordStore(root) - router = adapter() - host = new StructuredAgentSessionHost({ - agents: claudeAndCodexDeclared(), - logger: createStructuredAgentSessionLogger(), - store, - adapter: router, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-native', - now: () => NOW - }) - const attached = await host.attach( - CALLER, - hostTestAttachParams(null, { accountHome: { variable: 'CODEX_HOME', path: accountHome } }) - ) - expect(attached).toMatchObject({ ok: true }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -describe('structured session options and close', () => { - it('keeps provider readback out of the saved next-message choice', async () => { - const body = { - kind: 'message' as const, - role: 'user' as const, - blocks: [{ type: 'text' as const, text: 'first task' }] - } - await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - // Accepted, then handed over by the delivery loop; the status is read once it answered. - await vi.waitFor(() => expect(dispatchedModels).toEqual([DEFAULT_MODEL])) - await vi.waitFor(async () => - expect((await host.journalSnapshot(SESSION)).submissions[0]?.dispatchState).toBe('accepted') - ) - const events: AgentSessionStatusEvent[] = [] - host.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) - expect(events).toEqual([ - { - type: 'snapshot', - sessions: [expect.objectContaining({ status: 'idle', optionSelection: {} })] - } - ]) - expect(events[0]).not.toHaveProperty('sessions.0.model') - expect(store.getRecord(SESSION)?.options).toBeUndefined() - }) - - // Closing a chat used to leave its provider child resident for the whole app session: the host's - // session map had no delete and the only teardown was app quit. - it('stops the provider child and forgets the session when the chat closes', async () => { - expect(host.hasSession(SESSION)).toBe(true) - - await host.close(SESSION, 'evict') - - expect(closeNativeSession).toHaveBeenCalledWith(SESSION) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null, - deathEvidence: { kind: 'exit-observed' } - }) - expect(host.hasSession(SESSION)).toBe(false) - - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(closeNativeSession).toHaveBeenCalledOnce() - }) - - it('is a no-op for a session it does not hold', async () => { - await host.close(SESSION, 'evict') - closeNativeSession.mockClear() - - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(closeNativeSession).not.toHaveBeenCalled() - }) -}) - -it('applies running Codex model and effort picks to the next message', async () => { - await vi.waitFor(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready')) - for (const [key, value] of [ - ['model', 'gpt-picked'], - ['effort', 'high'] - ]) { - expect( - await host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', { key, value }), - key, - value - }) - ).toMatchObject({ ok: true }) - } - const body = { - kind: 'message' as const, - role: 'user' as const, - blocks: [{ type: 'text' as const, text: 'with the new choices' }] - } - expect( - await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(dispatchedModels).toEqual(['gpt-picked'])) - expect(vi.mocked(router.dispatch).mock.calls.at(-1)?.[0].executionOptions?.effort).toBe('high') - expect(router.setOption).not.toHaveBeenCalled() -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts deleted file mode 100644 index be5eeb8049da..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - CLAUDE_PROFILE_MISSING_MESSAGE, - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE -} from '../../../shared/claude-profile-routing' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { mapRuntimeError } from '../../runtime/rpc/errors' -import { - AgentSessionPreSpawnError, - type AgentSessionPreSpawnReason, - type StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { - attachFingerprintFields, - type AgentSessionAttachParams -} from './structured-agent-session-attach' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import { performAttach } from './structured-agent-session-attach-flow' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { StructuredAgentArgumentsError } from '../structured-agent-arguments-error' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-alpha' -const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` -let root: string | null = null - -afterEach(async () => { - vi.restoreAllMocks() - if (root) { - await rm(root, { recursive: true, force: true }) - } - root = null -}) - -function createParams(): AgentSessionAttachParams { - const params: AgentSessionAttachParams = { - envelope: { - sessionId: SESSION, - clientOperationId: OPERATION, - expectedRuntimeFence: null, - payloadFingerprint: '' - }, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - runtimeKind: 'native' - } - return { - ...params, - envelope: { - ...params.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields(params) - }) - } - } -} - -/** The thrown first answer as the wire sends it, and the ledger's replay of the same operation. */ -async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { - root = await mkdtemp(join(tmpdir(), 'orca-pre-spawn-first-answer-')) - const store = await openTestAgentSessionRecordStore(root) - const unused = async (): Promise => { - throw new Error('not reached before a spawn') - } - const adapter: StructuredAgentSessionAdapter = { - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire: async () => { - throw thrown - }, - dispatch: unused, - cancelTurn: unused, - answerPrompt: unused, - setOption: unused - } - const input = { - agents: NO_STRUCTURED_AGENTS, - store, - adapter, - logger: createStructuredAgentSessionLogger(), - journalDatabase: openTestJournalHostDatabase(root), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: OPERATION, - probe: { outcome: 'reservation-unused' as const } - }, - callerKey: 'client-1', - params: createParams(), - now: () => NOW, - onAttached: () => {} - } - const first = await performAttach(input).then( - (result) => result, - (error: unknown) => error - ) - if (first instanceof Error) { - expect(first).toBeInstanceOf(AgentSessionPreSpawnError) - } - return { - first: - first instanceof Error ? mapRuntimeError('req-1', { runtimeId: 'runtime-1' }, first) : first, - replay: await performAttach(input) - } -} - -describe('a create that fails before any process spawns', () => { - it('answers a safe saved Arguments problem on the first call and replay', async () => { - const { first, replay } = await firstAnswerAndReplay( - new AgentSessionPreSpawnError( - new StructuredAgentArgumentsError('Claude', '--model=private', 'multipleValues') - ) - ) - const sentence = - "Claude couldn't start. Saved Arguments give --model more than one value. Edit them in Settings > Agents > Arguments. Send your message to try again." - for (const result of [first, replay]) { - expect(result).toMatchObject({ - ok: false, - refusal: { - message: sentence, - details: { - reason: 'attachFailed', - argumentProblem: { agent: 'Claude', option: '--model', problem: 'multipleValues' } - } - } - }) - } - expect(JSON.stringify([first, replay])).not.toContain('private') - }) - - it.each<[string, string, AgentSessionPreSpawnReason | undefined, string]>([ - [ - 'the managed account env override', - 'This Claude launch defines explicit Anthropic auth environment variables.', - 'managedAccountEnvOverride', - 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' - ], - [ - 'an account switch in progress', - 'A Claude account switch is in progress. Try again after it finishes.', - 'accountSwitchInProgress', - 'A Claude account switch is in progress. Try again after it finishes.' - ], - [ - 'a Claude account added in WSL', - 'structured Claude is not offered under the active managed Claude account', - 'managedAccountUnsupported', - 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings.' - ], - [ - 'a floating chat whose launch folder is gone', - 'the folder this chat ran in no longer exists: /gone/floating', - 'launchFolderMissing', - 'The folder this chat ran in no longer exists. Restore it to continue this chat.' - ], - [ - 'a resume whose transcript is in another Claude account', - 'claude transcript is not in the selected account', - 'historyInOtherAccount', - "This chat's history is in another Claude account. Switch back to that account to continue it." - ], - [ - 'a selected Claude account whose folder is gone', - CLAUDE_PROFILE_MISSING_MESSAGE, - 'claudeAccountFolderMissing', - CLAUDE_PROFILE_MISSING_MESSAGE - ], - [ - 'a selected Claude account that could not be set up', - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE, - 'claudeAccountSetupFailed', - CLAUDE_PROFILE_SETUP_FAILED_MESSAGE - ], - [ - 'a Command setting that names no runnable program', - 'the claude Command setting is not a runnable program', - 'agentCommandNotRunnable', - "Claude's Command in Settings → Agents must be a program path or name Orca can find, with no arguments or variables. Change it or reset it." - ], - [ - "Orca's own reason", - 'claude sessions pin CLAUDE_CONFIG_DIR, not CODEX_HOME', - undefined, - "Claude couldn't start. Send your message to try again." - ] - ])('answers %s first in the words its replay reads', async (_, raw, reason, sentence) => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - - const { first, replay } = await firstAnswerAndReplay( - new AgentSessionPreSpawnError(new Error(raw), reason ? { reason } : {}) - ) - - // Thrown as before: the wire code is unchanged, only the words are the replay's. - expect(first).toMatchObject({ ok: false, error: { code: 'runtime_error', message: sentence } }) - expect(replay).toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: sentence, - ...(reason ? { details: { reason } } : {}) - } - }) - if (!reason) { - expect(replay).not.toHaveProperty('refusal.details') - } - if (raw !== sentence) { - expect(JSON.stringify([first, replay])).not.toContain(raw) - } - // What failed is kept for the log. - expect(warn).toHaveBeenCalledWith( - '[agent-session] provider-start: starting the provider failed', - expect.objectContaining({ - scope: 'provider-start', - error: expect.objectContaining({ message: raw }) - }) - ) - }) - - it('keeps a message that is itself a code, and the wire code it maps to', async () => { - const { first } = await firstAnswerAndReplay( - new AgentSessionPreSpawnError(new Error('runtime_unavailable')) - ) - - expect(first).toMatchObject({ - ok: false, - error: { code: 'runtime_unavailable', message: 'runtime_unavailable' } - }) - }) - - it('keeps the situation of a pre-spawn error it wraps', () => { - const typed = new AgentSessionPreSpawnError(new Error('switching'), { - reason: 'accountSwitchInProgress' - }) - - expect(new AgentSessionPreSpawnError(typed).reason).toBe('accountSwitchInProgress') - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts deleted file mode 100644 index 5a5ae37a4920..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ /dev/null @@ -1,908 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// The provider child is its own record on the conversation: stopping it, losing it or failing to -// start it ends the child, never the conversation. Against the real host, store and journal, with a -// live subscriber opened before each action. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionStatusSummary, - AgentSessionSubscribeEvent -} from '../../../shared/agent-session-wire' -import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' -import { - agentSessionFailureFact, - type AgentSessionFailureFact, - type SubmissionRejectionFact -} from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { ensureStructuredAgentSessionAgent } from './structured-agent-session-agent-start' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' -import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { structuredQueuePauses } from './structured-agent-session-queued-pause' -import { - HOST_TEST_LOCATION, - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { structuredAgentSessionHeldStarts } from './structured-agent-session-held-start.test-support' - -const CALLER = { callerKey: 'client-1' } -const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { - surface: 'rejection' -}) - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let dispatch: Mock -let adapterExtras: Partial - -function eventually(assertion: () => void | Promise): Promise { - return vi.waitFor(assertion, { timeout: 10_000 }) -} - -function generation(): string { - return `generation-${acquire.mock.calls.length}` -} - -const spawnChild: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - acquisitionGeneration: generation(), - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length - ? ('resumed' as const) - : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } -}) - -const starts = structuredAgentSessionHeldStarts(() => host) - -/** A child whose handshake is still running: `starting` until the test's `prove()`. */ -const spawnStartingChild = starts.held(spawnChild) - -function startHost(): void { - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: starts.wrap({ - dispatch, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn: vi.fn(async () => ({ cancelled: false })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined), - ...adapterExtras, - acquire, - open: - adapterExtras.open ?? - (async () => ({ - reportedOptions: { model: '' } - })) - }), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - now: () => NOW - }) -} - -async function restartHost(): Promise { - await host.flushAllStreamedEvents() - startHost() -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-child-record-')) - resetHostTestOperationIds() - adapterExtras = {} - starts.clear() - acquire = vi.fn(spawnChild) - dispatch = vi.fn(async (input) => ({ - state: 'accepted' as const, - providerIdentity: { - provider: 'codex' as const, - threadId: THREAD, - turnId: `turn-${input.clientMessageId}`, - ordinal: dispatch.mock.calls.length - } - })) - store = await openTestAgentSessionRecordStore(root) - startHost() - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - await host.close(SESSION, 'evict') -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function sendParams(text: string) { - const body = hostTestMessage(text) - return { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body - } -} - -async function accept(text: string, options: { person?: true } = {}): Promise { - const params = sendParams(text) - const sent = await host.send(CALLER, { ...params, ...(options.person ? { userSend: true } : {}) }) - expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - return params.envelope.clientOperationId -} - -function stop() { - const turnId = 'turn-none' - return host.cancel(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.cancel', - sessionId: SESSION, - fields: { turnId } - }) - }, - turnId - }) -} - -async function submission(id: string): Promise { - return (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.clientMessageId === id - ) -} - -async function statusRows(): Promise< - { - itemId: string - text: string - tone?: string - failure?: AgentSessionFailureFact - }[] -> { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => - item.body.kind === 'status' - ? [ - { - itemId: item.itemId, - text: item.body.text, - ...(item.body.tone ? { tone: item.body.tone } : {}), - ...(item.body.failure ? { failure: item.body.failure } : {}) - } - ] - : [] - ) -} - -/** The child the conversation has now, as its lifecycle events name it. */ -function currentChild() { - const child = conversation()?.child - if (!child?.generation) { - throw new Error('no child indexed') - } - return { sessionId: SESSION, fence: child.fence, acquisitionGeneration: child.generation } -} - -/** Release this child's held protocol open. */ -function prove(): Promise { - return starts.prove(currentChild()) -} - -function exit(child: ReturnType, reason: string) { - return host.handleAdapterEvent({ - type: 'ended', - ...child, - reason, - // As an adapter reports it: the exit's stderr as a log detail. - failure: { kind: 'providerExited', detail: { text: reason, audience: 'log' } }, - cause: 'unexpected-exit' - }) -} - -function rejectedIn(events: AgentSessionSubscribeEvent[], id: string): boolean { - return events.some( - (event) => - event.type === 'batch' && - event.batch.submissions.some( - (entry) => entry.clientMessageId === id && entry.dispatchState === 'rejected' - ) - ) -} - -function conversation() { - return host['sessions'].get(SESSION) -} - -async function subscribe(): Promise { - const events: AgentSessionSubscribeEvent[] = [] - await host.subscribe({ - id: 'sub-1', - sessionId: SESSION, - emit: (event) => events.push(structuredClone(event)) - }) - return events -} - -/** The chat's status row as a session list sees it, frame by frame. */ -function watchStatus(): AgentSessionStatusSummary[] { - const frames: AgentSessionStatusSummary[] = [] - host.subscribeStatus({ - id: 'list-1', - emit: (event) => { - if (event.type === 'status' && event.session.sessionId === SESSION) { - frames.push(event.session) - } - } - }) - return frames -} - -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((next) => { - resolve = next - }) - return { promise, resolve } -} - -describe('Stop on a child still proving its start', () => { - it('ends the child and keeps the conversation and its readers (R1)', async () => { - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) - // Held for the starting child, which never proves its start. - const first = await accept('hello') - const journal = conversation()?.journal - const events = await subscribe() - const frames = watchStatus() - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - expect((await submission(first))?.handedOverAt).toBeUndefined() - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - - // The same conversation: no reopen, and the chat told it is idle again. - expect(conversation()?.journal).toBe(journal) - expect(conversation()?.child).toBeNull() - expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-stop', rootGone: true }) - expect(frames.at(-1)).not.toHaveProperty('hostExecutionPhase') - expect(frames.at(-1)).not.toHaveProperty('hostExecutionOwned') - expect(await submission(first)).toMatchObject({ - dispatchState: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED - }) - expect(await statusRows()).toEqual([ - { itemId: 'orca:stop%3Aturn-none', text: 'Cancellation requested.' } - ]) - await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) - - const next = await accept('after stop') - await eventually(async () => expect((await submission(next))?.dispatchState).toBe('accepted')) - expect(conversation()?.journal).toBe(journal) - expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([next]) - // The reader opened before the Stop saw the next message delivered on the same stream. - expect( - events.some( - (event) => - event.type === 'batch' && - event.batch.submissions.some( - (entry) => entry.clientMessageId === next && entry.dispatchState === 'accepted' - ) - ) - ).toBe(true) - }) -}) - -describe('settling an earlier child before the next one takes its message', () => { - it("settles the earlier child's turn from its death evidence and leaves the queued message to the new child (R1)", async () => { - // The earlier child exited mid-turn; the released lease keeps only its death evidence. - await store.transitionHandoff(SESSION, (record) => ({ - ...record, - lease: { - ...record.lease, - deathEvidence: { kind: 'exit-observed', detail: 'provider exited', observedAt: NOW - 1_000 } - } - })) - const releasedFence = store.getRecord(SESSION)!.lease.runtimeFence - const journal = await openAgentSessionJournal({ - identity: { - sessionId: SESSION, - workspaceId: HOST_TEST_LOCATION.workspaceId, - hostId: HOST_TEST_LOCATION.executionHostId, - agent: 'codex', - providerHandle: codexProviderHandle(THREAD) - }, - database: openTestJournalHostDatabase(root) - }) - await journal.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'earlier-turn', ordinal: 0 }, - { kind: 'turn', turnId: 'earlier-turn', state: 'running', startedAt: NOW - 5_000 }, - { fence: releasedFence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.close() - const id = await accept('for the next child') - - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - // The exit was observed, so its receipt ends the turn, and the chat says why it stopped. - const items = conversation()!.journal.snapshot().items - expect(items.map((item) => readAgentJournalTurn(item.body)).filter(Boolean)).toContainEqual( - expect.objectContaining({ - turnId: 'earlier-turn', - state: 'interrupted', - completedAt: NOW - 1_000 - }) - ) - expect( - items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) - ).toContain( - 'Codex stopped while this response was in progress. You can continue in this conversation.' - ) - // Handed over at the new child's fence, which the attach reserved after settling. - const newFence = store.getRecord(SESSION)!.lease.runtimeFence - expect(newFence).toBeGreaterThan(releasedFence) - expect((await submission(id))?.fence).toBe(newFence) - expect(conversation()?.child).toMatchObject({ generation: generation(), fence: newFence }) - }) -}) - -const START_EXIT = 'claude stream-json exited (code 1)' -const START_TEXT = 'Codex stopped before it finished starting. Send your message to try again.' -const START_FAILURE: SubmissionRejectionFact = { - kind: 'providerStartFailed', - detail: { text: START_EXIT, audience: 'log' } -} - -describe('a published child that dies while it proves its start', () => { - const EXIT = START_EXIT - const TEXT = START_TEXT - - it('leaves one error row keyed by the start, and every message held for it rejected with it (R2)', async () => { - await restartHost() - acquire.mockImplementation(spawnStartingChild) - // Both are held for the starting child, which never proves its start. - const first = await accept('first') - const events = await subscribe() - const second = await accept('second') - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - const child = currentChild() - - await exit(child, EXIT) - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('rejected')) - expect(await statusRows()).toEqual([ - { - itemId: `orca:${encodeURIComponent(`start-failure:${child.acquisitionGeneration}`)}`, - text: TEXT, - tone: 'error', - failure: START_FAILURE - } - ]) - for (const id of [first, second]) { - expect(await submission(id)).toMatchObject({ - dispatchState: 'rejected', - reason: TEXT, - rejection: START_FAILURE - }) - } - expect(rejectedIn(events, second)).toBe(true) - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).not.toHaveBeenCalled() - }) -}) - -/** A start by an operation that needs the agent, such as a goal change, outside the loop. */ -function startForOperation() { - return host['serialize'](SESSION, () => - ensureStructuredAgentSessionAgent(host['attachContext'](), SESSION) - ) -} - -describe('a start another operation made that dies while a sent message waits on it', () => { - const EXIT = START_EXIT - const TEXT = START_TEXT - - it("is the message's own failed start: one error row, the message rejected, no second start (R2)", async () => { - await restartHost() - acquire.mockImplementation(spawnStartingChild) - // An operation that needs the agent starts a child that has not proven its start. - await startForOperation() - const operationChild = currentChild() - const events = await subscribe() - const params = sendParams('hello') - - // Accepted first; that child's exit is settled before the loop's first step. - const sent = host.send(CALLER, params) - const exited = exit(operationChild, EXIT) - expect(await sent).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await exited - const id = params.envelope.clientOperationId - - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) - await settleLoop() - expect((await submission(id))?.reason).toBe(TEXT) - expect(await statusRows()).toEqual([ - { - itemId: `orca:${encodeURIComponent(`start-failure:${operationChild.acquisitionGeneration}`)}`, - text: TEXT, - tone: 'error', - failure: START_FAILURE - } - ]) - expect(rejectedIn(events, id)).toBe(true) - // The setup's child and the operation's: nothing started again into the same failure. - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).not.toHaveBeenCalled() - }) - - it('leaves a message sent after that start failed to a fresh start (R2)', async () => { - await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) - await startForOperation() - await exit(currentChild(), EXIT) - expect(await statusRows()).toHaveLength(1) - - const id = await accept('after the failure') - - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - expect(acquire).toHaveBeenCalledTimes(3) - }) - - it('starts again for a message whose proven child crashed: only a failed start settles it (R2)', async () => { - await restartHost() - await startForOperation() - const params = sendParams('hello') - - const sent = host.send(CALLER, params) - const exited = exit(currentChild(), 'codex app-server crashed') - await sent - await exited - const id = params.envelope.clientOperationId - - await eventually(async () => expect((await submission(id))?.dispatchState).not.toBe('pending')) - expect((await submission(id))?.dispatchState).toBe('accepted') - expect(acquire).toHaveBeenCalledTimes(3) - }) - - it('hands over to a child started since the failed one, not to the failure (R2)', async () => { - await restartHost() - acquire.mockImplementation(spawnStartingChild) - await startForOperation() - const params = sendParams('hello') - - // A second operation's start lands after the first child's exit, before the loop's first step. - const sent = host.send(CALLER, params) - const exited = exit(currentChild(), EXIT) - const held = startForOperation() - await sent - await exited - await held - const id = params.envelope.clientOperationId - await prove() - - await eventually(async () => expect((await submission(id))?.dispatchState).not.toBe('pending')) - expect((await submission(id))?.dispatchState).toBe('accepted') - expect(acquire).toHaveBeenCalledTimes(3) - }) -}) - -describe('a child that ends before open completes', () => { - it('starts one child for the message, then rejects its start and stops (R2)', async () => { - await restartHost() - // The exit lands before the host's awaited open can establish readiness. - acquire.mockImplementation(async (input) => { - const child = await spawnStartingChild(input) - const { acquisitionGeneration } = child - if (acquisitionGeneration === undefined) { - throw new Error('spawnChild always names a generation') - } - void starts.prove({ sessionId: SESSION, fence: input.fence, acquisitionGeneration }) - void host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - fence: input.fence, - acquisitionGeneration, - reason: 'codex app-server crashed', - failure: { - kind: 'providerExited', - detail: { text: 'codex app-server crashed', audience: 'log' } - }, - cause: 'unexpected-exit' - }) - return child - }) - const id = await accept('hello') - const events = await subscribe() - - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) - // The exit's stderr rides beside the sentence, never in it. - const failure = { - kind: 'providerStartFailed', - detail: { text: 'codex app-server crashed', audience: 'log' } - } - expect(await submission(id)).toMatchObject({ - reason: 'Codex stopped before it finished starting. Send your message to try again.', - rejection: { ...failure, kind: 'providerStartFailed' } - }) - expect(await statusRows()).toEqual([ - { itemId: expect.any(String), text: (await submission(id))?.reason, tone: 'error', failure } - ]) - expect(rejectedIn(events, id)).toBe(true) - await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).not.toHaveBeenCalled() - }) -}) - -describe('another child indexed after a Stop ended the starting one', () => { - it('hands the next message to the child now there once it proves its start (R2)', async () => { - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementation(spawnStartingChild) - // Held for the starting child, which never proves its start. - const first = await accept('first') - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - const stopped = currentChild() - expect(await stop()).toMatchObject({ ok: true }) - // An operation that needs the agent starts its own child before the second message is sent. - await host['serialize'](SESSION, () => - ensureStructuredAgentSessionAgent(host['attachContext'](), SESSION) - ) - const replacement = currentChild() - expect(replacement.acquisitionGeneration).not.toBe(stopped.acquisitionGeneration) - const secondId = await accept('second') - expect((await submission(secondId))?.handedOverAt).toBeUndefined() - expect(dispatch).not.toHaveBeenCalled() - await host.flushStreamedEvents(SESSION) - expect((await submission(secondId))?.handedOverAt).toBeUndefined() - expect(dispatch).not.toHaveBeenCalled() - - await prove() - - await eventually(async () => - expect((await submission(secondId))?.dispatchState).toBe('accepted') - ) - expect(conversation()?.child).toMatchObject({ - generation: replacement.acquisitionGeneration, - phase: 'ready' - }) - expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([secondId]) - expect(acquire).toHaveBeenCalledTimes(3) - expect(await submission(first)).toMatchObject({ reason: DISPATCH_REJECTED_CANCELLED }) - }) -}) - -// A quit settles nothing still queued: the next launch's open keeps a person's message as a held -// card and rejects the rest, as a crash's would. -describe('a quit with a message still queued', () => { - const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { - surface: 'rejection' - }) - - /** Read by the next launch, through the same open any reader takes. */ - async function afterRelaunch(id: string): Promise { - startHost() - await host.revealSession(SESSION) - return await submission(id) - } - - async function keptCards(): Promise { - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - return page.ok ? (page.page.queuedMessages ?? []).map((card) => card.messageId) : [] - } - - it('keeps a person’s message no child ever had as a held card for the next launch (R2)', async () => { - // Quit has begun — its first step stops the delivery loops — when this message is accepted. - host['conversationDelivery'].loop.dispose() - const id = await accept('hello', { person: true }) - expect(conversation()?.child).toBeNull() - await host.flushAllStreamedEvents() - - expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) - expect(await keptCards()).toEqual([id]) - expect(dispatch).not.toHaveBeenCalled() - }) - - it("rejects a message from Orca itself at the next launch, as a crash's would", async () => { - host['conversationDelivery'].loop.dispose() - const id = await accept('from orca') - await host.flushAllStreamedEvents() - - expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) - expect(await keptCards()).toEqual([]) - }) - - it('stops a start already in flight before it launches a child (R2)', async () => { - const starting = deferred() - const closeSession = vi.fn(async () => true) - adapterExtras = { closeSession } - await restartHost() - // The loop's start step is under way, but has not reached its attach yet. - const resolveRecovery = host['runtimeState'].resolveRecovery.bind(host['runtimeState']) - const recovering = vi.spyOn(host['runtimeState'], 'resolveRecovery') - recovering.mockImplementationOnce(async (sessionId) => { - await starting.promise - return resolveRecovery(sessionId) - }) - const id = await accept('hello', { person: true }) - await eventually(() => expect(recovering).toHaveBeenCalled()) - const acquiresBefore = acquire.mock.calls.length - - const quit = host.flushAllStreamedEvents() - starting.resolve() - await quit - - // Quit aborts the start, so nothing is launched behind it and nothing is left to stop. - expect(acquire).toHaveBeenCalledTimes(acquiresBefore) - expect(closeSession).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null, - deathEvidence: { detail: 'reservation failed before spawn' } - }) - expect(dispatch).not.toHaveBeenCalled() - expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) - expect(await keptCards()).toEqual([id]) - }) -}) - -describe('a send whose start failed, sent again with the same operation id', () => { - it('replays the recorded rejection and starts no second agent (R2)', async () => { - acquire.mockRejectedValueOnce(new Error('spawn claude ENOENT')) - const fenceBefore = store.getRecord(SESSION)!.lease.runtimeFence - const params = sendParams('hello') - // A failed start is a rejected message, never a refused send. - expect(await host.send(CALLER, params)).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - const id = params.envelope.clientOperationId - await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) - // The start moved the fence while the message was out. - expect(store.getRecord(SESSION)!.lease.runtimeFence).toBeGreaterThan(fenceBefore) - const starts = acquire.mock.calls.length - - const replay = await host.send(CALLER, params) - - expect(replay).toMatchObject({ - ok: true, - replayed: true, - value: { submission: { clientMessageId: id, dispatchState: 'rejected' } } - }) - await settleLoop() - expect(acquire).toHaveBeenCalledTimes(starts) - expect(dispatch).not.toHaveBeenCalled() - }) -}) - -async function settleLoop(): Promise { - await eventually(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) -} - -describe('how a stopped child ends the start its loop was waiting on', () => { - /** A starting child the first message is held for, then the stop below; the message sent after - * the stop is the next delivery's. */ - async function stoppedWhileStarting(stop: () => Promise) { - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) - await accept('first') - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - await stop() - return accept('second') - } - - it("goes on after a user's Stop and delivers what was sent since (R2)", async () => { - const second = await stoppedWhileStarting(async () => { - expect(await stop()).toMatchObject({ ok: true }) - }) - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-stop', reason: null }) - expect(await statusRows()).toEqual([ - { itemId: 'orca:stop%3Aturn-none', text: 'Cancellation requested.' } - ]) - }) - - /** The close's stop alone: a close that aborts after it leaves the conversation indexed. */ - function closeStopOnly() { - return host['serialize'](SESSION, () => - stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { - cause: 'user-close' - }) - ) - } - - /** A message queued for a starting child when the close's stop cut it: asked for during the - * start step, the stop runs before the handover. `beforeStart` runs after the stop and before - * the loop looks again. */ - async function closedWhileStarting(beforeStart: () => void = () => undefined) { - const start = deferred() - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementationOnce(async (input) => { - await start.promise - return spawnStartingChild(input) - }) - const first = await accept('first') - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) - let starts = 0 - const closed = host['serialize'](SESSION, async () => { - await stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { - cause: 'user-close' - }) - starts = acquire.mock.calls.length - beforeStart() - }) - start.resolve() - await closed - await settleLoop() - return { first, starts } - } - - it('closes what was queued when the user closed the chat, and starts no child for it', async () => { - const { first, starts } = await closedWhileStarting() - - expect(await submission(first)).toMatchObject({ - dispatchState: 'rejected', - ...CHAT_CLOSED - }) - expect(acquire).toHaveBeenCalledTimes(starts) - expect(dispatch).not.toHaveBeenCalled() - expect(await statusRows()).toEqual([]) - }) - - it('keeps a person’s message the user closed as a waiting card, and starts no child for it', async () => { - const start = deferred() - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - // The start step holds the queue: the message is still queued when the close's stop runs. - acquire.mockImplementationOnce(async (input) => { - await start.promise - return spawnStartingChild(input) - }) - const first = await accept('first', { person: true }) - await eventually(() => expect(acquire).toHaveBeenCalledTimes(2)) - let starts = 0 - const closed = closeStopOnly().then(() => { - starts = acquire.mock.calls.length - }) - start.resolve() - await closed - await settleLoop() - - expect(await submission(first)).toMatchObject({ dispatchState: 'rejected', ...CHAT_CLOSED }) - const page = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(page.ok && page.page.queuedMessages?.map((card) => card.messageId)).toEqual([first]) - expect(acquire).toHaveBeenCalledTimes(starts) - expect(dispatch).not.toHaveBeenCalled() - // The close stopped the chat running: the card waits for its next turn, by a mark the - // re-check wrote once, when it kept the card. - const journal = conversation()!.journal - expect(structuredQueuePauses(journal).map((pause) => pause.reason)).toEqual(['restarted']) - const marks = vi.spyOn(journal, 'appendQueueReopen') - await accept('second', { person: true }) - await settleLoop() - // A later send's re-check settles nothing, so it marks nothing past that send. - expect(marks).not.toHaveBeenCalled() - }) - - it('starts no child when closing what was queued fails, and closes it on the next wake', async () => { - const { first, starts } = await closedWhileStarting(() => { - const journal = conversation()!.journal - const resolve = journal.resolveDispatch.bind(journal) - vi.spyOn(journal, 'resolveDispatch').mockImplementation(async (...args) => { - if (args[0].state === 'rejected' && args[0].rejection?.kind === 'chatClosed') { - vi.mocked(journal.resolveDispatch).mockImplementation(resolve) - throw new Error('disk full') - } - return resolve(...args) - }) - }) - - expect((await submission(first))?.dispatchState).toBe('pending') - expect(acquire).toHaveBeenCalledTimes(starts) - expect(dispatch).not.toHaveBeenCalled() - - const second = await accept('second') - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - expect(await submission(first)).toMatchObject({ - dispatchState: 'rejected', - ...CHAT_CLOSED - }) - expect(dispatch).toHaveBeenCalledTimes(1) - }) - - it('goes on with a message sent in a later epoch, whose sequence restarts at or below the close', async () => { - await closedWhileStarting() - const session = conversation()! - await session.journal.rollEpoch( - 'corruption', - structuredAgentSessionConversationFence(store, SESSION) - ) - - const second = await accept('second') - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - expect(session.lastEndedChild).toMatchObject({ cause: 'user-close' }) - expect((await submission(second))!.acceptedSequence).toBeLessThanOrEqual( - session.lastEndedChild!.endedAt.sequence - ) - }) - - it('goes on with a message sent after the close, never failing it', async () => { - const second = await stoppedWhileStarting(closeStopOnly) - - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-close' }) - expect(await statusRows()).toEqual([]) - }) - - it("fails the start after a host stop, as a start Orca stopped rather than the provider's (R2)", async () => { - const reason = 'the start watchdog fired' - adapterExtras = { closeSession: vi.fn(async () => true) } - await restartHost() - acquire.mockImplementationOnce(spawnStartingChild) - const first = await accept('first') - await eventually(() => expect(conversation()?.child?.phase).toBe('starting')) - await host['serialize'](SESSION, () => - stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { - cause: 'host-stop', - reason - }) - ) - - // What was held for the stopped start fails with it, said once; a later send starts afresh. - await eventually(async () => expect((await submission(first))?.dispatchState).toBe('rejected')) - // The sentence is the constructor's, not the reason the stop was given. - const text = 'Codex never finished starting, so Orca stopped it.' - expect(await submission(first)).toMatchObject({ - reason: text, - rejection: { kind: 'hostStopped' } - }) - expect(await statusRows()).toEqual([ - { itemId: expect.any(String), text, tone: 'error', failure: { kind: 'hostStopped' } } - ]) - const second = await accept('second') - await eventually(async () => expect((await submission(second))?.dispatchState).toBe('accepted')) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts deleted file mode 100644 index 11aea4000d02..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' -import { - HOST_TEST_NOW, - HOST_TEST_SESSION, - hostTestAttachParams, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const CLAUDE_SESSION = 'claude-session' -const hosts: StructuredAgentSessionHost[] = [] -let root = '' - -function claudeAdapter(): StructuredAgentSessionAdapter { - return { - supportsCreate: (_location, agent) => agent === 'claude', - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire: async ({ fence, spawnToken }) => ({ - acquisitionGeneration: globalThis.crypto.randomUUID(), - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - link: { - linkId: `link-${fence}`, - handle: claudeProviderHandle(CLAUDE_SESSION, null), - origin: 'created', - mintedAtFence: fence, - observedAt: HOST_TEST_NOW - } - }), - dispatch: async () => ({ - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { - surface: 'rejection' - }) - }), - cancelTurn: async () => ({ cancelled: false }), - answerPrompt: async () => undefined, - setOption: async () => undefined - } -} - -function createHost( - store: AgentSessionRecordStore, - probeOwner?: StructuredAgentSessionHost['deps']['probeOwner'] -): StructuredAgentSessionHost { - const host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: claudeAdapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - probeOwner, - now: () => HOST_TEST_NOW - }) - hosts.push(host) - return host -} - -afterEach(async () => { - await Promise.all(hosts.splice(0).map(abandonStructuredAgentSessionHost)) - await rm(root, { recursive: true, force: true }) - root = '' -}) - -describe('structured session provider restore', () => { - it('restores a durable Claude session tab with its recorded provider', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-provider-restore-')) - resetHostTestOperationIds() - const store = await openTestAgentSessionRecordStore(root) - const host = createHost(store) - const attached = await host.attach( - { callerKey: 'client-1' }, - hostTestAttachParams(null, { - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - providerHandle: { kind: 'claude', sessionId: CLAUDE_SESSION, leafUuid: null } - }) - ) - expect(attached).toMatchObject({ ok: true }) - - const reopenedStore = await openTestAgentSessionRecordStore(root) - const restarted = createHost(reopenedStore, async () => ({ - outcome: 'indeterminate', - reason: 'read does not need ownership' - })) - - await restarted.restoreReadableSessions() - - expect(restarted.listSessionTabs()).toEqual([ - { sessionId: HOST_TEST_SESSION, workspaceId: 'workspace-1', agent: 'claude' } - ]) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts deleted file mode 100644 index fead750e127d..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -// A chat that proves its start shows its account can start one, so the model catalog's held -// "can't start" reason is made due for the probe instead of trusted until its TTL runs out. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { structuredAgentSessionHeldStarts } from './structured-agent-session-held-start.test-support' - -const CALLER = { callerKey: 'client-1' } - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -const providerStarted = vi.fn() -const starts = structuredAgentSessionHeldStarts(() => host) - -function startHost(): void { - const acquire: StructuredAgentSessionAdapter['acquire'] = async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created' as const, - mintedAtFence: fence, - observedAt: NOW - }, - acquisitionGeneration: 'generation-1' - }) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: starts.wrap({ - acquire: starts.held(acquire), - releaseAcquisition: vi.fn(async () => true), - closeSession: vi.fn(async () => true), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined), - open: async () => ({ - reportedOptions: { model: '' } - }) - }), - modelCatalog: { - read: vi.fn(), - recordLiveListing: vi.fn(), - prewarm: vi.fn(async () => {}), - stop: vi.fn(), - providerStarted - }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-1', - now: () => NOW - }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-started-catalog-')) - resetHostTestOperationIds() - providerStarted.mockReset() - store = await openTestAgentSessionRecordStore(root) -}) - -afterEach(async () => { - starts.clear() - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -/** The adapter's report that the child's protocol session exists. */ -async function proveStart(): Promise { - await starts.prove({ - sessionId: SESSION, - fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, - acquisitionGeneration: 'generation-1' - }) - await vi.waitFor(() => expect(providerStarted).toHaveBeenCalledOnce()) -} - -it('a published child tells the catalog only once its start is proven, with its record', async () => { - startHost() - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true }) - expect(providerStarted).not.toHaveBeenCalled() - await proveStart() - expect(providerStarted).toHaveBeenCalledTimes(1) - expect(providerStarted.mock.calls[0]?.[0]).toMatchObject({ sessionId: SESSION }) -}) - -it('a re-attach to the live child proves nothing new and tells the catalog nothing', async () => { - startHost() - const params = hostTestAttachParams(null) - await expect(host.attach(CALLER, params)).resolves.toMatchObject({ ok: true }) - await proveStart() - const fence = store.getRecord(SESSION)?.lease.runtimeFence - // A reconnecting client replays its attach; the same operation re-attaches the live child. - await expect(host.attach(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(fence) - expect(providerStarted).toHaveBeenCalledTimes(1) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts deleted file mode 100644 index cedeb319cb7d..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -// A publish-first create proves nothing about the model until Claude answers startup. The record -// must never hold the catalog's default in the meantime: an owner handoff or a reopen would -// launch it as `--model` and silently move a user whose CLI default is not Sonnet. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-store' -import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' -import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' -import { - fakeClaude, - PROVIDER_SESSION_ID -} from '../../claude/claude-structured-session-test-support' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - hostTestAttachParams, - hostTestMessage, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { createTestParams } from './structured-agent-session-create-test-fixture' - -const CALLER = { callerKey: 'client-1' } -const CLAUDE_MODELS = [ - { value: 'claude-sonnet', displayName: 'Sonnet' }, - { value: 'claude-opus-9', displayName: 'Opus 9' } -] -const LISTED = [ - expect.objectContaining({ id: 'claude-sonnet' }), - expect.objectContaining({ id: 'claude-opus-9' }) -] -const INIT_DELAY_MS = 40 - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let adapter: ClaudeStructuredSessionAdapter -let lifecycle: Promise[] -let statuses: AgentSessionStatusEvent[] -let savedListings: { sessionId: string; listing: AgentModelCatalogLiveListing }[] - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-provider-started-')) - resetHostTestOperationIds() - lifecycle = [] - statuses = [] - savedListings = [] - // A CLI whose own default is not the catalog's: startup reports it through get_settings, - // since system/init arrives only with the first command. - const claude = fakeClaude({ - initDelayMs: INIT_DELAY_MS, - initModel: 'claude-opus-9', - settings: { - applied: { model: 'claude-opus-9', effort: 'high', advisor: null, ultracode: false }, - effective: { model: 'claude-opus-9', effortLevel: 'high', env: {} }, - sources: {} - }, - // Startup lists from initialize, and a chat's options read from list_models: the same rows. - initModels: CLAUDE_MODELS, - routes: { list_models: () => CLAUDE_MODELS } - }) - adapter = new ClaudeStructuredSessionAdapter({ - resolveLaunch: async () => ({ - pathToClaudeCodeExecutable: 'claude', - options: {}, - cwd: root, - claudeConfigDir: join(root, 'claude-home'), - providerSessionId: PROVIDER_SESSION_ID, - resumeLeafUuid: null, - // A session that already minted its provider handle resumes it, as the real launch does. - resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, - continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 - }), - // The runtime's own mapping, so this test drives the same lifecycle path production does. - onEvent: (event) => { - const mapped = structuredClaudeLifecycleEvent(event) - if (mapped) { - lifecycle.push(host.handleAdapterEvent(mapped)) - } - }, - openConnection: claude.openConnection, - readProcessStartTime: async () => 1_700_000_000_000, - now: () => NOW - }) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - // The production router is what declares create support; the bare adapter only knows locations. - adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - modelCatalog: { - read: async () => ({ origin: 'unknown' }), - recordLiveListing: (sessionId, listing) => savedListings.push({ sessionId, listing }), - prewarm: async () => {}, - stop: () => {}, - providerStarted: () => {} - }, - now: () => NOW - }) - host.subscribeStatus({ id: 'status-1', emit: (event) => statuses.push(event) }) -}) - -afterEach(async () => { - await adapter.closeAll() - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function claudeParams(expectedRuntimeFence: number | null = null) { - return hostTestAttachParams(expectedRuntimeFence, { - provider: 'claude', - agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, - providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } - }) -} - -function lastPhase(): string | undefined { - const last = statuses.findLast((event) => event.type === 'status') - return last?.type === 'status' ? last.session.hostExecutionPhase : undefined -} - -describe('a publish-first Claude create whose init is slow', () => { - it('keeps provider defaults out of saved picks after starting', async () => { - await expect(host.attach(CALLER, claudeParams())).resolves.toMatchObject({ ok: true }) - - // Published, not yet answering: the record holds no model rather than a guessed one. - expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() - expect(lastPhase()).toBe('starting') - - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() - expect(lastPhase()).toBe('ready') - }) - - it('keeps the saved model as intent while starting, then confirms what the child runs', async () => { - const params = claudeParams() - await expect( - host.attach(CALLER, { ...params, options: { model: 'opus' } }) - ).resolves.toMatchObject({ ok: true }) - expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - expect(lastPhase()).toBe('ready') - }) - - it('keeps the picked model across a resume whose new child starts on its own default', async () => { - const params = claudeParams() - await host.attach(CALLER, { ...params, options: { model: 'opus' } }) - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - await host.close(SESSION, 'evict') - const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - // Starting the chat again resumes the session under a new fence. - await expect(host.attach(CALLER, claudeParams(releasedFence))).resolves.toMatchObject({ - ok: true - }) - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(releasedFence) - // The new child is launched with the saved pick, whatever its CLI default. - expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - expect(lastPhase()).toBe('starting') - - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - expect(store.getRecord(SESSION)?.options?.model).toBe('opus') - expect(lastPhase()).toBe('ready') - }) - it('saves the account listing the child read at startup, though no view asks for options', async () => { - await host.attach(CALLER, claudeParams()) - expect(savedListings).toEqual([]) - - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - // `started` hands over the listing; the settings readback after it, what the config resolved. - expect(savedListings).toEqual([ - { sessionId: SESSION, listing: expect.objectContaining({ models: LISTED }) }, - { - sessionId: SESSION, - listing: expect.objectContaining({ - models: LISTED, - configuredDefault: expect.objectContaining({ modelId: 'claude-opus-9' }) - }) - } - ]) - expect(savedListings[0]?.listing).not.toHaveProperty('configuredDefault') - }) - it('reports the configured default from a chat created with its first message and no pick', async () => { - const first = { clientMessageId: 'opening', body: hostTestMessage('hello') } - await expect( - host.create(CALLER, createTestParams(first, claudeParams()), { firstMessage: first }) - ).resolves.toMatchObject({ ok: true }) - - // Delivery starts the CLI. Its open result carries the account listing; a later settings read - // resolves the configured model and says so once. - await vi.waitFor(() => expect(savedListings.length).toBeGreaterThanOrEqual(1)) - expect(savedListings[0]).toEqual({ - sessionId: SESSION, - listing: expect.objectContaining({ models: LISTED }) - }) - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() - expect(savedListings.at(-1)).toEqual({ - sessionId: SESSION, - listing: expect.objectContaining({ - configuredDefault: expect.objectContaining({ modelId: 'claude-opus-9' }) - }) - }) - // A later options read lists the models and says nothing more about the default. - await host.readOptions(SESSION) - expect(savedListings.at(-1)?.listing).not.toHaveProperty('configuredDefault') - }) - - it('saves the configured default when the settings readback beats the attach', async () => { - // The attach is slow to prove the owner, so the child's readback lands before the host indexes it. - const proveOwner = store.proveOwner.bind(store) - store.proveOwner = async (input) => { - await new Promise((resolve) => setTimeout(resolve, 200)) - return proveOwner(input) - } - await expect(host.attach(CALLER, claudeParams())).resolves.toMatchObject({ ok: true }) - await vi.waitFor(() => - expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready') - ) - await Promise.all(lifecycle) - - await vi.waitFor(() => - expect(savedListings.at(-1)?.listing).toMatchObject({ - configuredDefault: expect.objectContaining({ modelId: 'claude-opus-9' }) - }) - ) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts deleted file mode 100644 index 9cbaa5e63d70..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' -import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { - AGENT_SESSION_WIRE_REFUSAL_CODES, - type AgentSessionMutationEnvelope, - type AgentSessionMutationResult, - type AgentSessionWireRefusalCode -} from '../../../shared/agent-session-wire' -import { - agentSessionRefusalOperationState, - type AgentSessionRefusalOperationState -} from '../../../shared/agent-session-refusal-retry' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' -import { AgentSessionJournal } from '../agent-session-journal/journal-store' - -const CALLER = { callerKey: 'client-1' } -const METHODS = ['agentSession.setOption', 'agentSession.send'] as const -type Method = (typeof METHODS)[number] -type Pair = `${Method}:${AgentSessionWireRefusalCode}` - -type CallSpec = { - method: Method - operationId: string - expectedRuntimeFence?: number - payloadFingerprint?: string -} - -type Harness = { - root: string - store: AgentSessionRecordStore - host: StructuredAgentSessionHost - setOption: Mock -} - -const harnesses: Harness[] = [] -let operationSequence = 1_000 - -function operationId(timestamp = NOW): string { - operationSequence += 1 - return `${timestamp}-${operationSequence.toString(16).padStart(32, '0')}` -} - -async function createHarness(options: { attached?: boolean } = {}) { - const root = await mkdtemp(join(tmpdir(), 'orca-refusal-oracle-')) - const store = await openTestAgentSessionRecordStore(root) - const setOption = vi.fn(async () => undefined) - const adapter: StructuredAgentSessionAdapter = startsWhenPublished( - { - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire: async ({ fence }) => ({ - acquisitionGeneration: globalThis.crypto.randomUUID(), - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW - 1_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - }), - dispatch: async () => ({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => undefined, - // A failed acquisition is proven gone, as the real adapters prove it. - releaseAcquisition: async () => true, - setOption - }, - () => host - ) - const host = new StructuredAgentSessionHost({ - agents: claudeAndCodexDeclared(), - logger: createStructuredAgentSessionLogger(), - store, - adapter, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - const harness = { root, store, host, setOption } - harnesses.push(harness) - if (options.attached !== false) { - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - } - return harness -} - -afterEach(async () => { - const completed = harnesses.splice(0) - await Promise.all(completed.map(async ({ host }) => abandonStructuredAgentSessionHost(host))) - await Promise.all(completed.map(async ({ root }) => rm(root, { recursive: true }))) -}) - -function callFields(spec: CallSpec): Record { - if (spec.method === 'agentSession.send') { - return { body: hostTestMessage('host oracle') } - } - return { key: 'model', value: 'gpt-5' } -} - -function envelope(harness: Harness, spec: CallSpec): AgentSessionMutationEnvelope { - const fields = callFields(spec) - return { - sessionId: SESSION, - clientOperationId: spec.operationId, - expectedRuntimeFence: - spec.expectedRuntimeFence ?? harness.store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: - spec.payloadFingerprint ?? - computeAgentSessionPayloadFingerprint({ method: spec.method, sessionId: SESSION, fields }) - } -} - -function invoke(harness: Harness, spec: CallSpec): Promise> { - const fields = callFields(spec) - const mutationEnvelope = envelope(harness, spec) - if (spec.method === 'agentSession.send') { - return harness.host.send(CALLER, { - envelope: mutationEnvelope, - body: fields.body as ReturnType - }) - } - return harness.host.setOption(CALLER, { - envelope: mutationEnvelope, - key: fields.key as string, - value: fields.value as string - }) -} - -function operationState(harness: Harness, operation: string) { - return harness.store - .listOperationRows() - .find((row) => row.callerKey === CALLER.callerKey && row.operationId === operation)?.outcome -} - -async function assertHostAgreement( - harness: Harness, - spec: CallSpec, - code: AgentSessionWireRefusalCode, - retry?: () => Promise<{ harness: Harness; spec: CallSpec }> -): Promise { - try { - const result = await invoke(harness, spec) - expect(result, `${spec.method}:${code}`).toMatchObject({ ok: false, refusal: { code } }) - } catch (error) { - expect(error).toMatchObject({ message: code }) - } - const outcome = operationState(harness, spec.operationId) - let oracle: AgentSessionRefusalOperationState - if ( - harness.store.readCommandReceipt({ kind: 'global' }, spec.operationId).verdict === 'unreadable' - ) { - oracle = 'unknown' - } else if (outcome?.status === 'failed') { - oracle = 'settled-rejected' - } else if (outcome?.status === 'unknown') { - oracle = 'unknown' - } else if (outcome?.status === 'pending') { - oracle = 'pending-admission' - } else if (retry) { - const next = await retry() - await expect(invoke(next.harness, next.spec)).resolves.toMatchObject({ - ok: true, - replayed: false - }) - oracle = 'pending-admission' - } else { - oracle = 'settled-rejected' - } - expect(agentSessionRefusalOperationState(code), `${spec.method}:${code}`).toBe(oracle) - return `${spec.method}:${code}` -} - -// sendPlan and setOptionPlan have no unsupported branch. -const UNREACHABLE = new Set([ - 'agentSession.send:structured_agent_session_unsupported', - 'agentSession.setOption:structured_agent_session_unsupported', - // performPrompt is the sole producer of prompt revision and resolution refusals. - 'agentSession.setOption:agent_session_item_revision_stale', - 'agentSession.send:agent_session_item_revision_stale', - 'agentSession.setOption:agent_session_already_resolved', - 'agentSession.send:agent_session_already_resolved', - // StructuredAgentSessionHost.mutate maps an absent record to AGENT_SESSION_NOT_ATTACHED. - 'agentSession.setOption:agent_session_identity_required', - 'agentSession.send:agent_session_identity_required', - // Only a send opens the conversation it writes to. - 'agentSession.setOption:agent_session_journal_unreadable', - // Only a send restarts a lost owner. - 'agentSession.setOption:agent_session_owner_restart_failed', - // Saving a next-message pick is a conversation write, independent of its child owner. - 'agentSession.setOption:agent_session_conflict', - 'agentSession.setOption:execution_owner_reconciling', - 'agentSession.setOption:agent_session_operation_unknown', - // A write names its target, not an owner generation; only an attach compares fences. - 'agentSession.setOption:agent_session_checkpoint_stale', - 'agentSession.send:agent_session_checkpoint_stale', - // A send is a conversation write: admitted whoever owns the lease, and a start it needs that - // fails rejects the accepted message rather than refusing the call. - 'agentSession.send:agent_session_conflict', - 'agentSession.send:execution_owner_reconciling', - 'agentSession.send:agent_session_owner_restart_failed', - // Send admits through its receipt, which has no age budget. - 'agentSession.send:agent_session_operation_expired', - // The ledger has no count limit; only an older host still refuses with it. - 'agentSession.setOption:agent_session_operation_capacity', - 'agentSession.send:agent_session_operation_capacity' -]) - -describe('agentSessionRefusalOperationState host oracle', () => { - // Full host retries touch the durable store and successful cases rotate its backup. - it('agrees with every refusal the real host path can produce', { timeout: 90_000 }, async () => { - const produced = new Set() - const record = (pair: Pair) => produced.add(pair) - - const stale = await createHarness() - for (const method of METHODS) { - const spec = { method, operationId: operationId(), expectedRuntimeFence: 99 } - await expect(invoke(stale, spec), method).resolves.toMatchObject({ ok: true }) - } - expect(stale.setOption).not.toHaveBeenCalled() - - const absent = await createHarness({ attached: false }) - for (const method of METHODS) { - const spec = { method, operationId: operationId() } - record( - await assertHostAgreement(absent, spec, 'agent_session_ownership_unknown', async () => ({ - harness: await createHarness(), - spec - })) - ) - } - - const operationConflict = await createHarness() - for (const method of ['agentSession.setOption', 'agentSession.send'] as const) { - record( - await assertHostAgreement( - operationConflict, - { - method, - operationId: operationId(), - payloadFingerprint: 'wrong' - }, - 'agent_session_operation_conflict' - ) - ) - } - const ledgerRefusals = await createHarness() - for (const [code, timestamp, methods] of [ - [ - 'agent_session_operation_expired', - NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 1, - ['agentSession.setOption'] - ], - ['agent_session_operation_invalid', null, METHODS] - ] as const) { - for (const method of methods) { - record( - await assertHostAgreement( - ledgerRefusals, - { - method, - operationId: timestamp === null ? 'invalid-operation-id' : operationId(timestamp) - }, - code - ) - ) - } - } - - const unknown = await createHarness() - const sendUnknown = { method: 'agentSession.send' as const, operationId: operationId() } - await expect(invoke(unknown, sendUnknown)).resolves.toMatchObject({ ok: true }) - openTestJournalHostDatabase(unknown.root) - .db.prepare( - "UPDATE agent_session_command_receipts SET result_json = '{' WHERE operation_id = ?" - ) - .run(sendUnknown.operationId) - record(await assertHostAgreement(unknown, sendUnknown, 'agent_session_operation_unknown')) - - const unreadable = await createHarness() - await unreadable.host.close(SESSION, 'evict') - const unreadableOpen = vi - .spyOn(AgentSessionJournal.prototype, 'open') - .mockRejectedValue(new Error('journal path unreadable')) - const unreadableSend = { method: 'agentSession.send' as const, operationId: operationId() } - record( - await assertHostAgreement( - unreadable, - unreadableSend, - 'agent_session_journal_unreadable', - async () => { - unreadableOpen.mockRestore() - return { harness: unreadable, spec: unreadableSend } - } - ) - ) - - const allPairs = METHODS.flatMap((method) => - AGENT_SESSION_WIRE_REFUSAL_CODES.map((code) => `${method}:${code}` as Pair) - ) - expect(new Set([...produced, ...UNREACHABLE])).toEqual(new Set(allPairs)) - expect([...produced].filter((pair) => UNREACHABLE.has(pair))).toEqual([]) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts deleted file mode 100644 index 1d36bfe516e3..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-registered-definition.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -// The registry is the only source of an agent's definition: routing, declared capabilities and the -// option rules a chat at rest reads all follow what composition registered, and a definition this -// build ships but did not register decides nothing. - -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { CLAUDE_STRUCTURED_AGENT } from '../../claude/claude-structured-agent-definition' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' -import type { StructuredAgentDefinition } from './structured-agent-definition' -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionLiveOptions -} from './structured-agent-session-adapter' -import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' -import { StructuredAgentRegistry } from './structured-agent-registry' -import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' -import { - readStructuredAgentSessionOptions, - recordStructuredAgentSessionOptionIntent -} from './structured-agent-session-options-read' - -const RECORD: AgentSessionRecord = { - ...agentSessionRecordFixture(), - options: { model: 'pilot-model' } -} - -/** Claude, registered with rules unlike the ones Claude's own module declares. */ -const NON_DEFAULT: StructuredAgentDefinition = { - ...CLAUDE_STRUCTURED_AGENT, - capabilities: { ...CLAUDE_STRUCTURED_AGENT.capabilities, threadGoal: true }, - restingOptions: { - acceptsKey: (key) => key === 'pilotOption', - fallbackModels: () => [ - { id: 'pilot-model', label: 'Pilot', isDefault: true, defaultEffort: 'low', efforts: [] } - ], - effortDefaultsToModel: true - } -} - -function fakeAdapter(): StructuredAgentSessionAdapter { - return { - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire: vi.fn(async ({ fence, spawnToken }) => ({ - acquisitionGeneration: globalThis.crypto.randomUUID(), - process: { hostId: 'local', pid: 1, processStartTimeMs: 1, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: claudeProviderHandle('provider-session-1', null), - origin: 'created' as const, - mintedAtFence: fence, - observedAt: 1 - } - })), - dispatch: vi.fn(), - cancelTurn: vi.fn(), - answerPrompt: vi.fn(), - setOption: vi.fn(), - compact: vi.fn(), - changeThreadGoal: vi.fn() - } -} - -function pick(agents: StructuredAgentRegistry, record: AgentSessionRecord | null, key: string) { - const persistOptions = vi.fn(async () => {}) - const result = recordStructuredAgentSessionOptionIntent( - { store: { getRecord: () => record }, agents }, - { sessionId: RECORD.sessionId, persistOptions, publish: vi.fn() }, - { key, value: 'enabled' } - ) - return { result, persistOptions } -} - -/** At rest, or from a running child that answers `live`. */ -function readAtRest(agents: StructuredAgentRegistry, live?: StructuredAgentSessionLiveOptions) { - const resting = { - child: live ? { fence: 1 } : null, - params: { provider: RECORD.provider }, - journal: { threadGoal: () => null, contextUsage: () => null, context: { floor: () => null } } - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the read touches only these members. - const context = { - deps: { - adapter: live - ? { readOptions: async () => live } - : new StructuredAgentSessionAdapterRouter(agents, async () => {}), - agents, - store: { getRecord: () => RECORD } - }, - serialize: (_sessionId: string, task: () => Promise) => task(), - openConversation: async () => resting, - conversation: async () => resting - } as unknown as StructuredAgentSessionMutationContext - return readStructuredAgentSessionOptions(context, RECORD.sessionId, { readsWithoutModel: true }) -} - -describe('a registered definition', () => { - it('routes, declares capabilities and rules options at rest for its agent', async () => { - const adapter = fakeAdapter() - const agents = new StructuredAgentRegistry([{ definition: NON_DEFAULT, adapter }]) - const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) - - await router.acquire({ - identity: { - sessionId: 'session-routed', - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'claude', - providerHandle: null - }, - fence: 1, - spawnToken: 'spawn-1' - }) - expect(adapter.acquire).toHaveBeenCalledOnce() - expect(agents.capabilities(RECORD.provider)).toBe(NON_DEFAULT.capabilities) - - const accepted = pick(agents, RECORD, 'pilotOption') - await expect(accepted.result).resolves.toMatchObject({ ok: true }) - expect(accepted.persistOptions).toHaveBeenCalledWith({ - model: 'pilot-model', - pilotOption: 'enabled' - }) - // Claude's own module accepts `model`; the registration says otherwise and wins. - const refused = pick(agents, RECORD, 'model') - await expect(refused.result).resolves.toMatchObject({ ok: false }) - - const options = await readAtRest(agents) - expect(options.models.map((model) => model.id)).toEqual(['pilot-model']) - // No pick for effort: the registered rules read the model's default. - expect(options.current).toEqual({ - model: 'pilot-model', - effort: 'low', - permissionMode: 'ask' - }) - expect(options.threadGoal).toEqual({ current: null }) - }) - - it('leaves an agent this runtime did not register with no rules, whatever the build ships', async () => { - const agents = new StructuredAgentRegistry([ - { - definition: CODEX_STRUCTURED_AGENT, - adapter: { ...fakeAdapter(), rewind: vi.fn(), recoverRewind: vi.fn() } - } - ]) - - expect(agents.definition(RECORD.provider)).toBeNull() - expect(agents.capabilities(RECORD.provider)).toBeNull() - const refused = pick(agents, RECORD, 'model') - await expect(refused.result).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid', details: { reason: 'optionRejected' } } - }) - expect(refused.persistOptions).not.toHaveBeenCalled() - const options = await readAtRest(agents) - expect(options.models).toEqual([]) - expect(options.current).toEqual({ model: 'pilot-model' }) - expect(options.conversationCommands).toEqual(['clear']) - expect(options.acceptsImages).toBe(false) - }) - - it('says whether the session takes images: the definition, narrowed by a running child', async () => { - const registry = (imagePrompts: boolean) => - new StructuredAgentRegistry([ - { - definition: { - ...NON_DEFAULT, - capabilities: { ...NON_DEFAULT.capabilities, imagePrompts } - }, - adapter: fakeAdapter() - } - ]) - const live = (acceptsImages?: boolean): StructuredAgentSessionLiveOptions => ({ - models: [], - current: {}, - ...(acceptsImages === undefined ? {} : { acceptsImages }) - }) - await expect(readAtRest(registry(true))).resolves.toMatchObject({ acceptsImages: true }) - await expect(readAtRest(registry(false))).resolves.toMatchObject({ acceptsImages: false }) - await expect(readAtRest(registry(true), live())).resolves.toMatchObject({ acceptsImages: true }) - await expect(readAtRest(registry(true), live(false))).resolves.toMatchObject({ - acceptsImages: false - }) - // A child's yes never widens a definition's no. - await expect(readAtRest(registry(false), live(true))).resolves.toMatchObject({ - acceptsImages: false - }) - }) - - it('refuses a pick for a session with no record, as before', async () => { - const agents = new StructuredAgentRegistry([ - { definition: CLAUDE_STRUCTURED_AGENT, adapter: fakeAdapter() } - ]) - const missing = pick(agents, null, 'model') - await expect(missing.result).resolves.toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_invalid' } - }) - expect(missing.persistOptions).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts deleted file mode 100644 index 7f78671cbd69..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -// What the host does with a second press of the same control, against the real host, store and -// journal: under the first press's id it answers from that press and does nothing more, which is -// why a client sends every press under a new id; under a new id it acts. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { agentSessionRefusalOperationState } from '../../../shared/agent-session-refusal-retry' -import type { AgentSessionThreadGoalChange } from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let setOption: Mock -let changeThreadGoal: Mock> -let stopBackgroundTasks: Mock> -let cancelTurn: Mock - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-repeat-press-')) - resetHostTestOperationIds() - setOption = vi.fn(async () => undefined) - changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) - cancelTurn = vi.fn(async () => ({ cancelled: true })) - store = await openTestAgentSessionRecordStore(root) - const adapter: StructuredAgentSessionAdapter = startsWhenPublished( - { - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire: async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - acquisitionGeneration: 'generation-1', - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: fence > 1 ? ('resumed' as const) : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - }), - dispatch: vi.fn(async () => ({ state: 'admitted' as const })), - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn, - answerPrompt: vi.fn(async () => undefined), - setOption, - changeThreadGoal, - stopBackgroundTasks - }, - () => host - ) - host = new StructuredAgentSessionHost({ - agents: claudeAndCodexAgents(adapter), - logger: createStructuredAgentSessionLogger(), - store, - adapter, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-1', - // One live background task, which the background Stops below reach. - statusSink: { - publish: () => {}, - forget: () => {}, - readChildWork: () => [ - { - id: 'child-task-1', - providerId: 'task-1', - kind: 'agent', - state: 'working', - membership: 'live', - firstObservedAt: 1, - observedAt: 1, - stoppable: true, - invocation: { invocationId: 'spawn-task-1', generation: 1 } - } - ] - }, - now: () => NOW - }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function envelope( - method: string, - fields: Record, - clientOperationId: string, - expectedRuntimeFence: number | null = 1 -) { - return { - sessionId: SESSION, - clientOperationId, - expectedRuntimeFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -function pick(value: string, id = hostTestOperationId(), fence: number | null = 1) { - const fields = { key: 'model', value } - return host.setOption(CALLER, { - envelope: envelope('agentSession.setOption', fields, id, fence), - ...fields - }) -} - -describe('a press sent again', () => { - it('leaves an option on the other pick under the first id, and applies it under a new one', async () => { - const first = hostTestOperationId() - await pick('A', first) - await pick('B') - - expect(await pick('A', first)).toMatchObject({ ok: true, replayed: true }) - expect(setOption).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.options?.model).toBe('B') - - expect(await pick('A')).toMatchObject({ ok: true, replayed: false }) - expect(setOption).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.options?.model).toBe('A') - }) - - it('leaves a cleared goal cleared under the first id, and sets it under a new one', async () => { - const change = (next: AgentSessionThreadGoalChange, id = hostTestOperationId()) => - host.changeThreadGoal(CALLER, { - envelope: envelope('agentSession.threadGoal', { change: next }, id), - change: next - }) - const set: AgentSessionThreadGoalChange = { kind: 'set', objective: 'Ship the parser' } - const first = hostTestOperationId() - await change(set, first) - await change({ kind: 'clear' }) - - expect(await change(set, first)).toMatchObject({ ok: true, replayed: true }) - expect(changeThreadGoal).toHaveBeenCalledTimes(2) - - expect(await change(set)).toMatchObject({ ok: true, replayed: false }) - expect(changeThreadGoal).toHaveBeenCalledTimes(3) - }) - - it('stops a background task only under a new id once the first Stop ran', async () => { - const fields = { turnId: 'background-tasks', scope: 'background-tasks', taskId: 'task-1' } - const stop = (id = hostTestOperationId()) => - host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', fields, id), - turnId: 'background-tasks', - scope: 'background-tasks' as const, - taskId: 'task-1' - }) - // The first Stop reached the task before it could be stopped; its answer never arrived. - stopBackgroundTasks.mockResolvedValueOnce({ cancelled: false }) - const first = hostTestOperationId() - await stop(first) - - expect(await stop(first)).toMatchObject({ - ok: true, - replayed: true, - value: { cancelled: false } - }) - expect(stopBackgroundTasks).toHaveBeenCalledOnce() - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(stopBackgroundTasks).toHaveBeenCalledTimes(2) - }) - - it('interrupts a named turn only under a new id once the first Stop ran', async () => { - const stop = (id = hostTestOperationId()) => - host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }, id, null), - turnId: 'turn-1' - }) - cancelTurn.mockResolvedValueOnce({ cancelled: false }) - const first = hostTestOperationId() - await stop(first) - - expect(await stop(first)).toMatchObject({ - ok: true, - replayed: true, - value: { cancelled: false } - }) - expect(cancelTurn).toHaveBeenCalledOnce() - - expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(cancelTurn).toHaveBeenCalledTimes(2) - }) - - it('keeps nothing of a press it turned away before running, so its own id runs next time', async () => { - const fields = { turnId: 'background-tasks', scope: 'background-tasks' } - const id = hostTestOperationId() - const stop = (fence: number) => - host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', fields, id, fence), - turnId: 'background-tasks', - scope: 'background-tasks' as const - }) - await host.close(SESSION, 'evict') - - // No agent owns the chat, so the host turns the Stop away as not ready. - expect(await stop(1)).toMatchObject({ ok: false }) - const refused = await stop(1) - expect(refused.ok ? null : agentSessionRefusalOperationState(refused.refusal.code)).toBe( - 'pending-admission' - ) - const fence = store.getRecord(SESSION)!.lease.runtimeFence - expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) - - expect(await stop(store.getRecord(SESSION)!.lease.runtimeFence)).toMatchObject({ - ok: true, - replayed: false, - value: { cancelled: true } - }) - expect(stopBackgroundTasks).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts deleted file mode 100644 index d8cca1b75353..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -// Startup restore has to publish status, not just index the session. -// -// A tab nobody reopens after a restart still owes the sidebar a row. The host restores such a -// session read-only, without a provider child, so the only thing that can surface its state is -// the status publication the restore wiring makes. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import type { - AgentSessionMutationEnvelope, - AgentSessionStatusEvent -} from '../../../shared/agent-session-wire' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } - -const hosts: StructuredAgentSessionHost[] = [] -let root = '' - -function adapter(): StructuredAgentSessionAdapter { - return { - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire: async ({ fence, spawnToken }) => ({ - acquisitionGeneration: globalThis.crypto.randomUUID(), - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - }), - dispatch: async () => ({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => undefined, - setOption: async () => undefined - } -} - -function createHost(store: AgentSessionRecordStore): StructuredAgentSessionHost { - const host: StructuredAgentSessionHost = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: startsWhenPublished(adapter(), () => host), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - probeOwner: async () => ({ - outcome: 'indeterminate', - reason: 'read does not need ownership' - }), - now: () => NOW - }) - hosts.push(host) - return host -} - -function sendEnvelope( - store: AgentSessionRecordStore, - fields: Record -): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields - }) - } -} - -/** Persists one turn, then hands back a restarted host over the same directories. */ -async function restartWithPersistedTurn(): Promise { - root = await mkdtemp(join(tmpdir(), 'orca-restart-status-')) - resetHostTestOperationIds() - const store = await openTestAgentSessionRecordStore(root) - const host = createHost(store) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) - const body = hostTestMessage('persisted conversation') - const sent = await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body }) - if (!sent.ok) { - throw new Error('send was refused') - } - // Delivered, not just accepted: a message still queued at the restart was never a request. - await host.waitForSendSettlement(SESSION, sent.value.clientMessageId) - await host.flushAllStreamedEvents() - return createHost(await openTestAgentSessionRecordStore(root)) -} - -afterEach(async () => { - await Promise.all(hosts.splice(0).map((host) => host.flushAllStreamedEvents())) - await rm(root, { recursive: true, force: true }) - root = '' -}) - -describe('structured session restart status publication', () => { - // Served by the subscribe-time re-projection rather than the restore's own publish, so this - // covers what a restored journal projects — not the restore wiring. The test below pins that. - it('projects the persisted turn of a session restored without a provider', async () => { - const restarted = await restartWithPersistedTurn() - - await restarted.restoreReadableSessions() - const events: AgentSessionStatusEvent[] = [] - restarted.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) - - expect(events).toEqual([ - { - type: 'snapshot', - sessions: [ - expect.objectContaining({ - sessionId: SESSION, - workspaceId: 'workspace-1', - agent: 'codex', - status: 'idle', - latestPrompt: 'persisted conversation' - }) - ] - } - ]) - }) - - it('publishes a restored session to a list already sitting on the stream', async () => { - const restarted = await restartWithPersistedTurn() - const events: AgentSessionStatusEvent[] = [] - restarted.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) - expect(events).toEqual([{ type: 'snapshot', sessions: [] }]) - - await restarted.restoreReadableSessions() - - // The restore wiring publishes; without it this list never hears about the session at all. - expect(events.at(-1)).toEqual({ - type: 'status', - session: expect.objectContaining({ sessionId: SESSION, status: 'idle' }) - }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts deleted file mode 100644 index 82201aaed31f..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ /dev/null @@ -1,701 +0,0 @@ -// A send, or a hold, that finds the session's provider child gone, against the real host. The -// send is accepted at once; its delivery restarts the child, or rejects it with the reason. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { - AgentSessionMutationEnvelope, - AgentSessionSubscribeEvent -} from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { CodexAppServerRequestError } from '../../codex/codex-app-server-request-error' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } - -/** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ -function eventually(assertion: () => void | Promise): Promise { - return vi.waitFor(assertion, { timeout: 10_000 }) -} - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let spawnChild: StructuredAgentSessionAdapter['acquire'] -let dispatch: Mock -let hostErrors: unknown[] - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-send-recovery-')) - resetHostTestOperationIds() - hostErrors = [] - let generation = 0 - spawnChild = async ({ fence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - acquisitionGeneration: `generation-${++generation}`, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length - ? ('resumed' as const) - : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - }) - acquire = vi.fn(spawnChild) - dispatch = vi.fn(async () => ({ - state: 'accepted' as const, - providerIdentity: { - provider: 'codex' as const, - threadId: THREAD, - turnId: `turn-${dispatch.mock.calls.length}`, - ordinal: dispatch.mock.calls.length - } - })) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: { - warn: (_message, fields) => hostErrors.push(fields.error), - error: (_message, fields) => hostErrors.push(fields.error) - }, - store, - adapter: startsWhenPublished( - { - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire, - dispatch, - closeSession: vi.fn(async () => true), - releaseAcquisition: vi.fn(async () => true), - cancelTurn: vi.fn(async () => ({ cancelled: false })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - () => host - ), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - now: () => NOW - }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function sendParams(text: string, operationId = hostTestOperationId()) { - const body = hostTestMessage(text) - const envelope: AgentSessionMutationEnvelope = { - sessionId: SESSION, - clientOperationId: operationId, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - } - return { envelope, body } -} - -/** Accepted at once, before any owner exists for it; answers the message id. */ -async function accept(params: ReturnType): Promise { - const result = await host.send(CALLER, params) - expect(result, JSON.stringify(result)).toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'pending', handoverRecorded: true } } - }) - return params.envelope.clientOperationId -} - -async function submission(clientMessageId: string) { - return (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.clientMessageId === clientMessageId - ) -} - -/** The submission once delivery is done with it: handed over, or rejected unwritten. */ -async function settled(clientMessageId: string) { - await eventually(async () => { - const current = await submission(clientMessageId) - expect(current?.dispatchState !== 'pending' || current.handedOverAt !== undefined).toBe(true) - }) - return submission(clientMessageId) -} - -/** The failure rows a start the chat needed left, oldest first. */ -async function errorStatuses(): Promise { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => - item.body.kind === 'status' && item.body.tone === 'error' ? [item.body.text] : [] - ) -} - -/** The child timed out or exited: its lease is handed back and the host holds no session. */ -async function loseOwner(): Promise { - await host.close(SESSION, 'evict') - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - acquire.mockClear() -} - -describe('a send with no live owner', () => { - it('restarts the owner once and delivers against it', async () => { - await loseOwner() - - await accept(sendParams('after the child died')) - - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) - - it('accepts the send before anything restarts, and the restart hands it over', async () => { - await loseOwner() - const order: string[] = [] - const spawnChild = acquire.getMockImplementation()! - acquire.mockImplementationOnce(async (input) => { - order.push('acquire') - return spawnChild(input) - }) - const admit = store.admitMutationOperation - vi.spyOn(store, 'admitMutationOperation').mockImplementation((args) => { - order.push('admit') - return admit(args) - }) - - await accept(sendParams('accept first')) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - - expect(order).toEqual(['acquire']) - }) - - it('leaves a live owner alone', async () => { - acquire.mockClear() - - await accept(sendParams('owner is live')) - - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).not.toHaveBeenCalled() - }) - - it('restarts an independently retained old-build clear source', async () => { - await loseOwner() - await store.transitionHandoff(SESSION, (current) => ({ - ...current, - conversationCommand: { - command: 'clear', - state: 'completed', - replacementSessionId: 'session-after-clear', - operationId: hostTestOperationId(), - callerKey: CALLER.callerKey, - phase: 'committed' - } - })) - - await expect(host.send(CALLER, sendParams('into the retained chat'))).resolves.toMatchObject({ - ok: true - }) - await eventually(() => expect(acquire).toHaveBeenCalledOnce()) - }) - - it('restarts an owner that exited while the session stayed readable', async () => { - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence, - acquisitionGeneration: 'generation-1' - }) - expect(host.hasSession(SESSION)).toBe(true) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - acquire.mockClear() - - await accept(sendParams('after an exit')) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - }) - - it('restarts nothing for a resend the journal already answers', async () => { - const params = sendParams('sent once') - await accept(params) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - // The child died during startup: the lease is handed back, the fence moves, and the session - // stays readable. The client resends against the new fence. - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'startup deadline', - cause: 'unexpected-exit', - fence: params.envelope.expectedRuntimeFence ?? 0, - acquisitionGeneration: 'generation-1' - }) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - acquire.mockClear() - const resent = { - ...params, - envelope: { - ...params.envelope, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - } - } - - await expect(host.send(CALLER, resent)).resolves.toMatchObject({ ok: true, replayed: true }) - - expect(acquire).not.toHaveBeenCalled() - expect(dispatch).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - - // Retry rotates the id: a genuinely new send restarts the owner once. - await accept(sendParams('sent once')) - await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(2)) - expect(acquire).toHaveBeenCalledOnce() - }) - - /** A row admitted for this send, then the host died before the journal write, left as `outcome` - * says: `pending` by this build, `unknown` by a build that marked it before running. */ - async function admittedThenHostDied( - params: ReturnType, - outcome: 'pending' | 'unknown' - ) { - await store.admitMutationOperation({ - callerKey: CALLER.callerKey, - envelope: params.envelope, - hostFingerprint: params.envelope.payloadFingerprint, - now: NOW, - operationIdScope: 'global' - }) - if (outcome === 'unknown') { - await store.recordOperationOutcome({ - callerKey: CALLER.callerKey, - operationId: params.envelope.clientOperationId, - outcome: { status: 'unknown' } - }) - } - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: params.envelope.expectedRuntimeFence ?? 0, - acquisitionGeneration: 'generation-1' - }) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - acquire.mockClear() - return { - ...params, - envelope: { - ...params.envelope, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - } - } - } - - it('runs a send admitted but never run for the first time, restarting the owner once', async () => { - const resent = await admittedThenHostDied(sendParams('admitted, then the host died'), 'pending') - - await expect(host.send(CALLER, resent)).resolves.toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'pending' } } - }) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - }) - - it('accepts afresh when only an older ledger row exists without a command receipt', async () => { - const resent = await admittedThenHostDied(sendParams('marked unknown, then died'), 'unknown') - - await expect(host.send(CALLER, resent)).resolves.toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'pending' } } - }) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - }) - - it('accepts a send that arrives while a restart holds the queue, and hands both over in order', async () => { - await loseOwner() - const lostFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - let claimed = () => {} - let release = () => {} - const claim = new Promise((resolve) => (claimed = resolve)) - const spawnGate = new Promise((resolve) => (release = resolve)) - const spawnChild = acquire.getMockImplementation() - acquire.mockImplementationOnce(async (input) => { - claimed() - await spawnGate - return spawnChild!(input) - }) - - const first = await accept(sendParams('first')) - await claim - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(lostFence + 1) - // Written against the lost owner's fence, which admits it: a send is a conversation write. - const late = sendParams('second') - late.envelope.expectedRuntimeFence = lostFence - const second = host.send(CALLER, late) - release() - - expect(await second).toMatchObject({ ok: true }) - await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(2)) - expect(acquire).toHaveBeenCalledOnce() - expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toEqual([ - first, - late.envelope.clientOperationId - ]) - }) - - it('shares one restart between concurrent sends', async () => { - await loseOwner() - - const results = await Promise.all([ - host.send(CALLER, sendParams('first')), - host.send(CALLER, sendParams('second')), - host.send(CALLER, sendParams('third')) - ]) - - expect(results.map((result) => result.ok)).toEqual([true, true, true]) - await eventually(async () => expect(dispatch).toHaveBeenCalledTimes(3)) - expect(acquire).toHaveBeenCalledOnce() - }) - - it('replays into a closed session without spawning anything', async () => { - const params = sendParams('sent once') - await accept(params) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - await loseOwner() - - await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) - await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) - - // The conversation was opened for the answer; the record's lease was left as it was. - expect(host.hasSession(SESSION)).toBe(true) - expect(acquire).not.toHaveBeenCalled() - expect(dispatch).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it("rejects the accepted message with the restart's own cause, and says so in the chat once", async () => { - await loseOwner() - acquire.mockRejectedValue(new Error('Not signed in. Run codex login')) - const params = sendParams('while signed out') - // The acquire's error is Orca's wrapper, not the provider's words: it goes to the log. No exit - // was observed, so the chat does not say the provider stopped. - const cause = "Codex couldn't restart. Send your message to try again." - - const id = await accept(params) - - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - reason: cause, - rejection: { kind: 'restartFailed' } - }) - expect(dispatch).not.toHaveBeenCalled() - // Accepted, so the ledger answers a resend with the rejection rather than a second attempt. - expect( - store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId) - ).toMatchObject({ outcome: { status: 'succeeded' } }) - // One row, in the error tone, so the reason outlives the error strip. - expect(await errorStatuses()).toEqual([cause]) - }) - - it("keeps Codex's own words behind a refused resume without saying the provider stopped", async () => { - await loseOwner() - const said = `no rollout found for thread id ${THREAD}` - acquire.mockRejectedValue( - new CodexAppServerRequestError('thread/resume', -32600, `thread/resume failed: ${said}`, said) - ) - const id = await accept(sendParams('after the thread went away')) - - // The sentence names no cause and quotes nothing; Codex's words ride in the fact for Details. - const rejection = { - kind: 'restartFailed', - detail: { text: said, audience: 'person' }, - refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' } } - } - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - reason: "Codex couldn't restart. Send your message to try again.", - rejection - }) - expect(await errorStatuses()).toEqual([ - "Codex couldn't restart. Send your message to try again." - ]) - // Orca's own text is logged once where the start failed. - expect( - hostErrors.filter( - (error) => error instanceof Error && error.message === `thread/resume failed: ${said}` - ) - ).toHaveLength(1) - }) - - it('restarts again for a Retry under a new id, and replays a resend of the same id', async () => { - await loseOwner() - acquire.mockRejectedValue(new Error('Not signed in')) - const params = sendParams('while signed out') - await settled(await accept(params)) - expect(acquire).toHaveBeenCalledTimes(1) - - // A client that resends the same id gets the recorded rejection, and the chat no second row. - await expect(host.send(CALLER, params)).resolves.toMatchObject({ - ok: true, - replayed: true, - value: { submission: { dispatchState: 'rejected' } } - }) - expect(acquire).toHaveBeenCalledTimes(1) - expect(await errorStatuses()).toHaveLength(1) - - // The outbox's Retry rotates the id: a fresh attempt, with its own row. - expect(await settled(await accept(sendParams('while signed out')))).toMatchObject({ - dispatchState: 'rejected' - }) - expect(acquire).toHaveBeenCalledTimes(2) - expect(await errorStatuses()).toHaveLength(2) - expect(dispatch).not.toHaveBeenCalled() - }) - - it('restarts and delivers a later send once the cause clears', async () => { - await loseOwner() - acquire.mockRejectedValueOnce(new Error('Not signed in')) - expect(await settled(await accept(sendParams('while signed out')))).toMatchObject({ - dispatchState: 'rejected' - }) - - // The user signed in; nothing about the failed attempt is remembered. - await accept(sendParams('signed in now')) - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledTimes(2) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - }) - - it('suggests a new chat only when this host has nothing to restart the chat from', async () => { - await loseOwner() - acquire.mockRejectedValue(new Error('Not signed in')) - const failed = await settled(await accept(sendParams('restart fails'))) - expect(failed).toMatchObject({ dispatchState: 'rejected' }) - expect(failed?.reason).not.toMatch(/new chat/) - - // The adapter cannot run this record where it lives: no retry would bring it back. - host.deps.adapter.supportsLocation = () => false - const unresumable = await settled(await accept(sendParams('cannot resume here'))) - - expect(unresumable).toMatchObject({ - dispatchState: 'rejected', - reason: "Codex couldn't restart. Start a new chat to continue.", - rejection: { - kind: 'restartFailed', - refusal: { - code: 'structured_agent_session_unsupported', - details: { reason: 'hostUnsupported' } - } - } - }) - }) - - it('rejects the message with the cause when the restart met a lease someone else is settling', async () => { - await loseOwner() - vi.spyOn( - host['conversationDelivery'].loop['deps'], - 'ensureProviderChild' - ).mockResolvedValueOnce({ - ok: false, - refusal: { - code: 'execution_owner_reconciling', - message: 'Another runtime is still adjudicating this lease.' - } - }) - - const id = await accept(sendParams('owner being settled')) - - // The refusal's prose stays out of the chat; its code rides in the fact. - const cause = "Codex couldn't restart. Send your message to try again." - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - reason: cause, - rejection: { kind: 'restartFailed', refusal: { code: 'execution_owner_reconciling' } } - }) - expect(acquire).not.toHaveBeenCalled() - expect(await errorStatuses()).toEqual([cause]) - }) - - it('rejects the message, and reports the fault, when the restart itself faults', async () => { - await loseOwner() - vi.spyOn( - host['conversationDelivery'].loop['deps'], - 'ensureProviderChild' - ).mockRejectedValueOnce(new Error('spawn-token mint failed')) - - const id = await accept(sendParams('bookkeeping failed')) - - // Orca's own fault: reported to the log, and the chat says only that Orca failed. - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - reason: "Orca ran into a problem, so this didn't go through. Try again.", - rejection: { kind: 'hostFault' } - }) - expect(hostErrors).toContainEqual( - expect.objectContaining({ message: 'spawn-token mint failed' }) - ) - expect(await errorStatuses()).toHaveLength(1) - }) - - it('exits the recovery stage a failed attempt latched before its delivery resumes it', async () => { - await loseOwner() - // What an acquisition whose exit could not be proven leaves behind: nobody's, but latched. - await store.transitionHandoff(SESSION, (current) => ({ - ...current, - lease: { ...current.lease, handoffStage: 'recovering' } - })) - host.deps.probeOwner = async () => ({ outcome: 'pid-absent' }) - - await accept(sendParams('latched owner')) - - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - handoffStage: null, - claimStatus: 'live' - }) - }) - - it('adjudicates a lease this host has not reconciled before its delivery resumes it', async () => { - await loseOwner() - await store.transitionHandoff(SESSION, (current) => ({ - ...current, - lease: { ...current.lease, unreconciled: true } - })) - - // The send's start is the same serialized resume a hold runs, reconciliation first. - await accept(sendParams('owner unverified')) - - await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - unreconciled: false, - claimStatus: 'live' - }) - }) -}) - -// A pane keeps the fence of the last frame it read. An idle release and the restart after it -// each move the lease, so that fence can be several generations behind the one a write lands on. -describe('a write fenced to an owner the pane has not seen replaced', () => { - it('delivers a send fenced to the owner an idle release retired', async () => { - const seenFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await loseOwner() - const params = sendParams('after the release') - params.envelope.expectedRuntimeFence = seenFence - - const id = await accept(params) - expect(await settled(id)).toMatchObject({ dispatchState: 'accepted' }) - - expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(seenFence + 1) - }) - - // Clients resend a refused message when the fence they hold moves. A failed start is a - // rejected message now, never a refused send, and the pane keeps the fence it subscribed under. - it("keeps the pane's fence on the rows a failed start publishes, and rejects the message", async () => { - const seenFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - const frames: AgentSessionSubscribeEvent[] = [] - await host.subscribe({ id: 'pane', sessionId: SESSION, emit: (event) => frames.push(event) }) - await loseOwner() - acquire.mockRejectedValueOnce(new Error('Not signed in')) - const subscribed = frames.length - - const id = await accept(sendParams('while signed out')) - - expect(await settled(id)).toMatchObject({ - dispatchState: 'rejected', - rejection: { kind: 'restartFailed' } - }) - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(seenFence + 1) - const published = frames.slice(subscribed) - expect(published.length).toBeGreaterThan(0) - for (const frame of published) { - expect(frame).toMatchObject({ fence: seenFence }) - } - }) - - it('admits a Stop and a send queued behind the cold start that replaced their owner', async () => { - await loseOwner() - const lostFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - let claimed = () => {} - let release = () => {} - const claim = new Promise((resolve) => (claimed = resolve)) - const spawnGate = new Promise((resolve) => (release = resolve)) - acquire.mockImplementationOnce(async (input) => { - claimed() - await spawnGate - return spawnChild(input) - }) - - const firstParams = sendParams('starts the agent') - const first = host.send(CALLER, firstParams) - await claim - const cancelFields = { turnId: 'turn-1' } - const stop = host.cancel(CALLER, { - envelope: { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: lostFence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.cancel', - sessionId: SESSION, - fields: cancelFields - }) - }, - ...cancelFields - }) - const late = sendParams('typed during the start') - late.envelope.expectedRuntimeFence = lostFence - 1 - const second = host.send(CALLER, late) - release() - - expect(await first).toMatchObject({ ok: true }) - expect(await stop).toMatchObject({ ok: true, replayed: false }) - expect(await second).toMatchObject({ ok: true, replayed: false }) - // The Stop withdrew the message its start held; the one typed after it is delivered. - expect(await settled(late.envelope.clientOperationId)).toMatchObject({ - dispatchState: 'accepted' - }) - expect(await submission(firstParams.envelope.clientOperationId)).toMatchObject({ - dispatchState: 'rejected' - }) - // The Stop ended the child still proving its start, so the late send started another. - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts deleted file mode 100644 index 2d52abbbd6cf..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ /dev/null @@ -1,424 +0,0 @@ -// What a client's retry sees around a failed attach settlement: a crash between -// reserve and settlement replays into the original reservation, and a settled -// failure refuses sends without ever re-dispatching on the user's behalf. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import type * as AgentSessionRecordRows from '../../runtime/agent-session-record-rows' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { - AgentSessionDispatchOutcome, - StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const CALLER = { callerKey: 'client-1' } - -const publishFault = vi.hoisted(() => ({ failOnPublish: 0, publishCount: 0 })) - -vi.mock('../../runtime/agent-session-record-rows', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - writeAgentSessionStoreRows: (...args: Parameters) => { - publishFault.publishCount += 1 - if (publishFault.publishCount === publishFault.failOnPublish) { - throw new Error('simulated crash before failed-settlement publish') - } - return actual.writeAgentSessionStoreRows(...args) - } - } -}) - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let releaseAcquisition: Mock> -let dispatch: Mock - -function accepted(): AgentSessionDispatchOutcome { - return { - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - } -} - -function adapter(): StructuredAgentSessionAdapter { - return startsWhenPublished( - { - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire, - releaseAcquisition, - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - () => host - ) -} - -function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-settled-attach-retry-')) - publishFault.failOnPublish = 0 - publishFault.publishCount = 0 - resetHostTestOperationIds() - acquire = vi.fn(async ({ fence }) => ({ - acquisitionGeneration: globalThis.crypto.randomUUID(), - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', - mintedAtFence: fence, - observedAt: NOW - } - })) - releaseAcquisition = vi.fn(async () => true) - dispatch = vi.fn(async () => accepted()) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) -}) - -afterEach(async () => { - publishFault.failOnPublish = 0 - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -/** The host starting the agent with no message to deliver, as an operation that needs it does. */ -function startAgent(): Promise { - return host['serialize'](SESSION, () => host['mutationContext']().ensureAgent(SESSION)) -} - -describe('settled attach retry', () => { - it('settles a post-acquisition journal failure and retries without a restart', async () => { - const journalDatabase = openTestJournalHostDatabase(root) - vi.spyOn(AgentSessionJournal.prototype, 'open').mockRejectedValueOnce( - new Error('journal path unavailable') - ) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase, - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - const first = hostTestAttachParams(null) - - await expect(host.attach(CALLER, first)).rejects.toThrow('journal path unavailable') - expect(releaseAcquisition).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - ownerProcess: null, - reservedSpawnToken: null, - runtimeFence: 2 - }) - expect( - store.listOperationRows().find((row) => row.operationId === first.envelope.clientOperationId) - ?.outcome - ).toMatchObject({ status: 'failed' }) - - await expect(host.attach(CALLER, hostTestAttachParams(2))).resolves.toMatchObject({ ok: true }) - expect(acquire).toHaveBeenCalledTimes(2) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - handoffStage: null, - runtimeFence: 3 - }) - }) - - it('continues a safe same-host pending replay with the reserved token', async () => { - const spawnTokens: string[] = [] - acquire.mockImplementation(async ({ fence, spawnToken }) => { - spawnTokens.push(spawnToken) - if (spawnTokens.length === 1) { - throw new Error('reply lost') - } - return { - acquisitionGeneration: globalThis.crypto.randomUUID(), - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - } - }) - const mintSpawnToken = vi.fn(() => 'spawn-safe') - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken, - now: () => NOW - }) - const params = hostTestAttachParams(null) - publishFault.failOnPublish = 2 - - await expect(host.attach(CALLER, params)).rejects.toThrow( - 'agent session acquisition failure settlement failed' - ) - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true }) - expect(mintSpawnToken).toHaveBeenCalledOnce() - expect(spawnTokens).toEqual(['spawn-safe', 'spawn-safe']) - }) - - it('releases a reservation a crash left ownerless at restart, so the next start goes ahead', async () => { - const spawnTokens: string[] = [] - acquire.mockImplementation(async ({ fence, spawnToken }) => { - spawnTokens.push(spawnToken) - if (spawnTokens.length === 1) { - throw new Error('reply lost') - } - return { - acquisitionGeneration: globalThis.crypto.randomUUID(), - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - } - }) - let token = 0 - const mintSpawnToken = vi.fn(() => `spawn-${++token}`) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken, - // A host that cannot read another process's environment, so no scan can prove anything. - probeOwner: async () => ({ - outcome: 'indeterminate', - reason: 'spawn token scan unavailable' - }), - now: () => NOW - }) - const params = hostTestAttachParams(null) - publishFault.failOnPublish = 2 - - await expect(host.attach(CALLER, params)).rejects.toThrow( - 'agent session acquisition failure settlement failed' - ) - expect(publishFault.publishCount).toBe(2) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'reserved', - handoffStage: 'new-owner-proving', - runtimeFence: 1, - reservedSpawnToken: 'spawn-1', - ownerProcess: null - }) - - await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken, - // A host that cannot read another process's environment, so no scan can prove anything. - probeOwner: async () => ({ - outcome: 'indeterminate', - reason: 'spawn token scan unavailable' - }), - now: () => NOW - }) - - await host.restoreReadableSessions() - expect(releaseAcquisition).toHaveBeenCalledTimes(1) - // No owner was recorded: released at restart, with no evidence, since nothing proved one. - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - runtimeFence: 2, - reservedSpawnToken: null, - ownerProcess: null, - deathEvidence: null - }) - - // The interrupted operation's own retry continues it as a fresh reservation. - await expect(host.attach(CALLER, params)).resolves.toMatchObject({ ok: true }) - expect(mintSpawnToken).toHaveBeenCalledTimes(2) - expect(spawnTokens).toEqual(['spawn-1', 'spawn-2']) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - runtimeKind: 'native', - runtimeFence: 3, - handoffStage: null, - handoffOperationId: null, - ownerProcess: { spawnToken: 'spawn-2' } - }) - }) - - it('settles a submission the host restart left pending, and never redelivers it', async () => { - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) - // Admitted: written to the child, acknowledgement still outstanding. The - // restart below is the process fact that ends the wait, not a stopwatch. - dispatch.mockImplementationOnce(async () => ({ state: 'admitted' as const })) - const body = hostTestMessage('written before the host died') - const unknownParams = { - envelope: envelope('agentSession.send', { body }), - body - } - const first = await host.send(CALLER, unknownParams) - expect(first).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - // Handed over before the host dies: that is what makes the restart's answer doubt. - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) - - await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-restarted', - probeOwner: async () => ({ outcome: 'pid-absent' }), - now: () => NOW - }) - await host.restoreReadableSessions() - await startAgent() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - handoffStage: null, - runtimeFence: 3 - }) - expect(dispatch).toHaveBeenCalledTimes(1) - - const newBody = hostTestMessage('are you there?') - const sent = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body: newBody }), - body: newBody - }) - if (!sent.ok) { - throw new Error(`unexpected restored send refusal: ${sent.refusal.message}`) - } - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(2)) - const restoredHistory = await host.history({ sessionId: SESSION, direction: 'tail' }) - if (!restoredHistory.ok) { - throw new Error(`unexpected restored history reset: ${restoredHistory.reset}`) - } - expect( - restoredHistory.page.submissions.find( - (submission) => submission.clientMessageId === unknownParams.envelope.clientOperationId - )?.dispatchState - ).toBe('unknown') - - // A restart ends the wait without proving the dead child never took the - // frame, so even an explicit retry replays rather than sending a second copy. - const explicitRetry = await host.send(CALLER, { - ...unknownParams, - envelope: { - ...unknownParams.envelope, - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 3 - }, - retryUnknown: true - }) - expect(explicitRetry).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'unknown' } } - }) - expect(dispatch).toHaveBeenCalledTimes(2) - }) - - it('records proven acquisition cleanup as durable death evidence', async () => { - acquire.mockRejectedValueOnce(new Error('resume rejected')) - - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: false, - refusal: { - message: "Codex couldn't restart. Send your message to try again.", - ownerVerdict: 'exited' - } - }) - - expect(releaseAcquisition).toHaveBeenCalledTimes(1) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - deathEvidence: { - kind: 'exit-observed', - detail: 'acquisition cleanup proved no provider child remains' - } - }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts deleted file mode 100644 index 70c1dc04e139..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { - STRUCTURED_AGENT_SESSION_STARTUP_CEILING_MS as CEILING_MS, - STRUCTURED_AGENT_SESSION_STARTUP_SILENCE_MS as SILENCE_MS -} from './structured-agent-session-startup-attempt-contract' -import { - StructuredAgentSessionStartupAttempts, - mintStructuredAgentSessionStartupAttempt, - type StructuredAgentSessionExpiredStartup, - type StructuredAgentSessionStartupSettled -} from './structured-agent-session-startup-attempt' - -const SESSION = 'session-alpha' -const CHILD = { generation: 'generation-1', fence: 3 } - -let expired: StructuredAgentSessionExpiredStartup[] -let settled: StructuredAgentSessionStartupSettled[] -let attempts: StructuredAgentSessionStartupAttempts - -function mint() { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mint reads only the lease fence, location, account home and options this literal sets. - const record = { - lease: { runtimeFence: CHILD.fence }, - location: { executionHostId: 'local', workspaceId: 'workspace-1' }, - accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' } - } as unknown as AgentSessionRecord - return mintStructuredAgentSessionStartupAttempt({ - record, - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: null - }, - spawnToken: 'spawn-1' - }) -} - -/** Output every `everyMs` for `forMs`. */ -function chatter(output: () => void, forMs: number, everyMs = 10_000): void { - for (let elapsed = 0; elapsed < forMs; elapsed += everyMs) { - vi.advanceTimersByTime(everyMs) - output() - } -} - -beforeEach(() => { - vi.useFakeTimers() - expired = [] - settled = [] - attempts = new StructuredAgentSessionStartupAttempts({ - expire: (startup) => expired.push(startup), - settled: (startup) => settled.push(startup) - }) -}) - -afterEach(() => { - attempts.dispose() - vi.useRealTimers() -}) - -describe('the startup clock', () => { - it('does not run before the spawn: resolving a launch is not starting', () => { - attempts.track(SESSION, mint()) - - vi.advanceTimersByTime(CEILING_MS * 2) - - expect(expired).toEqual([]) - }) - - it('fails a spawned agent that says nothing for the silence limit, naming the acquire', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - - vi.advanceTimersByTime(SILENCE_MS - 1) - expect(expired).toEqual([]) - vi.advanceTimersByTime(1) - - expect(expired).toEqual([{ sessionId: SESSION, attemptId: attempt.attemptId, child: null }]) - vi.advanceTimersByTime(CEILING_MS) - expect(expired).toHaveLength(1) - // Measured at the limit, recorded once the aborted acquire settles. - expect(settled).toEqual([]) - attempts.abandon(SESSION, attempt.attemptId) - expect(settled).toEqual([{ agent: 'codex', outcome: 'silent', durationMs: SILENCE_MS }]) - }) - - it('keeps a slow start that is still talking past the silence limit', () => { - const attempt = mint() - const progress = attempts.track(SESSION, attempt) - progress.spawned() - attempts.published(SESSION, attempt.attemptId, CHILD) - - chatter(progress.output, SILENCE_MS * 3) - attempts.ready(SESSION, CHILD) - - expect(expired).toEqual([]) - expect(settled).toEqual([{ agent: 'codex', outcome: 'ready', durationMs: SILENCE_MS * 3 }]) - expect(attempts.isOpen(SESSION)).toBe(false) - }) - - it('times silence from the last output, not the spawn', () => { - const progress = attempts.track(SESSION, mint()) - progress.spawned() - vi.advanceTimersByTime(SILENCE_MS - 1_000) - progress.output() - - vi.advanceTimersByTime(SILENCE_MS - 1) - expect(expired).toEqual([]) - vi.advanceTimersByTime(1) - - expect(expired).toHaveLength(1) - }) - - it('fails a chatty agent that never becomes ready at the ceiling', () => { - const attempt = mint() - const progress = attempts.track(SESSION, attempt) - progress.spawned() - attempts.published(SESSION, attempt.attemptId, CHILD) - - chatter(progress.output, CEILING_MS - 10_000) - expect(expired).toEqual([]) - chatter(progress.output, 10_000) - - expect(expired).toEqual([{ sessionId: SESSION, attemptId: attempt.attemptId, child: CHILD }]) - expect(settled).toEqual([{ agent: 'codex', outcome: 'ceiling', durationMs: CEILING_MS }]) - }) - - it('starts on output or publication when no spawn was reported', () => { - const byOutput = mint() - attempts.track(SESSION, byOutput).output() - const byPublish = mint() - attempts.track('session-beta', byPublish) - attempts.published('session-beta', byPublish.attemptId, CHILD) - - vi.advanceTimersByTime(SILENCE_MS) - - expect(expired.map((startup) => startup.attemptId).sort()).toEqual( - [byOutput.attemptId, byPublish.attemptId].sort() - ) - }) -}) - -describe('an attempt ends', () => { - it('with the start its child proved, and ignores a stale child’s proof', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - attempts.published(SESSION, attempt.attemptId, CHILD) - - attempts.ready(SESSION, { ...CHILD, generation: 'generation-0' }) - expect(attempts.isOpen(SESSION)).toBe(true) - attempts.ready(SESSION, CHILD) - vi.advanceTimersByTime(CEILING_MS) - - expect(expired).toEqual([]) - expect(attempts.isOpen(SESSION)).toBe(false) - }) - - it('with its child, however the child ended, leaving no entry or timer', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - attempts.published(SESSION, attempt.attemptId, CHILD) - - attempts.childEnded(SESSION, { ...CHILD, generation: 'generation-0' }) - expect(attempts.isOpen(SESSION)).toBe(true) - attempts.childEnded(SESSION, CHILD) - - expect(attempts.isOpen(SESSION)).toBe(false) - expect(vi.getTimerCount()).toBe(0) - expect(settled.map((startup) => startup.outcome)).toEqual(['ended']) - }) - - it('once an expiry has stopped its published child', () => { - const attempt = mint() - attempts.track(SESSION, attempt).spawned() - attempts.published(SESSION, attempt.attemptId, CHILD) - - vi.advanceTimersByTime(SILENCE_MS) - - expect(expired).toHaveLength(1) - expect(attempts.isOpen(SESSION)).toBe(false) - expect(vi.getTimerCount()).toBe(0) - }) - - it('when an acquire the expiry aborted is abandoned, or publishes late and is expired then', () => { - const abandoned = mint() - attempts.track(SESSION, abandoned).spawned() - vi.advanceTimersByTime(SILENCE_MS) - expect(attempts.isOpen(SESSION)).toBe(true) - attempts.abandon(SESSION, abandoned.attemptId) - expect(attempts.isOpen(SESSION)).toBe(false) - - const late = mint() - attempts.track(SESSION, late).spawned() - vi.advanceTimersByTime(SILENCE_MS) - attempts.published(SESSION, late.attemptId, CHILD) - - expect(expired.map((startup) => startup.child)).toEqual([null, null, CHILD]) - expect(attempts.isOpen(SESSION)).toBe(false) - expect(vi.getTimerCount()).toBe(0) - // Each start is measured once, at the limit that ended it. - expect(settled.map((startup) => startup.outcome)).toEqual(['silent', 'silent']) - }) - - it('with a child that proved its start, or an attach that failed', () => { - const ready = mint() - attempts.track(SESSION, ready).spawned() - attempts.published(SESSION, ready.attemptId, CHILD) - attempts.ready(SESSION, CHILD) - const failed = mint() - attempts.track('session-beta', failed).spawned() - attempts.abandon('session-beta', failed.attemptId) - - vi.advanceTimersByTime(CEILING_MS) - - expect(expired).toEqual([]) - expect(vi.getTimerCount()).toBe(0) - }) - - it('when the next attempt replaces it, and every clock stops at quit', () => { - const first = mint() - const firstProgress = attempts.track(SESSION, first) - firstProgress.spawned() - const second = mint() - attempts.track(SESSION, second).spawned() - // The replaced attempt's reports reach nothing. - firstProgress.output() - attempts.abandon(SESSION, first.attemptId) - expect(attempts.isOpen(SESSION)).toBe(true) - - attempts.dispose() - vi.advanceTimersByTime(CEILING_MS) - - expect(expired).toEqual([]) - expect(attempts.isOpen(SESSION)).toBe(false) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts deleted file mode 100644 index 18c758d66f6a..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ /dev/null @@ -1,875 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// The lifetime of a provider child, against the real host rather than a double. -// -// Two leaks meet here: a chat that closes without stopping its app-server, and a launch that -// starts one for every record on disk. A view never starts or keeps a child; work starts one, the -// idle sweep stops it, and an exit is settled and left for the next send. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { - AgentSessionMutationEnvelope, - AgentSessionSubscribeEvent -} from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { - AgentSessionAcquisitionRootExitObservedError, - type StructuredAgentSessionAdapter -} from './structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-feed' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - hostTestMessage, - hostTestOperationId, - resetHostTestOperationIds -} from './structured-agent-session-host-test-data' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' - -const UNEXPECTED_PROVIDER_EXIT_OUTCOME = - 'Codex stopped while this response was in progress. You can continue in this conversation.' - -const CALLER = { callerKey: 'client-1' } -/** Short enough to keep the suite fast; the host clock below decides what is idle. */ -const SWEEP_MS = 5 -const IDLE_MS = 1_000 - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let acquire: Mock -let closeSession: Mock> -let dispatch: Mock -let sink: StructuredAgentSessionEventSink | null -let hostErrors: unknown[] -let statusSink: StructuredAgentSessionStatusSink -let clock: number -function adapter(): StructuredAgentSessionAdapter { - return startsWhenPublished( - { - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire, - closeSession, - releaseAcquisition: vi.fn(async () => true), - dispatch, - cancelTurn: vi.fn(async () => ({ cancelled: false })), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, - () => host - ) -} - -function openHost( - probeOwner?: (record: AgentSessionRecord) => Promise -): void { - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: { - warn: (_message, fields) => hostErrors.push(fields.error), - error: (_message, fields) => hostErrors.push(fields.error) - }, - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, - idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, - now: () => clock, - statusSink, - ...(probeOwner ? { probeOwner } : {}) - }) -} - -/** A fresh app generation over the same durable store, with its owner proven gone. */ -async function reboot(): Promise { - await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(root) - openHost(async () => ({ outcome: 'pid-absent' })) - acquire.mockClear() - closeSession.mockClear() -} - -async function attach(): Promise { - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) -} - -/** What a send's delivery or `agentSession.ensure` does: attach at the record's current fence. */ -async function startAgent(): Promise { - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) -} - -function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { - return { - sessionId: SESSION, - clientOperationId: hostTestOperationId(), - expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -function emitTurnLifecycle(state: 'running' | 'completed', ordinal: number): void { - sink?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal }, - { kind: 'status', text: state, turnLifecycle: { turnId: 'turn-1', state } }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) -} - -/** The sweep stops the child first and closes the conversation last. */ -function waitForEviction(): Promise { - return vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(host.hasSession(SESSION)).toBe(false) - }) -} - -/** Long enough for many sweep ticks, so "not stopped" means the sweep declined. */ -function waitOutSeveralSweeps(): Promise { - return new Promise((resolve) => setTimeout(resolve, SWEEP_MS * 20)) -} - -/** Fails the next eviction at `drain-published`, which leaves the session indexed for a retry. The - * stop drains once before it, to judge whether it ends work. */ -function failEvictionDrain(): void { - const sink = host['runtimeState'].eventSinkFor(SESSION) - const drained = sink.drained.bind(sink) - vi.spyOn(sink, 'drained') - .mockImplementationOnce(drained) - .mockResolvedValueOnce({ ok: false, error: new Error('drain barrier lost') }) -} - -/** The submissions as they stood when the session was forgotten; its journal is gone after that. */ -async function failJournalSinkUntilReleased(): Promise { - const session = ( - host as unknown as { - sessions: Map Promise } }> - } - ).sessions.get(SESSION) - expect(session).toBeDefined() - vi.spyOn(session!.journal, 'appendItem').mockRejectedValueOnce(new Error('disk unavailable')) - sink?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'lost write' }] }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - deathEvidence: { kind: 'exit-observed' } - }) - }) -} - -/** Replaces the failed cached sink so suite cleanup can drain the host. */ -function replaceFailedSink(): void { - ;( - host as unknown as { - runtimeState: { eventSinkFor: (sessionId: string) => unknown } - } - ).runtimeState.eventSinkFor(SESSION) -} - -function captureSettledSubmissions(): { value: AgentJournalSubmission[] } { - const captured: { value: AgentJournalSubmission[] } = { value: [] } - const journal = host['sessions'].get(SESSION)!.journal - const closeJournal = journal.close.bind(journal) - vi.spyOn(journal, 'close').mockImplementation(async () => { - captured.value = journal.snapshot().submissions - await closeJournal() - }) - return captured -} - -async function sendPending(text: string): Promise { - dispatch.mockResolvedValueOnce({ state: 'admitted' }) - const body = hostTestMessage(text) - expect( - await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-surface-lifetime-')) - resetHostTestOperationIds() - sink = null - hostErrors = [] - clock = NOW - statusSink = { publish: vi.fn(), forget: vi.fn() } - let generation = 0 - acquire = vi.fn(async ({ fence, spawnToken, events }) => { - sink = events ?? null - return { - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - acquisitionGeneration: `generation-${++generation}`, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length - ? ('resumed' as const) - : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - } - }) - closeSession = vi.fn(async () => true) - dispatch = vi.fn(async () => ({ - state: 'rejected' as const, - ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { - surface: 'rejection' - }) - })) - store = await openTestAgentSessionRecordStore(root) - openHost() -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -describe('a chat that closes', () => { - it('stops the provider child it started', async () => { - await attach() - - await host.close(SESSION, 'evict') - - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(host.hasSession(SESSION)).toBe(false) - expect(hostErrors).toEqual([]) - // The record and its journal stay; only the process and the claim on it go. - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null, - deathEvidence: { kind: 'exit-observed' } - }) - }) - - // The pane outlives the close by a few frames — a workspace delete closes the chats inside it - // while their panes are still mounted. A read in that window reopens the conversation as a cache - // and is answered, never refused; it starts nothing. - it('answers a read from the pane that outlived it without starting a child', async () => { - await attach() - - await host.close(SESSION, 'evict') - expect(host.hasSession(SESSION)).toBe(false) - - expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) - const unsubscribe = await host.subscribe({ - id: 'sub-1', - sessionId: SESSION, - emit: () => undefined - }) - unsubscribe() - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('answers a compatibility wait with what eviction recorded', async () => { - await attach() - dispatch.mockResolvedValueOnce({ state: 'admitted' }) - const body = hostTestMessage('pending until close') - const result = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - expect(result).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - if (!result.ok) { - throw new Error('send was refused') - } - // Handed over first: a message still queued at close is rejected as never sent instead. - await vi.waitFor(() => expect(dispatch).toHaveBeenCalled()) - const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId) - - await host.close(SESSION, 'evict') - - // Eviction's settlement is a journal write, so the wait sees it rather than timing out. - await expect(settlement).resolves.toMatchObject({ - value: { - submission: { dispatchState: 'unknown', reason: 'provider_closed_before_acknowledgement' } - } - }) - }) - - it('keeps the stop it made when the journal close loses its result', async () => { - await attach() - const session = host['sessions'].get(SESSION) - expect(session).toBeDefined() - const closeJournal = session!.journal.close.bind(session!.journal) - vi.spyOn(session!.journal, 'close') - .mockImplementationOnce(async () => { - await closeJournal() - throw new Error('journal close result lost') - }) - .mockImplementation(closeJournal) - - // The child is stopped and the lease released before the handle closes; the entry is dropped - // before that close, so a lost result leaves no closing handle for a reader to find. - await expect(host.close(SESSION, 'evict')).rejects.toThrow('journal close result lost') - expect(host.hasSession(SESSION)).toBe(false) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(closeSession).toHaveBeenCalledOnce() - }) - - it('reports a failed drain after the child stopped, and still settles and releases', async () => { - await attach() - dispatch.mockResolvedValueOnce({ state: 'admitted' }) - const body = hostTestMessage('pending across an aborted eviction') - const sent = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - const session = host['sessions'].get(SESSION) - expect(session).toBeDefined() - failEvictionDrain() - const settled = captureSettledSubmissions() - - // The child is proven gone: the failed drain is bookkeeping, reported, and the rest still runs. - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(session!.child).toBeNull() - expect(closeSession).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - expect(hasUnansweredStructuredAgentSessionDispatch(settled.value)).toBe(false) - }) -}) - -describe('a session with a turn in flight', () => { - it('is not stopped while the turn runs, and is once it ends and idles', async () => { - await attach() - emitTurnLifecycle('running', 1) - await host.flushStreamedEvents(SESSION) - - clock += IDLE_MS - await waitOutSeveralSweeps() - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - - emitTurnLifecycle('completed', 2) - await host.flushStreamedEvents(SESSION) - clock += IDLE_MS - - await waitForEviction() - }) - - // Codex settles an admitted send only on its echo, which may never come; the stop retires it. - it('is stopped with an admitted send outstanding once no turn runs', async () => { - await attach() - await sendPending('admitted, never echoed') - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - - clock += IDLE_MS - - await waitForEviction() - }) -}) - -describe('startup', () => { - it('settles an idle absent owner without chat pollution and resumes the same provider identity', async () => { - await attach() - const beforeRestart = store.getRecord(SESSION) - await abandonStructuredAgentSessionHost(host) - - store = await openTestAgentSessionRecordStore(root) - openHost(async () => ({ outcome: 'pid-absent' })) - await host.restoreReadableSessions() - - const restored = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(restored.ok && restored.page.items.some((item) => item.body.kind === 'status')).toBe( - false - ) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - - await startAgent() - expect(store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toEqual( - beforeRestart?.providerHandleChain.at(-1)?.handle - ) - expect(store.getRecord(SESSION)?.providerHandleChain.at(-1)?.origin).toBe('resumed') - }) - - it('restores a session for reading without spawning a provider child', async () => { - await attach() - await reboot() - - await host.restoreReadableSessions() - - // The record is readable — the tab comes back, history answers — and nothing is running. - expect(acquire).not.toHaveBeenCalled() - expect(host.listSessionTabs()).toEqual([ - { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } - ]) - expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('gives the child back for work, never for a read', async () => { - await attach() - await reboot() - await host.restoreReadableSessions() - - const unsubscribe = await host.subscribe({ - id: 'viewer-1', - sessionId: SESSION, - emit: () => undefined - }) - expect(acquire).not.toHaveBeenCalled() - await startAgent() - unsubscribe() - - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - runtimeKind: 'native', - ownerProcess: { pid: 4242 } - }) - }) -}) - -describe('a session closed and started again', () => { - it('publishes provider events to the reattached chat', async () => { - await attach() - await host.close(SESSION, 'evict') - expect(host.hasSession(SESSION)).toBe(false) - - await startAgent() - const events: AgentSessionSubscribeEvent[] = [] - const unsubscribe = await host.subscribe({ - id: 'subscriber-1', - sessionId: SESSION, - emit: (event) => events.push(event) - }) - sink?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 1 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'back again' }] }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - sink?.publish() - await host.flushStreamedEvents(SESSION) - unsubscribe() - - expect(JSON.stringify(events)).toContain('back again') - }) -}) - -async function submissionState(clientMessageId: string): Promise { - return (await host.journalSnapshot(SESSION)).submissions.find( - (entry) => entry.clientMessageId === clientMessageId - )?.dispatchState -} - -describe('an unexpected provider exit', () => { - it('publishes terminal settlement to a waiting older client', async () => { - await attach() - dispatch.mockResolvedValueOnce({ state: 'admitted' }) - const body = hostTestMessage('pending until provider exit') - const result = await host.send(CALLER, { - envelope: envelope('agentSession.send', { body }), - body - }) - expect(result).toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - if (!result.ok) { - throw new Error('send was refused') - } - // Accepted first; the exit must meet a message the provider was handed. - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) - const settlement = host.waitForSendSettlement(SESSION, result.value.clientMessageId) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - - await expect(settlement).resolves.toMatchObject({ - value: { submission: { dispatchState: 'unknown' } } - }) - }) - - it('turns a journal sink failure into observed-exit settlement and lease release', async () => { - await attach() - - await failJournalSinkUntilReleased() - - expect(dispatch).not.toHaveBeenCalled() - const history = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect( - history.ok && - history.page.items.some( - (item) => item.body.kind === 'status' && item.body.text.includes('journal sink failure') - ) - ).toBe(false) - replaceFailedSink() - }) - - // TypeScript accepts the body; the persisted reader rejects a call id that is only spaces. - it('stops a provider whose output would not read back, and the next send starts a child', async () => { - await attach() - - sink?.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, - { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'tool-call', name: 'Bash', input: null, callId: ' ' }] - }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await vi.waitFor(() => { - expect(closeSession).toHaveBeenCalledWith(SESSION) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) - }) - expect(hostErrors).toContainEqual(expect.objectContaining({ code: 'journal_row_rejected' })) - replaceFailedSink() - - // Nothing restarts it on its own; the next send does, and the chat still loads. - expect(acquire).toHaveBeenCalledOnce() - await startAgent() - expect(acquire).toHaveBeenCalledTimes(2) - emitTurnLifecycle('running', 2) - await host.flushAllStreamedEvents() - await reboot() - expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) - }) - - it('settles a journal sink failure whose stop saw the provider root exit', async () => { - await attach() - // The lease follows the root, so its seen exit settles like a proven one. - closeSession.mockRejectedValueOnce( - new AgentSessionAcquisitionRootExitObservedError(new Error('provider close unproven')) - ) - - await failJournalSinkUntilReleased() - - replaceFailedSink() - }) - - it('releases the exact generation, starts nothing, and the next message starts a child', async () => { - await attach() - dispatch.mockRejectedValueOnce(new Error('provider delivery became unknown')) - const unknownBody = hostTestMessage('message with unknown delivery') - const unknownEnvelope = envelope('agentSession.send', { body: unknownBody }) - await expect( - host.send(CALLER, { envelope: unknownEnvelope, body: unknownBody }) - ).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - // Accepted, then handed over by the delivery loop, where the thrown dispatch becomes doubt. - await vi.waitFor(async () => - expect(await submissionState(unknownEnvelope.clientOperationId)).toBe('unknown') - ) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - - const recoveredHistory = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect( - recoveredHistory.ok && - hasUnansweredStructuredAgentSessionDispatch(recoveredHistory.page.submissions) - ).toBe(false) - // No respawn: the exit is settled and shown, and the conversation waits for work. - expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - runtimeFence: exitedFence + 1 - }) - dispatch.mockResolvedValueOnce({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-next', ordinal: 1 } - }) - const body = hostTestMessage('a distinct next message') - const nextEnvelope = envelope('agentSession.send', { body }) - await expect(host.send(CALLER, { envelope: nextEnvelope, body })).resolves.toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await vi.waitFor(async () => - expect(await submissionState(nextEnvelope.clientOperationId)).toBe('accepted') - ) - expect(acquire).toHaveBeenCalledTimes(2) - expect(dispatch).toHaveBeenCalledTimes(2) - }) - - it('does not reacquire for a live reader or a stale child generation', async () => { - await attach() - const unsubscribe = await host.subscribe({ - id: 'subscriber-1', - sessionId: SESSION, - emit: () => undefined - }) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'stale child exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-stale' - }) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'current child exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - expect(acquire).toHaveBeenCalledOnce() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - runtimeFence: exitedFence + 1, - deathEvidence: { kind: 'exit-observed' } - }) - unsubscribe() - }) - - it('ends the record of a requested close as a close, never as a crash to recover from', async () => { - await attach() - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider closed', - cause: 'requested-close', - fence, - acquisitionGeneration: 'generation-1' - }) - - expect(acquire).toHaveBeenCalledOnce() - expect(host['sessions'].get(SESSION)?.child).toBeNull() - expect(host['sessions'].get(SESSION)?.lastEndedChild).not.toHaveProperty('failure') - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('recovers after a failed lifecycle barrier and dispatches a distinct next message', async () => { - await attach() - dispatch.mockRejectedValueOnce(new Error('provider delivery became unknown')) - const unknownBody = hostTestMessage('message with unknown delivery') - const unknownParams = { - envelope: envelope('agentSession.send', { body: unknownBody }), - body: unknownBody - } - await expect(host.send(CALLER, unknownParams)).resolves.toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await vi.waitFor(async () => - expect(await submissionState(unknownParams.envelope.clientOperationId)).toBe('unknown') - ) - const runtimeState = ( - host as unknown as { - runtimeState: { lifecycleBarrier: () => Promise<{ ok: false; error: Error }> } - } - ).runtimeState - vi.spyOn(runtimeState, 'lifecycleBarrier').mockResolvedValueOnce({ - ok: false, - error: new Error('journal failed') - }) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - runtimeFence: exitedFence + 1 - }) - expect(acquire).toHaveBeenCalledOnce() - expect(dispatch).toHaveBeenCalledOnce() - expect(hostErrors).toContainEqual(expect.objectContaining({ message: 'journal failed' })) - const history = await host.history({ sessionId: SESSION, direction: 'tail' }) - expect(history.ok && history.page.submissions[0]?.dispatchState).toBe('unknown') - // A send whose delivery outcome is unknown IS work in progress, so the reassuring outcome is - // written — its failure fact beside it, and never the old bare `Provider exited: ` row. - const statuses = history.ok - ? history.page.items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) - : [] - expect(statuses).toEqual([UNEXPECTED_PROVIDER_EXIT_OUTCOME]) - expect(statuses.some((text) => text.startsWith('Provider exited'))).toBe(false) - - dispatch.mockResolvedValueOnce({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-next', ordinal: 1 } - }) - const body = hostTestMessage('a distinct next message after failed-barrier recovery') - const nextEnvelope = envelope('agentSession.send', { body }) - await expect(host.send(CALLER, { envelope: nextEnvelope, body })).resolves.toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await vi.waitFor(async () => - expect(await submissionState(nextEnvelope.clientOperationId)).toBe('accepted') - ) - expect(dispatch).toHaveBeenCalledTimes(2) - }) - - it('releases the lease when the exit settlement cannot be written, and the next send settles the turn it left', async () => { - await attach() - emitTurnLifecycle('running', 1) - await host.flushStreamedEvents(SESSION) - const runtimeState = ( - host as unknown as { - runtimeState: { lifecycleBarrier: () => Promise<{ ok: false; error: Error }> } - } - ).runtimeState - vi.spyOn(runtimeState, 'lifecycleBarrier').mockResolvedValueOnce({ - ok: false, - error: new Error('journal failed') - }) - const session = ( - host as unknown as { - sessions: Map< - string, - { journal: { appendLifecycleBatch: (...args: never[]) => Promise } } - > - } - ).sessions.get(SESSION) - expect(session).toBeDefined() - // The conversation's one handle refuses every write of the exit's settlement. - const refusing = vi - .spyOn(session!.journal, 'appendLifecycleBatch') - .mockRejectedValue(new Error('settlement still unavailable')) - const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - - await host.handleAdapterEvent({ - type: 'ended', - sessionId: SESSION, - reason: 'provider exited', - cause: 'unexpected-exit', - fence: exitedFence, - acquisitionGeneration: 'generation-1' - }) - - const released = store.getRecord(SESSION)?.lease - expect(released).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - ownerProcess: null, - runtimeFence: exitedFence + 1, - deathEvidence: { kind: 'exit-observed', detail: 'provider exited', observedAt: NOW } - }) - // The retry recording the exit queues is refused too; once the journal writes again, the next - // acquire re-derives it. - await host.collaboratorsForTests().serialize(SESSION, async () => {}) - refusing.mockRestore() - - dispatch.mockResolvedValueOnce({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-next', ordinal: 1 } - }) - const body = hostTestMessage('sent after a settlement that never landed') - const sentEnvelope = envelope('agentSession.send', { body }) - await expect(host.send(CALLER, { envelope: sentEnvelope, body })).resolves.toMatchObject({ - ok: true, - value: { submission: { dispatchState: 'pending' } } - }) - await vi.waitFor(async () => - expect(await submissionState(sentEnvelope.clientOperationId)).toBe('accepted') - ) - expect(acquire).toHaveBeenCalledTimes(2) - // The new child's acquire settled the turn from the release's evidence: ended at the exit's - // receipt. The evidence is Orca's log text, so the row says only that the provider stopped. - const history = await host.history({ sessionId: SESSION, direction: 'tail' }) - const items = history.ok ? history.page.items : [] - expect(items.map((item) => readAgentJournalTurn(item.body)).filter(Boolean)).toContainEqual( - expect.objectContaining({ turnId: 'turn-1', state: 'interrupted', completedAt: NOW }) - ) - const statuses = items.flatMap((item) => (item.body.kind === 'status' ? [item.body] : [])) - expect(statuses).toContainEqual({ - kind: 'status', - text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, - failure: { kind: 'providerExited' }, - tone: 'error' - }) - expect(statuses.map((status) => status.text).join('\n')).not.toContain('provider exited') - }) -}) - -describe('a quit after a close whose drain failed', () => { - // The close's own wind-down hands the lease back past a failed drain, so quit finds nothing owed. - it('has nothing of that child left to finish', async () => { - await attach() - await sendPending('pending across a failed drain') - const settled = captureSettledSubmissions() - failEvictionDrain() - - await expect(host.close(SESSION, 'evict')).resolves.toBeUndefined() - expect(host['sessions'].get(SESSION)?.child ?? null).toBeNull() - - await host.flushAllStreamedEvents() - - expect(closeSession).toHaveBeenCalledOnce() - expect(host.hasSession(SESSION)).toBe(false) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - ownerProcess: null - }) - expect(hasUnansweredStructuredAgentSessionDispatch(settled.value)).toBe(false) - }) -}) diff --git a/src/main/native-chat/structured-chat-naming-command-first.test.ts b/src/main/native-chat/structured-chat-naming-command-first.test.ts deleted file mode 100644 index 60149f20d214..000000000000 --- a/src/main/native-chat/structured-chat-naming-command-first.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' -import { - claudeProviderHandle, - codexProviderHandle -} from '../../shared/agent-session-provider-handle-encoding' -import { createStructuredChatNamingHandler } from './structured-chat-naming' -import { firstStructuredChatNamingPrompt } from '../../shared/structured-chat-naming-eligibility' -import type { StructuredAgentSessionAdapter } from './agent-session-wire/structured-agent-session-adapter' -import { - hostTestState, - envelope -} from './agent-session-wire/structured-agent-session-host-test-harness' -import { - hostTestAttachParams, - hostTestMessage, - HOST_TEST_SESSION, - HOST_TEST_THREAD -} from './agent-session-wire/structured-agent-session-host-test-data' - -describe.each(['claude', 'codex'] as const)('%s command-first naming', (provider) => { - it.each(['Please repair login', ''])( - 'keeps the first real input eligible after compact: %j', - async (input) => { - const { host, store, acquire, dispatch, log } = hostTestState() - const compact = vi.fn>(async () => ({ - state: 'accepted', - providerIdentity: null - })) - host.deps.adapter.compact = compact - const handle = - provider === 'claude' - ? claudeProviderHandle(HOST_TEST_THREAD, null) - : codexProviderHandle(HOST_TEST_THREAD) - acquire.mockImplementation(async ({ fence, spawnToken }) => ({ - acquisitionGeneration: globalThis.crypto.randomUUID(), - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle, - origin: 'created', - mintedAtFence: fence, - observedAt: 100 - } - })) - dispatch.mockResolvedValue({ state: 'admitted' }) - const readFirstPrompt = vi.fn(async (sessionId: string, startedAt: number) => - firstStructuredChatNamingPrompt(await host.journalSnapshot(sessionId), startedAt) - ) - const generate = vi.fn(async () => 'Saved first input') - const naming = createStructuredChatNamingHandler({ - getStore: () => store, - getSettings: () => ({}), - workerTaskTitle: () => null, - readFirstPrompt, - generate, - onNamed: vi.fn(), - logger: log.logger - }) - const observer = vi.fn(naming) - host.deps.onSessionStatusChanged = observer - expect( - await host.attach( - { callerKey: 'client-1' }, - hostTestAttachParams(null, { - provider, - agent: provider, - providerHandle: - provider === 'claude' - ? { kind: 'claude', sessionId: HOST_TEST_THREAD, leafUuid: null } - : { kind: 'codex', threadId: HOST_TEST_THREAD }, - accountHome: { - variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', - path: '/isolated/account' - } - }) - ) - ).toMatchObject({ ok: true }) - expect( - await host.conversationCommand( - { callerKey: 'client-1' }, - { - command: 'compact', - envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) - } - ) - ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(1)) - expect( - observer.mock.calls.some( - ([summary, options]) => - summary.status === 'working' && options.firstInputSubmissionKey === null - ) - ).toBe(true) - const events = acquire.mock.calls.at(-1)?.[0].events - const command = compact.mock.calls[0]?.[0].command - if (!events || !command) { - throw new Error('Missing actual compact turn') - } - for (let index = 0; index < 12; index++) { - events.publish() - } - await host.flushStreamedEvents(HOST_TEST_SESSION) - expect(readFirstPrompt).not.toHaveBeenCalled() - events.appendLifecycleBatch?.( - 'compact-finished', - [ - { - kind: 'item', - identity: command.identity, - body: { - ...command.running, - state: 'completed', - outcome: 'success', - completedAt: Date.now() - }, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ], - { lifecycle: true } - ) - await host.flushStreamedEvents(HOST_TEST_SESSION) - const body = hostTestMessage(input) - expect( - await host.send( - { callerKey: 'client-1' }, - { body, envelope: envelope('agentSession.send', { body }) } - ) - ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(readFirstPrompt).toHaveBeenCalledTimes(1)) - if (input) { - await vi.waitFor(() => - expect(store.getRecord(HOST_TEST_SESSION)?.conversationName).toBe('Saved first input') - ) - expect(generate).toHaveBeenCalledExactlyOnceWith(expect.anything(), input) - } else { - expect(generate).not.toHaveBeenCalled() - } - for (let index = 0; index < 12; index++) { - events.publish() - } - const later = hostTestMessage('Later input must not rename') - await host.send( - { callerKey: 'client-1' }, - { body: later, envelope: envelope('agentSession.send', { body: later }) } - ) - await host.flushStreamedEvents(HOST_TEST_SESSION) - expect(readFirstPrompt).toHaveBeenCalledTimes(1) - expect(generate).toHaveBeenCalledTimes(input ? 1 : 0) - } - ) -}) diff --git a/src/main/native-chat/structured-chat-naming-status-hook.test.ts b/src/main/native-chat/structured-chat-naming-status-hook.test.ts deleted file mode 100644 index a58752f7699f..000000000000 --- a/src/main/native-chat/structured-chat-naming-status-hook.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' -import type { StructuredChatNamingDeps } from './structured-chat-naming' -import { createStructuredChatNamingHandler } from './structured-chat-naming' -import { firstStructuredChatNamingPrompt } from '../../shared/structured-chat-naming-eligibility' -import { - claudeProviderHandle, - codexProviderHandle -} from '../../shared/agent-session-provider-handle-encoding' -import { - hostTestState, - envelope -} from './agent-session-wire/structured-agent-session-host-test-harness' -import { - hostTestAttachParams, - hostTestMessage, - HOST_TEST_SESSION, - HOST_TEST_THREAD -} from './agent-session-wire/structured-agent-session-host-test-data' - -describe.each(['claude', 'codex'] as const)('%s first-message naming status hook', (provider) => { - it.each([ - { outcome: 'accepted', completesDuringRead: false }, - { outcome: 'admitted', completesDuringRead: false }, - { outcome: 'accepted', completesDuringRead: true }, - { outcome: 'admitted', completesDuringRead: true } - ] as const)( - 'names a live $outcome send (first turn completes during journal read: $completesDuringRead)', - async ({ outcome, completesDuringRead }) => { - const { host, store, acquire, dispatch, log } = hostTestState() - const handle = - provider === 'claude' - ? claudeProviderHandle(HOST_TEST_THREAD, null) - : codexProviderHandle(HOST_TEST_THREAD) - acquire.mockImplementation(async ({ fence }) => ({ - acquisitionGeneration: globalThis.crypto.randomUUID(), - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(HOST_TEST_SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle, - origin: 'created', - mintedAtFence: fence, - observedAt: 100 - } - })) - dispatch.mockImplementation(async () => - outcome === 'admitted' - ? { state: 'admitted' } - : { - state: 'accepted', - providerIdentity: - provider === 'claude' - ? { provider: 'claude', sessionId: HOST_TEST_THREAD, uuid: 'turn-1' } - : { provider: 'codex', threadId: HOST_TEST_THREAD, turnId: 'turn-1', ordinal: 1 } - } - ) - let finish: (value: string | null) => void = () => { - throw new Error('Not initialized') - } - const generation = new Promise((resolve) => { - finish = resolve - }) - const journalRead = Promise.withResolvers() - const readFirstPrompt = vi.fn(async (sessionId: string, hostStartedAt: number) => { - await journalRead.promise - return firstStructuredChatNamingPrompt(await host.journalSnapshot(sessionId), hostStartedAt) - }) - const generate = vi.fn(() => generation) - const deps: StructuredChatNamingDeps = { - getStore: () => store, - getSettings: () => ({}), - workerTaskTitle: () => null, - readFirstPrompt, - generate, - onNamed: vi.fn(), - logger: log.logger - } - const naming = createStructuredChatNamingHandler(deps) - const status = vi.fn(naming) - host.deps.onSessionStatusChanged = status - const attached = await host.attach( - { callerKey: 'client-1' }, - hostTestAttachParams(null, { - provider, - agent: provider, - providerHandle: - provider === 'claude' - ? { kind: 'claude', sessionId: HOST_TEST_THREAD, leafUuid: null } - : { kind: 'codex', threadId: HOST_TEST_THREAD }, - accountHome: { - variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', - path: '/isolated/account' - } - }) - ) - expect(attached).toMatchObject({ ok: true }) - const body = hostTestMessage('Please repair the login flow') - const sent = await host.send( - { callerKey: 'client-1' }, - { - envelope: envelope('agentSession.send', { body }), - body - } - ) - expect(sent).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(readFirstPrompt).toHaveBeenCalledTimes(1)) - expect(generate).not.toHaveBeenCalled() - if (completesDuringRead) { - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) - const events = acquire.mock.calls.at(-1)?.[0].events - const first = (await host.journalSnapshot(HOST_TEST_SESSION)).items.find( - (item) => item.body.kind === 'message' && item.body.role === 'user' - ) - if (!events || !first) { - throw new Error('Missing spawnd first turn') - } - events.appendItem( - provider === 'claude' - ? { provider: 'claude', sessionId: HOST_TEST_THREAD, uuid: 'turn-1' } - : { provider: 'codex', threadId: HOST_TEST_THREAD, turnId: 'turn-1', ordinal: 1 }, - body, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - events.appendItem( - provider === 'claude' - ? { provider: 'claude', sessionId: HOST_TEST_THREAD, uuid: 'turn-completion' } - : { provider: 'codex', threadId: HOST_TEST_THREAD, turnId: 'turn-1', ordinal: 2 }, - { kind: 'turn', turnId: 'turn-1', userItemId: first.itemId, state: 'completed' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - events.publish() - await host.flushStreamedEvents(HOST_TEST_SESSION) - await vi.waitFor(() => - expect(status.mock.calls.at(-1)?.[0]).toMatchObject({ status: 'idle' }) - ) - expect(generate).not.toHaveBeenCalled() - expect(store.getRecord(HOST_TEST_SESSION)?.conversationName).toBeUndefined() - } - journalRead.resolve() - await vi.waitFor(() => expect(generate).toHaveBeenCalledTimes(1)) - expect( - status.mock.calls.some( - ([summary, options]) => - summary.agent === provider && summary.status === 'working' && !options.replay - ) - ).toBe(true) - expect(store.getRecord(HOST_TEST_SESSION)?.conversationName).toBeUndefined() - finish('auth/login') - await vi.waitFor(() => - expect(store.getRecord(HOST_TEST_SESSION)?.conversationName).toBe('auth/login') - ) - } - ) -}) diff --git a/src/main/pi/rpc-configured-default.test.ts b/src/main/pi/rpc-configured-default.test.ts deleted file mode 100644 index 9283d78ceb71..000000000000 --- a/src/main/pi/rpc-configured-default.test.ts +++ /dev/null @@ -1,423 +0,0 @@ -// Pi's `--list-models` names no model it runs by default, so a new Pi chat with no saved pick showed -// "Model" and then whatever its session started on. Through the real Pi probe, adapter and catalog -// host, a chat started with no pick teaches the host the account's default for the next new chat. - -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionModelCatalogResult } from '../../shared/agent-session-wire' -import { structuredAgentSessionSeedCatalog } from '../../shared/structured-agent-session-seed-catalog' -import { - applyStructuredAgentSessionModelCatalog, - createStructuredAgentSessionOptionState, - structuredAgentSessionOptionSnapshot -} from '../../shared/structured-agent-session-options' -import { createAgentModelCatalogService } from '../native-chat/agent-model-catalog/agent-model-catalog-service' -import { AgentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import { - agentReadsProjectModelConfig, - workspaceMayOverrideDefaultModel -} from '../native-chat/agent-model-catalog/agent-project-model-override' -import { - closeProviderTimelineRigs, - openProviderTimelineRig -} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' -import { agentModelCatalogFingerprint } from '../native-chat/agent-model-catalog/agent-model-catalog-fingerprint' -import { createPiModelCatalogProbe, piModelCatalogFromListing } from './rpc-model-catalog-probe' -import type { PiRpcConnection } from './rpc-session' -import { PiRpcSessionAdapter } from './rpc-session-adapter' - -const PI_HOME = { variable: 'PI_CODING_AGENT_DIR', path: '/homes/pi' } -// What `pi --list-models` prints: the first row is not the model Pi starts on. -const LISTING = [ - 'provider model context max-out thinking images', - 'anthropic claude-sonnet-4 200K 64K yes yes', - 'openai gpt-6 400K 128K yes yes' -].join('\n') -const AVAILABLE = [ - { provider: 'anthropic', id: 'claude-sonnet-4', name: 'Claude Sonnet 4', reasoning: true }, - // Its thinking map adds xhigh, which `--list-models` never shows. - { - provider: 'openai', - id: 'gpt-6', - name: 'GPT-6', - reasoning: true, - thinkingLevelMap: { xhigh: 'xhigh' } - } -] -const RUNS = AVAILABLE[1]! - -let root: string -const adapters: PiRpcSessionAdapter[] = [] -beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orca-pi-configured-default-')) -}) -afterEach(async () => { - for (const adapter of adapters.splice(0)) { - await adapter.closeAll() - } - rmSync(root, { recursive: true, force: true }) - await closeProviderTimelineRigs() -}) - -function workspace(name: string, files: Record = {}): string { - const dir = join(root, name) - mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, '.git'), 'gitdir: /elsewhere') - for (const [file, text] of Object.entries(files)) { - mkdirSync(join(dir, file, '..'), { recursive: true }) - writeFileSync(join(dir, file), text) - } - return dir -} - -type PiChild = { connection: PiRpcConnection; switchTo: (modelId: string) => void } - -/** A Pi child that starts on `startsOn` at `thinking`; a signed-out one lists no model. `switchTo` - * is a switch Orca didn't send, as an extension's `setModel` makes. */ -function piChild(available: readonly object[], thinking: string, startsOn: string): PiChild { - let model: object | null = AVAILABLE.find((entry) => entry.id === startsOn) ?? RUNS - const switchTo = (modelId: string): void => { - model = AVAILABLE.find((entry) => entry.id === modelId) ?? model - } - const connection: PiRpcConnection = { - pid: 4123, - closed: false, - rootVerdict: 'live', - processless: false, - lastCloseResult: null, - request: async (command: string, params?: Record) => { - if (command === 'get_state') { - return { - sessionFile: '/homes/pi/sessions/one.jsonl', - isStreaming: false, - isCompacting: false, - model, - thinkingLevel: thinking - } - } - if (command === 'get_available_models') { - return { models: available } - } - if (command === 'set_model') { - switchTo(String(params?.modelId)) - } - return command === 'get_commands' ? { commands: [] } : {} - }, - send: async () => {}, - close: async () => ({ root: 'exited', tree: 'exited' }), - pauseReading: () => {}, - resumeReading: () => {}, - onExit: () => {} - } - return { connection, switchTo } -} - -type ChatOptions = { - saved?: Record - resumed?: boolean - forked?: boolean - /** A new Pi session standing in for one whose saved file was gone. */ - replacement?: boolean - available?: object[] - thinking?: string - startsOn?: string - /** What happens between the chat's start and the pane's options read. */ - afterStart?: (chat: { - child: PiChild - adapter: PiRpcSessionAdapter - sessionId: string - }) => Promise -} - -/** A host with Pi's real probe and catalog service, and one Pi chat in `workspacePath`. */ -async function piHost() { - const records = new Map() - const clock = { now: 1_000 } - const store = new AgentModelCatalogStore({ now: () => clock.now }) - const catalog = createAgentModelCatalogService({ - store, - getRecord: (sessionId) => records.get(sessionId), - drivesRecord: () => true, - resolveAccountHome: async () => PI_HOME, - recordWorkspacePath: async (row) => row.launchDirectory ?? null, - agentReadsProjectModelConfig, - workspaceMayOverrideDefaultModel, - probes: { - pi: createPiModelCatalogProbe({ - resolveEnvironment: async () => ({ PATH: '/usr/bin' }), - resolveCommand: () => '/opt/pi/bin/pi', - probeVersion: async () => true, - homePath: '/home/user', - runListing: async () => LISTING - }) - } - }) - - let chats = 0 - /** A Pi chat in `workspacePath`: the host saves what its start listed, then the pane reads its - * options once, and the host saves each read's listing too. */ - async function chat(workspacePath: string, opts: ChatOptions = {}) { - const sessionId = `pi-${++chats}` - let child: PiChild | undefined - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the catalog service reads only these fields. - records.set(sessionId, { - sessionId, - provider: 'pi', - accountHome: PI_HOME, - launchDirectory: workspacePath, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'ws-1', - workspaceKind: 'git-worktree' - } - } as AgentSessionRecord) - const rig = await openProviderTimelineRig({ agent: 'pi', sessionId }) - const adapter = new PiRpcSessionAdapter({ - resolveLaunch: async () => ({ - command: '/opt/pi/bin/pi', - cwd: workspacePath, - fullAccess: true, - previous: null, - ...(opts.resumed ? { sessionFile: '/homes/pi/sessions/one.jsonl' } : {}), - ...(opts.forked ? { forkFile: '/homes/pi/sessions/elsewhere.jsonl' } : {}), - ...(opts.replacement ? { replacement: 'unsaved' as const } : {}) - }), - readProcessStartTime: async () => 1, - openConnection: () => { - child = piChild(opts.available ?? AVAILABLE, opts.thinking ?? 'high', opts.startsOn ?? '') - return child.connection - }, - onLifecycle: vi.fn(), - onSettled: vi.fn(), - onIdle: vi.fn(), - logger: { warn: vi.fn(), error: vi.fn() } - }) - adapters.push(adapter) - const spawned = await adapter.acquire({ - identity: { - sessionId, - workspaceId: 'ws-1', - hostId: 'local', - agent: 'pi', - providerHandle: null - }, - fence: 1, - spawnToken: 'spawn-token', - options: opts.saved ?? {}, - events: rig.eventSink - }) - const opened = await adapter - .open({ - sessionId, - fence: 1, - acquisitionGeneration: spawned.acquisitionGeneration, - signal: new AbortController().signal - }) - .catch(async (error: unknown) => { - await adapter.closeSession(sessionId) - throw error - }) - if (!opened.catalogListing) { - throw new Error('a Pi start hands the host its listing') - } - catalog.recordLiveListing(sessionId, opened.catalogListing) - if (opts.afterStart && child) { - await opts.afterStart({ child, adapter, sessionId }) - } - const read = async (): Promise => { - const { catalogListing } = await adapter.readOptions({ sessionId, fence: 1 }) - if (catalogListing) { - catalog.recordLiveListing(sessionId, catalogListing) - } - } - await read() - return { listing: opened.catalogListing, read } - } - return { catalog, chat, store, clock } -} - -/** What a new Pi chat's composer paints first from a host answer. */ -function firstFrame(answer: AgentSessionModelCatalogResult) { - const seed = structuredAgentSessionSeedCatalog('pi') - const state = applyStructuredAgentSessionModelCatalog( - createStructuredAgentSessionOptionState('pi', seed), - seed, - answer, - { newLaunch: true } - ) - const snapshot = structuredAgentSessionOptionSnapshot(state) - const select = (id: string) => { - const entry = snapshot.find((descriptor) => descriptor.id === id) - return entry?.kind.type === 'select' ? (entry.kind.currentValue ?? null) : null - } - return { model: select('model'), effort: select('effort') } -} - -const NOTHING = { model: null, effort: null } - -describe('Pi’s default comes from what a chat with no pick runs', () => { - it('names no model from the listing, then the model and thinking level a no-pick chat ran', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - const listed = await catalog.read({ agent: 'pi', workspacePath: clean, waitForListing: true }) - expect(listed).toMatchObject({ origin: 'probe', listingNamesConfiguredModel: false }) - expect(firstFrame(listed)).toEqual(NOTHING) - - await chat(clean) - // The next new chat, in this workspace or another whose config picks nothing. - for (const path of [clean, workspace('other')]) { - const answer = await catalog.read({ agent: 'pi', workspacePath: path }) - expect(answer).toMatchObject({ listingNamesConfiguredModel: true }) - expect(answer).not.toHaveProperty('defaultHoldsInEveryWorkspace') - expect(firstFrame(answer)).toEqual({ model: 'openai/gpt-6', effort: 'high' }) - } - }) - - it('keeps the placeholder in a workspace whose Pi settings pick the model', async () => { - const { catalog, chat } = await piHost() - await chat(workspace('clean')) - const configured = workspace('configured', { - '.pi/settings.json': '{"defaultProvider":"anthropic","defaultModel":"claude-sonnet-4"}' - }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: configured }))).toEqual( - NOTHING - ) - }) - - it('learns nothing from a chat whose workspace picks its model', async () => { - const { catalog, chat } = await piHost() - await catalog.read({ agent: 'pi', waitForListing: true }) - await chat(workspace('configured', { '.pi/extensions/pick.ts': 'export default () => {}\n' })) - expect( - firstFrame(await catalog.read({ agent: 'pi', workspacePath: workspace('clean') })) - ).toEqual(NOTHING) - }) - - it('learns its configured default despite a saved next-message pick, but not from a resumed conversation', async () => { - const { catalog, chat } = await piHost() - await catalog.read({ agent: 'pi', waitForListing: true }) - const clean = workspace('clean') - expect( - (await chat(clean, { saved: { model: 'openai/gpt-6' } })).listing.configuredDefault - ).toEqual({ - modelId: 'openai/gpt-6', - effort: 'high' - }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'openai/gpt-6', - effort: 'high' - }) - const resumed = await piHost() - await resumed.catalog.read({ agent: 'pi', waitForListing: true }) - expect( - (await resumed.chat(workspace('resumed'), { resumed: true })).listing - ).not.toHaveProperty('configuredDefault') - }) - - it('saves no default from a signed-out Pi, which lists no model', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - await catalog.read({ agent: 'pi', waitForListing: true }) - await expect(chat(clean, { available: [] })).rejects.toMatchObject({ reason: 'notSignedIn' }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual(NOTHING) - - // Nor does it forget one a signed-in chat taught. - await chat(clean) - await expect(chat(clean, { available: [] })).rejects.toMatchObject({ reason: 'notSignedIn' }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'openai/gpt-6', - effort: 'high' - }) - }) - - it('teaches the model the chat started on, not one an extension switched to later', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - await catalog.read({ agent: 'pi', waitForListing: true }) - await chat(clean, { afterStart: async ({ child }) => child.switchTo('claude-sonnet-4') }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'openai/gpt-6', - effort: 'high' - }) - }) - - it('teaches the start model after a pick made in the chat, never the pick', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - await catalog.read({ agent: 'pi', waitForListing: true }) - await chat(clean, { - afterStart: async ({ adapter, sessionId }) => { - await adapter.setOption({ - sessionId, - fence: 1, - key: 'model', - value: 'anthropic/claude-sonnet-4' - }) - } - }) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'openai/gpt-6', - effort: 'high' - }) - }) - - it('learns nothing from a chat forked from a conversation in another folder', async () => { - const { catalog, chat } = await piHost() - await catalog.read({ agent: 'pi', waitForListing: true }) - const clean = workspace('clean') - expect((await chat(clean, { forked: true })).listing).not.toHaveProperty('configuredDefault') - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual(NOTHING) - }) - - it('keeps a learned thinking level only the chat’s menu offers when a later listing is newer', async () => { - const { catalog, chat, store, clock } = await piHost() - const clean = workspace('clean') - await chat(clean, { thinking: 'xhigh' }) - // A background re-list after the chat: `--list-models` offers no xhigh. - clock.now = 2_000 - store.recordSuccess( - agentModelCatalogFingerprint({ agent: 'pi', accountHome: PI_HOME, wslDistro: null }), - 'pi', - { - models: piModelCatalogFromListing(LISTING), - origin: 'probe' - }, - 'discovery' - ) - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'openai/gpt-6', - effort: 'xhigh' - }) - }) - - it('a chat’s later reads teach nothing: a newer chat’s default stands', async () => { - const { catalog, chat } = await piHost() - const clean = workspace('clean') - await catalog.read({ agent: 'pi', waitForListing: true }) - const first = await chat(clean) - // Pi's own default changed since (a `/model` in a terminal): the next new chat starts on it. - await chat(clean, { startsOn: 'claude-sonnet-4' }) - // The first chat keeps running and reads its options again, as every turn does. - await first.read() - expect(firstFrame(await catalog.read({ agent: 'pi', workspacePath: clean }))).toEqual({ - model: 'anthropic/claude-sonnet-4', - effort: 'high' - }) - }) - - it('learns the configured model and thinking level despite a saved next-message thinking pick', async () => { - const { chat } = await piHost() - const { listing } = await chat(workspace('clean'), { saved: { effort: 'low' } }) - expect(listing.configuredDefault).toEqual({ modelId: 'openai/gpt-6', effort: 'high' }) - }) - - it('learns from a new Pi session standing in for a conversation whose file was gone', async () => { - const { chat } = await piHost() - const { listing } = await chat(workspace('clean'), { replacement: true }) - expect(listing.configuredDefault).toEqual({ modelId: 'openai/gpt-6', effort: 'high' }) - }) -}) diff --git a/src/main/runtime/agent-session-record-options.test.ts b/src/main/runtime/agent-session-record-options.test.ts deleted file mode 100644 index 7c6559af9f6b..000000000000 --- a/src/main/runtime/agent-session-record-options.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -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 { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' -import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' -import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-options' -let directory: string - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-options-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -it('makes the process the owner before its provider answers with a handle, and records it after', async () => { - const store = await openTestAgentSessionRecordStore(directory) - const reserved = await store.reserveOwner({ - sessionId: SESSION, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, - expectedFence: null, - spawnToken: 'spawn-options', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'indeterminate', reason: 'new session' }, - operation: { - callerKey: 'client-1', - operationId: '1800000000000-00000000000000000000000000000000', - fingerprint: 'options-create' - }, - now: NOW - }) - const fence = reserved.record.lease.runtimeFence - await store.commitProcessIdentity({ - sessionId: SESSION, - fence, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW - 1, - spawnToken: 'spawn-options' - }, - now: NOW - }) - await store.proveOwner({ sessionId: SESSION, fence, now: NOW }) - await store.replaceSessionOptions({ - sessionId: SESSION, - fence, - options: { model: 'gpt-tui' }, - now: NOW - }) - // A crash here leaves a record the next run reads back: live, its handle still owed. - const starting = (await openTestAgentSessionRecordStore(directory)).getRecord(SESSION) - expect(starting?.lease).toMatchObject({ claimStatus: 'live', provenHandleLinkId: null }) - expect(starting?.providerHandleChain).toEqual([]) - expect(starting?.options).toEqual({ model: 'gpt-tui' }) - - await store.transitionHandoff(SESSION, (record) => - recordAgentSessionProviderHandle({ - record, - fence, - link: { - linkId: 'codex-options-1', - handle: codexProviderHandle('thread-options'), - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - }, - now: NOW - }) - ) - const started = (await openTestAgentSessionRecordStore(directory)).getRecord(SESSION) - expect(started?.lease.provenHandleLinkId).toBe('codex-options-1') - expect(started?.providerHandleChain.map((link) => link.linkId)).toEqual(['codex-options-1']) -}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts deleted file mode 100644 index 7de7f2c3ce4a..000000000000 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import '../unused-default-rpc-methods.test-fixture' -// `agentSession.hold` / `release` are kept answering for clients that still send them, and do -// nothing else: a view never starts or keeps an agent. -// -// Run against the REAL subscription registry rather than a stub, because the claim includes that a -// connection closing runs nothing hold-related — a stubbed registry could not show that. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' -import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams, - resetHostTestOperationIds -} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import type { AgentSessionRecordStore } from '../../agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' -import { OrcaRuntimeService } from '../../orca-runtime' -import type { RpcResponse } from '../core' -import { RpcDispatcher } from '../dispatcher' -import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' -import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' -import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' -import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' -import { codexProviderHandle } from '../../../../shared/agent-session-provider-handle-encoding' -import { NO_STRUCTURED_AGENTS } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' -import { getDefaultPersistedState } from '../../../../shared/constants' -import { RuntimeClientSettingsController } from '../../runtime-client-settings' - -const CONNECTION = 'connection-1' -const CLIENT = { - clientId: 'device-1', - clientKind: 'runtime' as const, - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], - connectionId: CONNECTION -} - -let root: string -let store: AgentSessionRecordStore -let host: StructuredAgentSessionHost -let runtime: OrcaRuntimeService -let dispatcher: RpcDispatcher -let closeSession: Mock> -let acquire: Mock -let requests = 0 -let structuredNativeChatEnabled = true - -async function call(method: string, params: unknown): Promise { - const replies: RpcResponse[] = [] - requests += 1 - await dispatcher.dispatchStreaming( - { id: `request-${requests}`, authToken: 'token', method, params }, - (raw) => replies.push(JSON.parse(raw) as RpcResponse), - CLIENT - ) - return replies[0] as RpcResponse -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-hold-wire-')) - resetHostTestOperationIds() - requests = 0 - structuredNativeChatEnabled = true - closeSession = vi.fn(async () => true) - acquire = vi.fn(async ({ fence, spawnToken }) => ({ - acquisitionGeneration: globalThis.crypto.randomUUID(), - process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: store.getRecord(SESSION)?.providerHandleChain.length - ? ('resumed' as const) - : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - })) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: { - open: async () => ({ - reportedOptions: { model: '' } - }), - acquire, - // A failed acquisition is proven gone, as the real adapters prove it; without this an - // acquire that throws leaves an unverifiable owner nothing may replace. - releaseAcquisition: vi.fn(async () => true), - closeSession, - dispatch: async () => ({ - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { - surface: 'rejection' - }) - }), - cancelTurn: async () => ({ cancelled: false }), - answerPrompt: async () => undefined, - setOption: async () => undefined - }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - setStructuredAgentSessionHost(host) - runtime = new OrcaRuntimeService() - // The structured surface is settings-gated for every caller, in-process included. - const clientSettings = new RuntimeClientSettingsController({ - getSettings: () => ({ - ...getDefaultPersistedState(root).settings, - experimentalNativeChat: structuredNativeChatEnabled - }), - updateSettings: () => undefined - }).get() - vi.spyOn(runtime, 'getClientSettings').mockImplementation(() => clientSettings) - dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS }) - expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ - ok: true - }) -}) - -afterEach(async () => { - setStructuredAgentSessionHost(null) - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -describe('the hold surface, for clients that still call it', () => { - it('answers a hold without starting an agent or registering a cleanup', async () => { - await host.close(SESSION, 'evict') - expect(host.hasSession(SESSION)).toBe(false) - const registered = vi.spyOn(runtime, 'registerOwnedSubscriptionCleanup') - const acquiresBefore = acquire.mock.calls.length - - expect( - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { held: true } }) - - expect(acquire.mock.calls.length).toBe(acquiresBefore) - expect(registered).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('answers a release without stopping anything, and a connection close runs nothing', async () => { - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - - expect( - await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { released: true } }) - runtime.cleanupSubscriptionsForConnection(CONNECTION) - await new Promise((resolve) => setTimeout(resolve, 20)) - - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - }) - - it('answers a hold and a release whatever the host structured-chat setting says', async () => { - structuredNativeChatEnabled = false - - expect( - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { held: true } }) - expect( - await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { released: true } }) - expect(closeSession).not.toHaveBeenCalled() - }) - - it('answers a hold even when no agent could be started', async () => { - await host.close(SESSION, 'evict') - acquire.mockRejectedValue(new Error('provider unavailable')) - const acquiresBefore = acquire.mock.calls.length - - expect( - await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) - ).toMatchObject({ ok: true, result: { held: true } }) - expect(acquire.mock.calls.length).toBe(acquiresBefore) - }) -}) - -describe('a stream', () => { - it('reads a closed conversation without starting its agent', async () => { - await host.close(SESSION, 'evict') - expect(host.hasSession(SESSION)).toBe(false) - const acquiresBefore = acquire.mock.calls.length - const frames: unknown[] = [] - - await dispatcher.dispatchStreaming( - { - id: 'request-subscribe', - authToken: 'token', - method: 'agentSession.subscribe', - params: { sessionId: SESSION } - }, - (raw) => frames.push(JSON.parse(raw)), - CLIENT - ) - - expect(frames).toContainEqual( - expect.objectContaining({ result: expect.objectContaining({ type: 'snapshot' }) }) - ) - expect(acquire.mock.calls.length).toBe(acquiresBefore) - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') - }) - - it('keeps nothing alive when its transport dies', async () => { - const transport = new AbortController() - await dispatcher.dispatchStreaming( - { - id: 'desktop-subscription', - authToken: 'token', - method: 'agentSession.subscribe', - params: { sessionId: SESSION } - }, - () => {}, - { - signal: transport.signal, - clientId: 'desktop-renderer', - clientKind: 'runtime', - clientCapabilities: CLIENT.clientCapabilities - } - ) - - transport.abort() - await new Promise((resolve) => setTimeout(resolve, 20)) - - // The idle sweep, not a departing reader, is what stops an agent. - expect(closeSession).not.toHaveBeenCalled() - expect(host.hasSession(SESSION)).toBe(true) - }) -}) diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts deleted file mode 100644 index 341f74d36612..000000000000 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -// Failures the desktop host used to drop: it installed the runtime with no error callback, so -// each of these reached nobody. Every one now lands in the required logger under its own scope. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' -import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' -import type { StructuredAgentSessionHostDeps } from '../native-chat/agent-session-wire/structured-agent-session-host' -import { StructuredAgentSessionHostRuntimeState } from '../native-chat/agent-session-wire/structured-agent-session-host-runtime-state' -import { - StructuredAgentSessionDeliveryLoop, - type StructuredAgentSessionDeliveryLoopDeps -} from '../native-chat/agent-session-wire/structured-agent-session-delivery-loop' -import { - StructuredAgentSessionIdleSweep, - type StructuredAgentSessionIdleSweepDeps -} from '../native-chat/agent-session-wire/structured-agent-session-idle-sweep' -import { - StructuredAgentSessionQueuedMessageDrain, - type QueuedMessageDrainDeps -} from '../native-chat/agent-session-wire/structured-agent-session-queued-messages' -import { - createStructuredAgentSessionLogger, - neverThrowingStructuredAgentSessionLogger -} from '../native-chat/agent-session-wire/structured-agent-session-logger' -import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' -import { StructuredAgentSessionDeliveryOrder } from '../native-chat/agent-session-wire/structured-agent-session-delivery-order' -import { _resetTracerForTests, setActiveSink } from '../observability/tracer' -import { createStructuredAgentSessionDispatchFollowUps } from './structured-agent-session-dispatch-followups' -import { createStructuredAgentSessionLifecycleDelivery } from './structured-agent-session-lifecycle-delivery' -import { - STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED, - ensureStructuredAgentSessionHost, - stopStructuredAgentSessionRuntime -} from './structured-agent-session-runtime' - -const SESSION = 'session-1' - -function rejectingHost(error: Error) { - return { - settleLateDispatch: vi.fn(async () => { - throw error - }), - releaseUnansweredDispatches: vi.fn(async () => { - throw error - }) - } -} - -describe('failures the desktop host used to drop', () => { - it('logs a late dispatch settlement that fails', async () => { - const log = recordingStructuredAgentSessionLogger() - const error = new Error('settlement write failed') - const followUps = createStructuredAgentSessionDispatchFollowUps({ - host: () => rejectingHost(error), - logger: log.logger - }) - - followUps.onDispatchSettledLate({ - sessionId: SESSION, - clientMessageId: 'client-1', - state: 'unknown', - reason: 'provider went idle' - }) - - await vi.waitFor(() => - expect(log.entries).toEqual([ - { - level: 'warn', - message: 'settling a dispatch the provider answered late failed', - fields: { scope: 'late-settlement', sessionId: SESSION, error } - } - ]) - ) - }) - - it('logs releasing the sends an idle provider never answered when that fails', async () => { - const log = recordingStructuredAgentSessionLogger() - const error = new Error('release write failed') - const followUps = createStructuredAgentSessionDispatchFollowUps({ - host: () => rejectingHost(error), - logger: log.logger - }) - - followUps.releaseUnansweredDispatches({ sessionId: SESSION }) - - await vi.waitFor(() => - expect(log.entries).toEqual([ - expect.objectContaining({ - fields: { scope: 'unanswered-dispatch', sessionId: SESSION, error } - }) - ]) - ) - }) - - it('logs a provider report or exit the host could not take', async () => { - const log = recordingStructuredAgentSessionLogger() - const error = new Error('host refused the event') - const lifecycle = createStructuredAgentSessionLifecycleDelivery({ - handle: async () => { - throw error - }, - logger: log.logger, - drainObservedExits: async () => {} - }) - - lifecycle.deliver({ - type: 'options-reported', - sessionId: SESSION, - fence: 1, - acquisitionGeneration: 'generation-1', - reportedOptions: { model: 'gpt-5' } - }) - lifecycle.deliver({ - type: 'ended', - sessionId: SESSION, - reason: 'exited', - cause: 'unexpected-exit', - fence: 1, - acquisitionGeneration: 'generation-1' - }) - await lifecycle.drain() - - expect(log.entries.map((entry) => entry.fields)).toEqual([ - { scope: 'lifecycle-options-reported', sessionId: SESSION, error }, - { scope: 'lifecycle-exit', sessionId: SESSION, error } - ]) - }) - - it('logs provider events the chat journal would not take', async () => { - const log = recordingStructuredAgentSessionLogger() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: minting and binding a sink reads no other host dependency. - const deps = { logger: log.logger } as unknown as StructuredAgentSessionHostDeps - const runtime = new StructuredAgentSessionHostRuntimeState(deps, new Map()) - const sink = runtime.eventSinkFor(SESSION) - const error = new Error('journal append failed') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sink calls only appendItem before it fails. - const journal = { - appendItem: async () => { - throw error - } - } as unknown as AgentSessionJournal - sink.bind({ journal, fence: 1, publish: () => {} }) - - sink.sink.appendItem( - { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await expect(sink.drained()).resolves.toMatchObject({ ok: false }) - - expect(log.entries).toEqual([ - expect.objectContaining({ - level: 'error', - fields: { scope: 'journal-event-sink', sessionId: SESSION, error } - }) - ]) - }) -}) - -describe('host collaborators log with their own scope', () => { - const error = new Error('serialize refused') - const rejectingSerialize = async (): Promise => { - throw error - } - - it('the delivery loop logs a failed delivery, and a failed recording of it', async () => { - const log = recordingStructuredAgentSessionLogger() - const loop = new StructuredAgentSessionDeliveryLoop( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a step that rejects at its first serialize reads only these members. - { - serialize: rejectingSerialize, - sessions: new Map(), - deliveryOrder: new StructuredAgentSessionDeliveryOrder(), - trackStart: (start: Promise) => start, - logger: log.logger - } as unknown as StructuredAgentSessionDeliveryLoopDeps - ) - - loop.wake(SESSION) - - await vi.waitFor(() => - expect(log.entries.map((entry) => entry.fields)).toEqual([ - { scope: 'delivery-loop', sessionId: SESSION, error }, - { scope: 'delivery-loop-fail', sessionId: SESSION, error } - ]) - ) - }) - - it('the idle sweep logs a step that fails', async () => { - const log = recordingStructuredAgentSessionLogger() - const sweep = new StructuredAgentSessionIdleSweep( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a tick whose serialize rejects reads only these members. - { - sessions: new Map([[SESSION, {}]]), - isDisposed: () => false, - serialize: rejectingSerialize, - logger: log.logger - } as unknown as StructuredAgentSessionIdleSweepDeps - ) - - await sweep.tick() - - expect(log.entries.map((entry) => entry.fields)).toEqual([ - { scope: 'idle-sweep', sessionId: SESSION, error } - ]) - }) - - it('the queued-message drain logs a step that fails', async () => { - const log = recordingStructuredAgentSessionLogger() - const journal = { queuedMessages: { settlementOwed: () => true } } - const drain = new StructuredAgentSessionQueuedMessageDrain( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a schedule past the owed-settlement check reads only these members. - { - sessions: new Map([[SESSION, { journal }]]), - serialize: rejectingSerialize, - logger: log.logger - } as unknown as QueuedMessageDrainDeps - ) - - drain.schedule(SESSION) - - await vi.waitFor(() => - expect(log.entries.map((entry) => entry.fields)).toEqual([ - { scope: 'queued-drain', sessionId: SESSION, error } - ]) - ) - }) -}) - -describe('a logger that throws', () => { - it('never throws out of the report, and still prints what failed', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const loggerError = new Error('logger broke') - const logger = neverThrowingStructuredAgentSessionLogger({ - warn: () => { - throw loggerError - }, - error: () => { - throw loggerError - } - }) - const error = new Error('what failed') - - expect(() => logger.warn('a step failed', { scope: 'step', error })).not.toThrow() - expect(() => logger.error('a step failed', { scope: 'step', error })).not.toThrow() - expect(warn).toHaveBeenCalledWith('[agent-session] a step failed', { - scope: 'step', - error, - loggerError - }) - warn.mockRestore() - }) -}) - -describe('the production logger', () => { - afterEach(() => { - _resetTracerForTests() - vi.restoreAllMocks() - }) - - it('writes each entry to the trace file as a failed span named for its scope', () => { - const push = vi.fn() - setActiveSink({ push, flush: () => {}, close: () => {} }) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - - createStructuredAgentSessionLogger().warn('settling a late dispatch failed', { - scope: 'late-settlement', - sessionId: SESSION, - error: new Error('disk full') - }) - - expect(push).toHaveBeenCalledOnce() - expect(push.mock.calls[0]?.[0]).toMatchObject({ - type: 'effect-span', - name: 'agentSession.late-settlement', - attributes: { - level: 'warn', - message: 'settling a late dispatch failed', - sessionId: SESSION - }, - exit: { _tag: 'Failure', cause: expect.stringContaining('Error: disk full') } - }) - }) -}) - -describe('installing the runtime', () => { - let stateDirectory: string | null = null - - afterEach(async () => { - await stopStructuredAgentSessionRuntime() - if (stateDirectory) { - await rm(stateDirectory, { recursive: true, force: true }) - stateDirectory = null - } - }) - - it('refuses without a logger rather than dropping every failure', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-logger-wiring-')) - stateDirectory = root - - await expect( - ensureStructuredAgentSessionHost( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: deliberately missing the logger, as a `@ts-nocheck` caller could drop it. - { - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async () => root, - resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ account: 'managed' }) - } as unknown as Parameters[0] - ) - ).rejects.toThrow(STRUCTURED_AGENT_SESSION_LOGGER_REQUIRED) - }) - - it('hands the host a logger whose throw cannot reach the operation it reports', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-logger-wiring-')) - stateDirectory = root - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const host = await ensureStructuredAgentSessionHost({ - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async () => root, - resolveEnvironment: async () => ({}), - resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ account: 'managed' }), - logger: { - warn: () => { - throw new Error('logger broke') - }, - error: () => { - throw new Error('logger broke') - } - } - }) - - expect(() => host.deps.logger.warn('a step failed', { scope: 'step' })).not.toThrow() - }) -}) From 031a466cce514d3b9ac0c052c60ca74c13a4f510 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:57:47 -0700 Subject: [PATCH 7/9] Keep reliability gate on retained send idempotency coverage --- config/reliability-gates.jsonc | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 112b690d4ff1..567afb2ae657 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-09-11", + "updatedAt": "2026-10-13", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -1478,14 +1478,14 @@ "invariant": "One structured-send operation id causes at most one provider dispatch. Each user Send on phone or desktop is a new action with a fresh id and one request; neither automatically resends an ambiguous action. The host retains the original action's outcome, and a later phone Send remains possible with equal text, after remount, or when phone storage fails. Repeated transmission of the same operation remains protected by host deduplication.", "oracle": "Inject adapter/RPC acknowledgement loss, caller replacement, logical-client close after response, auth recovery with a written request, missing host submissions, legacy pending rows, stale fences, expiry, mobile remount, and phone storage refusal. Assert at most one dispatch for repeated transmission of one operation. For a later phone Send, assert a fresh id and second delivery while the original host entry remains unknown; equal images use current uploaded paths and delivery fields follow current host capabilities.", "commands": [ - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "ORCA_BACKGROUND_LAUNCH=1 pnpm --dir mobile test ../mobile/src/session/mobile-structured-send-new-action.test.ts ../mobile/src/session/use-mobile-structured-agent-session-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx ../mobile/src/session/mobile-structured-send-delivery.test.ts ../mobile/src/session/use-mobile-structured-agent-session-background-tasks.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx ../mobile/src/session/use-mobile-structured-agent-session.test.tsx ../mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx ../mobile/src/session/mobile-native-chat-image-attachment.test.ts ../mobile/src/session/use-mobile-native-chat-image-attachments.test.ts ../mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts ../mobile/src/mobile-web-shell/page-storage-keys.test.ts ../mobile/src/mobile-web-shell/bridge/page-async-storage.test.ts ../mobile/src/mobile-web-shell/use-page-host-snapshot.test.tsx ../mobile/src/mobile-web-shell/bridge-host-init.test.ts ../mobile/src/transport/mobile-relay-rpc-session.test.ts ../mobile/src/transport/rpc-client-delivery-ambiguity.test.ts ../mobile/src/transport/stable-logical-rpc-client.test.ts --maxWorkers=2" ], "testFiles": [ "src/shared/agent-session-operation-ledger.test.ts", "src/shared/structured-agent-session-send-evidence.test.ts", "src/main/runtime/agent-session-operation-admission.test.ts", - "src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts", + "src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts", "src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts", "src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts", "src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts", @@ -1582,13 +1582,13 @@ "summary": "Nineteen focused mobile files passed 241 tests for fresh Send identity, remount, attachment paths, storage refusal, mixed host capabilities, queued cards, and written-request ambiguity." }, { - "date": "2026-10-06", + "date": "2026-10-13", "runner": "local", "platform": "macos", - "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-session-operation-ledger.test.ts src/shared/structured-agent-session-send-evidence.test.ts src/main/runtime/agent-session-operation-admission.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts src/main/runtime/orchestration/structured-pointer-operation-id.test.ts src/renderer/src/components/native-chat/structured-agent-session-message-sender.test.ts src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx src/renderer/src/lib/launch-structured-agent-session.test.ts", "result": "passed", - "durationSeconds": 14, - "summary": "Eleven focused host, shared, renderer, and orchestration files passed 173 tests after the desktop outbox gave way to the in-memory sender, which makes one request per send and never resends." + "durationSeconds": 6.47, + "summary": "Eleven focused host, shared, renderer and orchestration files passed 196 tests, including unknown send outcomes, reused message IDs and host replay without duplicate dispatch. The retained send-idempotency suite replaces the removed branch-by-branch refusal matrix." } ], "runtimeBudget": { From b2cc2fc6103e63c79954a485086c2567ca5013e2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:20:43 -0700 Subject: [PATCH 8/9] Keep capability and Stop contracts through acquisition cleanup --- ...ructured-agent-session-startup-attempt-host.test.ts | 10 ++++------ .../structured-agent-session-stop-receipts.test.ts | 2 ++ .../structured-agent-runtime-registrations-acp.test.ts | 3 +++ ...tructured-chat-owner-status-activation.unit.test.ts | 1 + 4 files changed, 10 insertions(+), 6 deletions(-) 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 01b1d92ec0c8..707d01677ba6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-host.test.ts @@ -87,11 +87,7 @@ async function startHost(): Promise { adapter: { open: ({ acquisitionGeneration }) => new Promise((resolve) => { - openGates.set(acquisitionGeneration!, (model) => - resolve({ - reportedOptions: { model } - }) - ) + openGates.set(acquisitionGeneration!, (model) => resolve({ reportedOptions: { model } })) }), acquire, dispatch, @@ -298,6 +294,8 @@ describe('a start that ends before it proves itself', () => { }) expect((await submission(id))?.handedOverAt).toBeUndefined() expect(closeSession).toHaveBeenCalled() + const rows = (await host.journalSnapshot(SESSION)).items.map(({ body }) => body) + expect(rows).toContainEqual({ kind: 'status', text: 'Cancellation requested.' }) expect(dispatch).not.toHaveBeenCalled() expect(startupAttemptOpen()).toBe(false) }) @@ -481,7 +479,7 @@ describe('a chat after startup permission backfill', () => { await restartWith(publishFirst) await heldBehindStart('hello') expect(acquire.mock.calls.at(-1)![0].permissionMode).toBe(mode) - await host.handleAdapterEvent(startedEvent()) + await proveOpen() await vi.advanceTimersByTimeAsync(50) await host.flushStreamedEvents(SESSION) expect(store.getRecord(SESSION)?.options?.permissionMode).toBe(mode) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-receipts.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-receipts.test.ts index 040a8fc78b34..f806875934f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-receipts.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-receipts.test.ts @@ -137,6 +137,8 @@ it('is saved before it interrupts, and a retry of its id never stops the newer t expect((await rig.submission(behind))?.dispatchState).not.toBe('rejected') expect((await rig.submission(newer))?.dispatchState).not.toBe('rejected') expect(await rig.drafts()).toEqual([]) + expect(await rig.stop()).toMatchObject({ ok: true, replayed: false, value: { cancelled: true } }) + expect(rig.cancelTurn).toHaveBeenCalledTimes(2) }) // Round 1: a late Stop acted on newer work while the next message was handed over. diff --git a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts index 809b99aaeea3..065ff1819ffa 100644 --- a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts +++ b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts @@ -11,6 +11,9 @@ describe('ACP agents in the runtime registrations', () => { expect( STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) ).toEqual(['pi', 'codex', 'claude', 'grok', 'opencode', 'omp']) + expect(structuredAgentRuntimeRegistration('codex')?.definition.capabilities.contextUsage).toBe( + true + ) expect(structuredAgentRuntimeRegistration('grok')?.definition).toMatchObject({ handleTransport: 'acp', accountHomeVariable: 'GROK_HOME', diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index 91e5f6f7c1ac..de0ce6d5d983 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -47,6 +47,7 @@ function openHost(): void { adapter: startsWhenPublished( { acquire: async ({ fence, spawnToken }) => ({ + acquisitionGeneration: `generation-${fence}`, process: { hostId: 'local', pid: 4242, From 76ef74e37e8cbbdd6ade58463026600abc9d3031 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:22:48 -0700 Subject: [PATCH 9/9] Give the adopted ACP fixture its required acquisition generation --- .../structured-agent-session-adopted-import.test.ts | 1 + 1 file changed, 1 insertion(+) 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 7014bbf130ed..859b3055cdb9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -188,6 +188,7 @@ describe('adopting a provider conversation on create', () => { const handle = { transport: 'acp', agent: 'grok', nativeId: THREAD } const sessionAdapter = adapter() vi.mocked(sessionAdapter.acquire).mockImplementation(async ({ fence, spawnToken }) => ({ + acquisitionGeneration: `generation-${fence}`, process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, link: { linkId: 'resumed-link',