Files
orca/src/main/codex/codex-structured-session-adapter.test.ts
T

883 lines
29 KiB
TypeScript

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<string, unknown> | 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<CodexAppServerConnection, 'closed'> & {
closed: boolean
launch: CodexAppServerLaunch
handlers: CodexAppServerConnectionHandlers
calls: { method: string; params?: Record<string, unknown> }[]
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<string, Route> = {}): {
connections: FakeConnection[]
openConnection: typeof openCodexAppServerConnection
routes: Record<string, Route>
} {
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<typeof fakeCodex>,
launch: Partial<CodexStructuredLaunch> = {},
events: CodexStructuredSessionEvent[] = [],
processControl: Partial<
Pick<CodexStructuredSessionAdapterDeps, 'captureTurnProcesses' | 'terminateTurnProcesses'>
> = {}
): 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<typeof fakeCodex>,
launch: Partial<CodexStructuredLaunch> = {},
events: CodexStructuredSessionEvent[] = []
): Promise<CodexStructuredSessionAdapter> {
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<void>((resolve) => {
releaseOpen = resolve
})
const openEntered = new Promise<void>((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<CodexStructuredLaunch>()
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<typeof fakeCodex>): 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')
})
})