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 new file mode 100644 index 00000000000..8867162af68 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -0,0 +1,200 @@ +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 { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +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' + +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 +): 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', + providerHandle: { kind: 'codex', threadId: 'legacy-thread' } + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(params) + }) + } + } +} + +function adapter(input: { + origin: 'created' | 'resumed' + options?: AgentSessionOptionsResult +}): StructuredAgentSessionAdapter { + return { + acquire: vi + .fn() + .mockImplementation(async ({ fence, spawnToken }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW, + spawnToken + }, + link: { + linkId: `${input.origin}-link`, + handle: { provider: 'codex', threadId: '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() + } +} + +describe('structured session acquisition options', () => { + it('persists provider options before proving a resumed legacy record', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-')) + const storeDir = join(root, 'store') + const store = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + + const created = await performAttach({ + store, + adapter: adapter({ origin: 'created' }), + journalRoot: 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' }, + now: NOW + }) + + const resumedStore = await AgentSessionRecordStore.open({ + directory: storeDir, + hostId: 'local' + }) + await resumedStore.reconcileOnRestart({ + probe: async () => ({ outcome: 'pid-absent' }), + now: NOW + 1 + }) + const releasedFence = resumedStore.getRecord(SESSION)?.lease.runtimeFence ?? 0 + const resumed = await performAttach({ + store: resumedStore, + adapter: adapter({ + origin: 'resumed', + options: { + current: { model: 'gpt-5.6-terra', effort: 'medium' }, + models: [] + } + }), + journalRoot: 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 AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + expect(reopened.getRecord(SESSION)?.options).toEqual({ + approvalPolicy: 'on-request', + personality: 'concise', + model: 'gpt-5.6-terra', + effort: 'medium' + }) + }) + + it('releases an acquisition when provider options cannot be read', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-failure-')) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const releaseAcquisition = vi.fn(async () => undefined) + const failingAdapter: StructuredAgentSessionAdapter = { + ...adapter({ origin: 'created' }), + readOptions: vi.fn(async () => { + throw new Error('model list unavailable') + }), + releaseAcquisition + } + + await expect( + performAttach({ + store, + adapter: failingAdapter, + journalRoot: 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: () => {} + }) + ).rejects.toThrow('model list unavailable') + expect(releaseAcquisition).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease.ownerProcess).toBeNull() + }) +}) 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 9b5e4bfed41..dea24156e7b 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 @@ -26,6 +26,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { isAgentSessionPreSpawnError } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { readNativeHandoffSessionOptions } from './structured-agent-session-handoff-options' export type AttachFlowInput = { store: AgentSessionRecordStore @@ -156,6 +157,12 @@ async function acquireOwner( ...(input.eventSink ? { events: input.eventSink } : {}) }) try { + const options = await readNativeHandoffSessionOptions({ + adapter: input.adapter, + sessionId: record.sessionId, + fence, + ...(record.options ? { priorOptions: record.options } : {}) + }) if (record.lease.ownerProcess === null) { await input.store.commitProcessIdentity({ sessionId: record.sessionId, @@ -170,7 +177,8 @@ async function acquireOwner( sessionId: record.sessionId, fence, link: acquired.link, - now: input.now() + now: input.now(), + ...(options ? { options } : {}) }) } catch (error) { try { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.ts index 037971b969d..a5afd9891a1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.ts @@ -4,8 +4,9 @@ export async function readNativeHandoffSessionOptions(input: { adapter: Pick sessionId: string fence: number + priorOptions?: Readonly> }): Promise> | undefined> { - const { adapter, sessionId, fence } = input + const { adapter, sessionId, fence, priorOptions } = input const reported = await adapter.readOptions?.({ sessionId, fence @@ -13,7 +14,9 @@ export async function readNativeHandoffSessionOptions(input: { if (!reported) { return undefined } + const { model: _model, effort: _effort, ...restored } = priorOptions ?? {} return { + ...restored, model: reported.current.model, ...(reported.current.effort ? { effort: reported.current.effort } : {}) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts index 42b882d14df..81fb37ec046 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts @@ -133,7 +133,8 @@ async function acquireNativeHandoffOwner( const options = await readNativeHandoffSessionOptions({ adapter: deps.adapter, sessionId: input.sessionId, - fence: input.fence + fence: input.fence, + ...(record.options ? { priorOptions: record.options } : {}) }) await deps.store.commitProcessIdentity({ sessionId: input.sessionId,