import { describe, expect, it, vi } from 'vitest' import type { AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import { CodexAppServerRequestError } from './codex-app-server-connection' import type { CodexAppServerConnection, CodexAppServerConnectionHandlers, CodexAppServerLaunch, openCodexAppServerConnection } from './codex-app-server-connection' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' import { encodeCodexQuestionOptionId } from './codex-structured-prompt-replies' import { CodexStructuredSessionAdapter, type CodexStructuredLaunch, type CodexStructuredSessionAdapterDeps, type CodexStructuredSessionEvent } from './codex-structured-session-adapter' const THREAD_ID = 'thread-abc' function identityFor(sessionId: string): AgentSessionJournalIdentity { return { sessionId, workspaceId: 'ws-1', hostId: 'host-1', agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD_ID } } } const USER_MESSAGE: AgentJournalMessageItem = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'ship it' }] } type Route = (params: Record | undefined) => unknown // `closed` is readonly on the real connection; the fake flips it so a test can // kill the child at a chosen moment. type FakeConnection = Omit & { closed: boolean launch: CodexAppServerLaunch handlers: CodexAppServerConnectionHandlers calls: { method: string; params?: Record }[] replies: { id: number | string; result?: unknown; code?: number; message?: string }[] closeCount: number } /** Stands in for a live `codex app-server`: every RPC is answered from `routes`, * and the test drives Codex's own traffic through `handlers`. */ function fakeCodex(routes: Record = {}): { connections: FakeConnection[] openConnection: typeof openCodexAppServerConnection routes: Record } { const connections: FakeConnection[] = [] const openConnection = (async (launch, handlers = {}) => { const connection: FakeConnection = { launch, handlers, calls: [], replies: [], closeCount: 0, pid: 4321, closed: false, request: async (method, params) => { connection.calls.push({ method, params }) const route = routes[method] return route ? route(params) : {} }, notify: () => {}, respond: (id, result) => connection.replies.push({ id, result }), respondWithError: (id, code, message) => connection.replies.push({ id, code, message }), close: async () => { connection.closeCount += 1 connection.closed = true return true } } connections.push(connection) return connection }) as typeof openCodexAppServerConnection routes['thread/start'] ??= () => ({ thread: { id: THREAD_ID, path: '/rollouts/abc.jsonl' }, model: 'gpt-live', reasoningEffort: 'medium' }) routes['thread/resume'] ??= (params) => ({ thread: { id: (params as { threadId: string }).threadId }, model: 'gpt-live', reasoningEffort: 'medium' }) return { connections, openConnection, routes } } function adapterFor( codex: ReturnType, launch: Partial = {}, events: CodexStructuredSessionEvent[] = [], processControl: Partial< Pick > = {} ): CodexStructuredSessionAdapter { let acquisitionGeneration = 0 return new CodexStructuredSessionAdapter({ resolveLaunch: async () => ({ command: 'codex', args: ['app-server'], cwd: '/work/repo', codexHome: null, resumeThreadId: null, ...launch }), onEvent: (event) => events.push(event), openConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000, captureTurnProcesses: async () => ({ platform: 'win32', identities: new Map() }), terminateTurnProcesses: async () => true, now: () => 1_700_000_000_500, mintAcquisitionGeneration: () => `generation-${++acquisitionGeneration}`, ...processControl }) } async function acquired( codex: ReturnType, launch: Partial = {}, events: CodexStructuredSessionEvent[] = [] ): Promise { const adapter = adapterFor(codex, launch, events) await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) return adapter } describe('CodexStructuredSessionAdapter.acquire', () => { it('starts a new thread and reports the process and link the lease will prove', async () => { const codex = fakeCodex() const adapter = adapterFor(codex, { codexHome: '/codex/home' }) const acquisition = await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) expect(codex.connections[0].launch.env).toEqual({ [CODEX_SPAWN_TOKEN_ENV]: 'spawn-9', CODEX_HOME: '/codex/home' }) expect(codex.connections[0].launch.cwd).toBe('/work/repo') expect(codex.connections[0].calls[0]).toEqual({ method: 'thread/start', params: { cwd: '/work/repo' } }) expect(acquisition.process).toEqual({ hostId: 'host-1', pid: 4321, processStartTimeMs: 1_700_000_000_000, spawnToken: 'spawn-9' }) expect(acquisition.link).toEqual({ linkId: `codex-7-${THREAD_ID}`, handle: { provider: 'codex', threadId: THREAD_ID }, origin: 'created', mintedAtFence: 7, observedAt: 1_700_000_000_500 }) expect(acquisition.acquisitionGeneration).toBe('generation-1') }) it('resumes the thread the durable handle chain names, not the client one', async () => { const codex = fakeCodex() const adapter = adapterFor(codex, { resumeThreadId: 'thread-proven', resumePath: '/rollouts/thread-proven.jsonl' }) const acquisition = await adapter.acquire({ identity: identityFor('session-1'), fence: 9, spawnToken: 'spawn-9' }) expect(codex.connections[0].calls[0]).toEqual({ method: 'thread/resume', params: expect.objectContaining({ threadId: 'thread-proven', cwd: '/work/repo', path: '/rollouts/thread-proven.jsonl' }) }) expect(acquisition.link.origin).toBe('resumed') expect(acquisition.link.handle).toEqual({ provider: 'codex', threadId: 'thread-proven' }) }) it('refuses a resume that lands on a different thread and reaps the child', async () => { const codex = fakeCodex({ 'thread/resume': () => ({ thread: { id: 'thread-other' } }) }) const adapter = adapterFor(codex, { resumeThreadId: 'thread-proven' }) await expect( adapter.acquire({ identity: identityFor('session-1'), fence: 9, spawnToken: 'spawn-9' }) ).rejects.toThrow('resumed thread-other instead of thread-proven') expect(codex.connections[0].closeCount).toBe(1) }) it('refuses a thread Codex never named', async () => { const codex = fakeCodex({ 'thread/start': () => ({}) }) const adapter = adapterFor(codex) await expect( adapter.acquire({ identity: identityFor('session-1'), fence: 1, spawnToken: 'spawn-9' }) ).rejects.toThrow('did not name the thread') expect(codex.connections[0].closeCount).toBe(1) }) it('closes the previous child before re-acquiring at a new fence', async () => { const codex = fakeCodex() const adapter = await acquired(codex) await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) expect(codex.connections).toHaveLength(2) expect(codex.connections[0].closeCount).toBe(1) expect(codex.connections[1].closeCount).toBe(0) }) it('keeps the traffic Codex sends before the session is published', async () => { const codex = fakeCodex() const events: CodexStructuredSessionEvent[] = [] codex.routes['thread/start'] = () => { // Codex talks as soon as the child is up, which is before the adapter has // a thread id to publish the session under. codex.connections[0].handlers.onNotification?.('item/started', { threadId: THREAD_ID }) codex.connections[0].handlers.onServerRequest?.({ id: 5, method: 'item/commandExecution/requestApproval', params: { itemId: 'codex-item-early', threadId: THREAD_ID, turnId: 'turn-1' } }) return { thread: { id: THREAD_ID } } } const adapter = await acquired(codex, {}, events) expect(events.map((event) => event.type)).toEqual(['notification', 'prompt']) // The early approval is answerable, so Codex is not left blocked on a // request that arrived a moment too soon. await adapter.answerPrompt({ sessionId: 'session-1', itemId: 'codex-item-early', kind: 'approval', optionId: 'accept', fence: 7 }) expect(codex.connections[0].replies).toEqual([{ id: 5, result: { decision: 'accept' } }]) }) it('retries a notification rejected by journal admission instead of dropping it', async () => { const codex = fakeCodex() const events: CodexStructuredSessionEvent[] = [] let attempts = 0 const sink: StructuredAgentSessionEventSink = { appendItem: vi.fn(), appendTombstone: vi.fn(), publish: vi.fn(), tryAppendItem: vi.fn((identity, body, blobs) => { attempts += 1 if (attempts === 1) { return { accepted: false as const, reason: 'backpressure' as const } } sink.appendItem(identity, body, blobs) return { accepted: true as const } }) } const adapter = adapterFor(codex, {}, events) await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', events: sink }) codex.connections[0].handlers.onNotification?.('item/completed', { item: { type: 'agentMessage', id: 'message-1', text: 'hello' } }) await vi.waitFor(() => { expect(events).toHaveLength(1) expect(events[0]).toMatchObject({ type: 'notification', method: 'item/completed' }) }) expect(attempts).toBe(2) }) it('refuses to publish a session whose child died while it was being acquired', async () => { const codex = fakeCodex() const adapter = new CodexStructuredSessionAdapter({ resolveLaunch: async () => ({ command: 'codex', args: ['app-server'], cwd: '/work/repo', codexHome: null, resumeThreadId: null }), openConnection: codex.openConnection, // The child dies while the acquisition is still reading its identity. readProcessStartTime: async () => { codex.connections[0].closed = true return 1_700_000_000_000 } }) await expect( adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) ).rejects.toThrow('exited while being acquired') expect(codex.connections[0].closeCount).toBe(1) await expect( adapter.dispatch({ sessionId: 'session-1', clientMessageId: 'client-1', body: USER_MESSAGE, fence: 7 }) ).rejects.toThrow('no live codex app-server') }) it('classifies launch validation failure as pre-spawn without opening a child', async () => { const codex = fakeCodex() const adapter = new CodexStructuredSessionAdapter({ resolveLaunch: async () => { throw new Error('workspace no longer exists') }, openConnection: codex.openConnection }) const error = await adapter .acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) .catch((cause: unknown) => cause) expect(error).toMatchObject({ name: 'AgentSessionPreSpawnError', message: 'workspace no longer exists' }) expect(codex.connections).toHaveLength(0) }) it('reports the rollout path Codex named, and null when it named none', async () => { const withPath = fakeCodex() const adapter = await acquired(withPath) expect(await adapter.historyFilePath({ identity: identityFor('session-1') })).toBe( '/rollouts/abc.jsonl' ) const withoutPath = fakeCodex({ 'thread/start': () => ({ thread: { id: THREAD_ID } }) }) const bare = await acquired(withoutPath) expect(await bare.historyFilePath({ identity: identityFor('session-1') })).toBeNull() }) it('lets closeAll cancel and reap an acquisition still opening', async () => { const codex = fakeCodex() let releaseOpen = (): void => {} let markOpenEntered = (): void => {} const gate = new Promise((resolve) => { releaseOpen = resolve }) const openEntered = new Promise((resolve) => { markOpenEntered = resolve }) const openConnection: typeof openCodexAppServerConnection = async (...args) => { markOpenEntered() await gate return codex.openConnection(...args) } const adapter = new CodexStructuredSessionAdapter({ resolveLaunch: async () => ({ command: 'codex', args: ['app-server'], cwd: '/work/repo', codexHome: null, resumeThreadId: null }), openConnection, readProcessStartTime: async () => 1_700_000_000_000 }) const acquiring = adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) await openEntered const closing = adapter.closeAll() releaseOpen() await expect(acquiring).rejects.toThrow('superseded while being acquired') await closing expect(codex.connections[0]?.closeCount).toBe(1) }) it('fences an acquisition while launch resolution is still pending', async () => { const launch = Promise.withResolvers() const codex = fakeCodex() const adapter = new CodexStructuredSessionAdapter({ resolveLaunch: () => launch.promise, openConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000 }) const acquiring = adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) const closing = adapter.closeAll() launch.resolve({ command: 'codex', args: ['app-server'], cwd: '/work/repo', codexHome: null, resumeThreadId: null }) await expect(acquiring).rejects.toThrow('superseded while being acquired') await closing expect(codex.connections).toHaveLength(0) }) }) describe('CodexStructuredSessionAdapter.dispatch', () => { it('accepts a turn Codex names in its response', async () => { const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) const adapter = await acquired(codex) const outcome = await adapter.dispatch({ sessionId: 'session-1', clientMessageId: 'client-1', body: { kind: 'message', role: 'user', blocks: [ { type: 'text', text: 'ship it' }, { type: 'image-ref', path: '/tmp/shot.png' }, { type: 'image-ref', url: 'https://example.test/a.png' } ] }, fence: 7 }) expect(outcome).toEqual({ state: 'accepted', providerIdentity: { provider: 'codex', threadId: THREAD_ID, turnId: 'turn-1', ordinal: 0 } }) expect(codex.connections[0].calls[1].params).toEqual({ threadId: THREAD_ID, clientUserMessageId: 'client-1', input: [ { type: 'text', text: 'ship it' }, { type: 'localImage', path: '/tmp/shot.png' }, { type: 'image', url: 'https://example.test/a.png' } ] }) }) it('accepts a turn named only by the notification that raced the ack', async () => { const codex = fakeCodex() const events: CodexStructuredSessionEvent[] = [] const adapter = await acquired(codex, {}, events) codex.routes['turn/start'] = () => { codex.connections[0].handlers.onNotification?.('turn/started', { threadId: THREAD_ID, turn: { id: 'turn-late' } }) return {} } const outcome = await adapter.dispatch({ sessionId: 'session-1', clientMessageId: 'client-1', body: USER_MESSAGE, fence: 7 }) expect(outcome).toMatchObject({ state: 'accepted' }) expect(outcome).toMatchObject({ providerIdentity: { turnId: 'turn-late' } }) expect(events.at(-1)).toMatchObject({ type: 'notification', method: 'turn/started' }) }) it('does not let a child thread answer for the root thread', async () => { const codex = fakeCodex() const events: CodexStructuredSessionEvent[] = [] codex.routes['turn/start'] = () => { // A subagent runs its own thread over the same connection, and its turn // starts first. const notify = codex.connections[0].handlers.onNotification notify?.('turn/started', { threadId: 'thread-child', turn: { id: 'turn-child' } }) notify?.('turn/started', { threadId: THREAD_ID, turn: { id: 'turn-root' } }) return {} } const adapter = await acquired(codex, {}, events) const outcome = await adapter.dispatch({ sessionId: 'session-1', clientMessageId: 'client-1', body: USER_MESSAGE, fence: 7 }) expect(outcome).toEqual({ state: 'accepted', providerIdentity: { provider: 'codex', threadId: THREAD_ID, turnId: 'turn-root', ordinal: 0 } }) // Each event carries the thread it actually came from, so the journal can // keep a subagent's turn out of the root conversation. expect(events.map((event) => (event.type === 'notification' ? event.threadId : null))).toEqual([ 'thread-child', THREAD_ID ]) }) it('settles unknown rather than failed when Codex never names the turn', async () => { vi.useFakeTimers() try { const codex = fakeCodex() const adapter = await acquired(codex) const dispatching = adapter.dispatch({ sessionId: 'session-1', clientMessageId: 'client-1', body: USER_MESSAGE, fence: 7 }) await vi.advanceTimersByTimeAsync(10_000) expect(await dispatching).toEqual({ state: 'unknown', reason: 'codex app-server started a turn it did not name in time' }) } finally { vi.useRealTimers() } }) it('rejects only when Codex answered and declined', async () => { const codex = fakeCodex({ 'turn/start': () => { throw new CodexAppServerRequestError('turn/start', -32602, 'turn already running') } }) const adapter = await acquired(codex) expect( await adapter.dispatch({ sessionId: 'session-1', clientMessageId: 'client-1', body: USER_MESSAGE, fence: 7 }) ).toEqual({ state: 'rejected', reason: 'turn already running' }) }) it('rethrows a dead child so the wire settles the submission unknown', async () => { const codex = fakeCodex({ 'turn/start': () => { throw new Error('codex app-server connection ended') } }) const adapter = await acquired(codex) await expect( adapter.dispatch({ sessionId: 'session-1', clientMessageId: 'client-1', body: USER_MESSAGE, fence: 7 }) ).rejects.toThrow('connection ended') }) it('applies an option change to the next turn only', async () => { const codex = fakeCodex({ 'model/list': () => ({ data: [ { model: 'gpt-live', supportedReasoningEfforts: [{ reasoningEffort: 'medium' }], defaultReasoningEffort: 'medium' }, { model: 'gpt-5', supportedReasoningEfforts: [{ reasoningEffort: 'high' }], defaultReasoningEffort: 'high' } ], nextCursor: null }), 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) const adapter = await acquired(codex) await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'gpt-5', fence: 7 }) await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) await expect( adapter.setOption({ sessionId: 'session-1', key: 'sandboxEscape', value: 'yes', fence: 7 }) ).rejects.toThrow('no thread option named sandboxEscape') await adapter.dispatch({ sessionId: 'session-1', clientMessageId: 'client-1', body: USER_MESSAGE, fence: 7 }) const turnStart = codex.connections[0].calls.findLast((call) => call.method === 'turn/start') expect(turnStart?.params).toMatchObject({ model: 'gpt-5', effort: 'high' }) expect(turnStart?.params).not.toHaveProperty('sandboxEscape') }) }) describe('CodexStructuredSessionAdapter prompts', () => { function askApproval(codex: ReturnType): void { codex.connections[0].handlers.onServerRequest?.({ id: 11, method: 'item/commandExecution/requestApproval', params: { itemId: 'codex-item-1', threadId: THREAD_ID, turnId: 'turn-1' } }) } it('surfaces an approval request and answers it exactly once', async () => { const codex = fakeCodex() const events: CodexStructuredSessionEvent[] = [] const adapter = await acquired(codex, {}, events) askApproval(codex) adapter.bindPromptItemId('session-1', 'codex:thread-abc:turn-1:3', 'codex-item-1') await adapter.answerPrompt({ sessionId: 'session-1', itemId: 'codex:thread-abc:turn-1:3', kind: 'approval', optionId: 'accept', fence: 7 }) expect(events.at(-1)).toMatchObject({ type: 'prompt', codexItemId: 'codex-item-1' }) expect(codex.connections[0].replies).toEqual([{ id: 11, result: { decision: 'accept' } }]) await expect( adapter.answerPrompt({ sessionId: 'session-1', itemId: 'codex:thread-abc:turn-1:3', kind: 'approval', optionId: 'decline', fence: 7 }) ).rejects.toThrow('no longer waiting on') expect(codex.connections[0].replies).toHaveLength(1) }) it('responds with an error when a prompt cannot be admitted to the journal sink', async () => { const codex = fakeCodex() const events: CodexStructuredSessionEvent[] = [] const sink: StructuredAgentSessionEventSink = { appendItem: vi.fn(), appendTombstone: vi.fn(), publish: vi.fn(), tryAppendItem: vi.fn(() => ({ accepted: false as const, reason: 'closed' as const })) } const adapter = adapterFor(codex, {}, events) await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', events: sink }) askApproval(codex) expect(events.filter((event) => event.type === 'prompt')).toEqual([]) expect(codex.connections[0].replies).toEqual([ { id: 11, code: -32001, message: 'Orca could not durably record item/commandExecution/requestApproval prompt (closed)' } ]) await expect( adapter.answerPrompt({ sessionId: 'session-1', itemId: 'codex-item-1', kind: 'approval', optionId: 'accept', fence: 7 }) ).rejects.toThrow('no longer waiting on') }) it('force-closes when an unhandled provider frame cannot be admitted', async () => { const codex = fakeCodex() const events: CodexStructuredSessionEvent[] = [] const sink: StructuredAgentSessionEventSink = { appendItem: vi.fn(), appendTombstone: vi.fn(), publish: vi.fn(), tryAppendItem: vi.fn(() => ({ accepted: false as const, reason: 'backpressure' as const })) } const adapter = adapterFor(codex, {}, events) await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', events: sink }) codex.connections[0].handlers.onUnhandledFrame?.('frame:invalid-json', '{') await vi.waitFor(() => expect(codex.connections[0].closeCount).toBe(1)) expect(events.filter((event) => event.type === 'ended')).toMatchObject([ { cause: 'unexpected-exit', fence: 7, acquisitionGeneration: 'generation-1' } ]) }) it('force-closes after a responded server request is not durably admitted', async () => { const codex = fakeCodex() const events: CodexStructuredSessionEvent[] = [] const sink: StructuredAgentSessionEventSink = { appendItem: vi.fn(), appendTombstone: vi.fn(), publish: vi.fn(), tryAppendItem: vi.fn(() => ({ accepted: false as const, reason: 'failed' as const })) } const adapter = adapterFor(codex, {}, events) await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9', events: sink }) codex.connections[0].handlers.onServerRequest?.({ id: 17, method: 'item/permissions/requestApproval', params: { threadId: THREAD_ID } }) await vi.waitFor(() => expect(codex.connections[0].closeCount).toBe(1)) expect(events.filter((event) => event.type === 'ended')).toMatchObject([ { cause: 'unexpected-exit', fence: 7, acquisitionGeneration: 'generation-1' } ]) }) it('answers each approval a tool item asks for separately', async () => { const codex = fakeCodex() const events: CodexStructuredSessionEvent[] = [] const adapter = await acquired(codex, {}, events) // A shell bridge re-asks per command under one parent tool item, so only the // approval id tells the two requests apart. const ask = (id: number, approvalId: string): void => { codex.connections[0].handlers.onServerRequest?.({ id, method: 'item/commandExecution/requestApproval', params: { itemId: 'codex-item-1', approvalId, threadId: THREAD_ID, turnId: 'turn-1' } }) } ask(11, 'approval-a') ask(12, 'approval-b') adapter.bindPromptItemId('session-1', 'journal-a', 'approval-a') adapter.bindPromptItemId('session-1', 'journal-b', 'approval-b') for (const [itemId, optionId] of [ ['journal-b', 'decline'], ['journal-a', 'accept'] ]) { await adapter.answerPrompt({ sessionId: 'session-1', itemId, kind: 'approval', optionId, fence: 7 }) } expect(codex.connections[0].replies).toEqual([ { id: 12, result: { decision: 'decline' } }, { id: 11, result: { decision: 'accept' } } ]) expect(events.map((event) => (event.type === 'prompt' ? event.promptKey : null))).toEqual([ 'approval-a', 'approval-b' ]) }) it('rejects an option id that is not a Codex decision', async () => { const codex = fakeCodex() const adapter = await acquired(codex) askApproval(codex) await expect( adapter.answerPrompt({ sessionId: 'session-1', itemId: 'codex-item-1', kind: 'approval', optionId: 'yolo', fence: 7 }) ).rejects.toThrow('is not a Codex approval decision') expect(codex.connections[0].replies).toEqual([]) }) it('holds a multi-question request until every question is answered', async () => { const codex = fakeCodex() const adapter = await acquired(codex) codex.connections[0].handlers.onServerRequest?.({ id: 12, method: 'item/tool/requestUserInput', params: { itemId: 'codex-item-2', threadId: THREAD_ID, turnId: 'turn-1', questions: [ { id: 'q1', question: 'Use this answer?' }, { id: 'q2', question: 'Use that answer?' } ] } }) await adapter.answerPrompt({ sessionId: 'session-1', itemId: 'codex-item-2', kind: 'question', optionId: encodeCodexQuestionOptionId('q1', 'yes'), fence: 7 }) expect(codex.connections[0].replies).toEqual([]) await adapter.answerPrompt({ sessionId: 'session-1', itemId: 'codex-item-2', kind: 'question', optionId: encodeCodexQuestionOptionId('q2', 'no'), fence: 7 }) expect(codex.connections[0].replies).toEqual([ { id: 12, result: { answers: { q1: { answers: ['yes'] }, q2: { answers: ['no'] } } } } ]) }) it('declines MCP elicitation and journals the explicit disposition', async () => { const codex = fakeCodex() const events: CodexStructuredSessionEvent[] = [] await acquired(codex, {}, events) codex.connections[0].handlers.onServerRequest?.({ id: 13, method: 'mcpServer/elicitation/request', params: { itemId: 'codex-item-3', threadId: THREAD_ID } }) expect(codex.connections[0].replies).toEqual([ { id: 13, result: { action: 'decline', content: null, _meta: null } } ]) expect(events.some((event) => event.type === 'prompt')).toBe(false) }) it('surfaces an answer to a prompt Codex already forgot', async () => { const codex = fakeCodex() const adapter = await acquired(codex) await expect( adapter.answerPrompt({ sessionId: 'session-1', itemId: 'codex-item-gone', kind: 'approval', optionId: 'accept', fence: 7 }) ).rejects.toThrow('no longer waiting on codex-item-gone') }) })