import './rpc/unused-default-rpc-methods.test-fixture' // One structured Codex session driven end to end over `agentSession.*`. // // Nothing here is stubbed except the Codex child itself: the RPC dispatcher, the // zod schemas, the capability gate, the durable record store, the journal, the // lease, the Codex adapter, and the event-to-journal translation are all the ones // that ship. The fake app-server answers the same JSON-RPC calls the real one // does and pushes the same notifications and blocking requests back. import { 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 type { CodexAppServerConnection, CodexAppServerConnectionHandlers, openCodexAppServerConnection } from '../codex/codex-app-server-connection' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../ipc/desktop-renderer-runtime-capabilities' import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../shared/agent-session-create-capabilities' import type { AgentJournalRenderItem, AgentJournalSubmission } from '../../shared/agent-session-journal-types' import type { AgentSessionHistoryResult, AgentSessionSubscribeEvent } from '../../shared/agent-session-wire' import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { importLegacyTranscriptIntoJournal } from '../native-chat/agent-session-journal/journal-legacy-import' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type { OrcaRuntimeService } from './orca-runtime' import { readPersistedTestAgentSessionStoreText } from './agent-session-record-store-test-harness' import type { RpcRequest, RpcResponse } from './rpc/core' import { RpcDispatcher } from './rpc/dispatcher' import type { NativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' import { STRUCTURED_AGENT_SESSION_METHODS } from './rpc/methods/structured-agent-session' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture' const journals = createTrackedJournalOpener() const SESSION = 'session-integration-1' const THREAD = 'thread-integration' const TURN = 'turn-1' const WORKSPACE = 'workspace-1' const JOURNAL_IDENTITY = { sessionId: SESSION, workspaceId: WORKSPACE, hostId: 'local', agent: 'codex' as const, providerHandle: codexProviderHandle(THREAD) } // An eager-start client with current options capabilities. Without the pending-send // one the host holds the reply until the send settles, which is a shim for // clients too old to render a pending bubble — not what this suite models. const CLIENT = { clientId: 'device-a', clientKind: 'runtime' as const, clientCapabilities: DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES.filter( (capability) => capability !== AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY ) } // ─── the fake `codex app-server` ──────────────────────────────────────────── type CodexScript = { connections: FakeConnection[] openConnection: typeof openCodexAppServerConnection live: () => FakeConnection notify: (method: string, params: unknown) => void ask: (id: number, method: string, params: unknown) => void } // `closed` is readonly on the real connection; the fake flips it so the test can // see the takeover reap the previous child. type FakeConnection = Omit & { closed: boolean handlers: CodexAppServerConnectionHandlers calls: { method: string; params?: Record }[] replies: { id: number | string; result?: unknown; code?: number }[] resumedThreadId: string | null launch: Parameters[0] } function fakeCodex(): CodexScript { const connections: FakeConnection[] = [] const openConnection: typeof openCodexAppServerConnection = async (launch, handlers = {}) => { const connection: FakeConnection = { launch, handlers, calls: [], replies: [], resumedThreadId: null, pid: 4321, closed: false, request: async (method, params) => { connection.calls.push({ method, params }) if (method === 'thread/start') { return { thread: { id: THREAD, path: '/rollouts/integration.jsonl' } } } if (method === 'thread/resume') { connection.resumedThreadId = (params as { threadId: string }).threadId return { thread: { id: connection.resumedThreadId } } } if (method === 'turn/start') { return { turn: { id: TURN } } } if (method === 'model/list') { return { data: [ { model: 'gpt-live', displayName: 'GPT Live', hidden: false, supportedReasoningEfforts: [ { reasoningEffort: 'medium', description: 'Balanced' }, { reasoningEffort: 'high', description: 'Deep reasoning' } ], defaultReasoningEffort: 'medium', isDefault: true } ], nextCursor: null } } return {} }, notify: () => {}, respond: (id, result) => connection.replies.push({ id, result }), respondWithError: (id, code) => connection.replies.push({ id, code }), close: async () => { connection.closed = true return true } } connections.push(connection) return connection } const live = (): FakeConnection => { const connection = connections.at(-1) if (!connection) { throw new Error('no codex app-server has been opened') } return connection } return { connections, openConnection, live, notify: (method, params) => live().handlers.onNotification?.(method, params), ask: (id, method, params) => live().handlers.onServerRequest?.({ id, method, params }) } } // ─── the RPC client ───────────────────────────────────────────────────────── let operations = 0 /** `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. Real * time, not a frozen constant: the runtime under test stamps the ledger with * its own clock and refuses a future-dated id. */ function operationId(): string { operations += 1 return `${Date.now()}-${operations.toString(16).padStart(32, '0')}` } function envelope(method: string, fields: Record, fence: number | null) { return { sessionId: SESSION, clientOperationId: operationId(), expectedRuntimeFence: fence, payloadFingerprint: computeAgentSessionPayloadFingerprint({ method, sessionId: SESSION, fields }) } } function attachParams(fence: number | null) { const params = { location: { executionHostId: 'local', wslDistro: null, workspaceId: WORKSPACE, workspaceKind: 'git-worktree' as const }, provider: 'codex' as const, agent: 'codex', accountHome: { variable: 'CODEX_HOME' as const, path: '/home/dev/.codex' }, runtimeKind: 'native' as const, providerHandle: { kind: 'codex' as const, threadId: THREAD } } const envelope = { sessionId: SESSION, clientOperationId: operationId(), expectedRuntimeFence: fence, payloadFingerprint: '' } return { ...params, envelope: { ...envelope, payloadFingerprint: computeAgentSessionPayloadFingerprint({ method: 'agentSession.attach', sessionId: SESSION, fields: attachFingerprintFields({ ...params, envelope } as never) }) } } } function createIntentParams() { const worktree = `id:${WORKSPACE}` const fields = { worktree, agent: 'codex' } return { envelope: envelope('agentSession.create', fields, null), ...fields } } let codex: CodexScript /** Accepted first; the delivery loop hands the send over as `turn/start` after the reply. */ const handedOverAs = (params: Record) => vi.waitFor(() => expect(codex.live().calls.at(-1)).toMatchObject({ method: 'turn/start', params }) ) let root: string let dispatcher: RpcDispatcher let bootEnvironmentReads: number let codexOverrideReads: number let configuredCodexProfile: string let shellEnvironmentPolicy: NativeChatShellEnvironmentPolicy /** Runs a one-shot method and returns its decoded reply. */ async function call(method: string, params: unknown): Promise { const replies: RpcResponse[] = [] const request: RpcRequest = { id: `req-${operations}`, authToken: 'token', method, params } await dispatcher.dispatchStreaming(request, (raw) => replies.push(JSON.parse(raw)), CLIENT) const first = replies[0] if (!first) { throw new Error(`no reply for ${method}`) } return first } /** Asserts success and unwraps the host's `{ok:true, value}` mutation result. */ async function ok(method: string, params: unknown): Promise { const response = await call(method, params) expect(response, `${method} failed: ${JSON.stringify(response)}`).toMatchObject({ ok: true }) const result = (response as { result: { ok: boolean; value?: T; refusal?: unknown } }).result expect(result, `${method} refused: ${JSON.stringify(result.refusal)}`).toMatchObject({ ok: true }) return result.value as T } /** Opens a live subscription and keeps collecting frames after the call settles. */ async function subscribe( requestId: string, cursor?: { epoch: string; sequence: number } ): Promise { const frames: AgentSessionSubscribeEvent[] = [] await dispatcher.dispatchStreaming( { id: requestId, authToken: 'token', method: 'agentSession.subscribe', params: { sessionId: SESSION, ...(cursor ? { cursor } : {}) } }, (raw) => { const response = JSON.parse(raw) as { ok: boolean; result?: AgentSessionSubscribeEvent } if (response.ok && response.result) { frames.push(response.result) } }, CLIENT ) return frames } /** Settles everything the provider streamed into the journal. Real clients see * these rows arrive on the subscription; a test has to wait for them. */ function drainStreamedEvents(): Promise { return getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) ?? Promise.resolve() } function textOf(item: AgentJournalRenderItem): string { const body = item.body return body?.kind === 'message' ? body.blocks.map((block) => (block.type === 'text' ? block.text : '')).join('') : '' } /** The durable submission row, which settlement rewrites after the send returns. */ async function submissionOf(clientMessageId: string): Promise { return (await getStructuredAgentSessionHost()?.journalSnapshot(SESSION))?.submissions.find( (entry) => entry.clientMessageId === clientMessageId ) } async function historyPage( direction: 'tail' | 'before' | 'after', extra: Record = {} ): Promise { const response = await call('agentSession.history', { sessionId: SESSION, direction, ...extra }) return (response as { result: AgentSessionHistoryResult }).result } async function readKnownOptions(): Promise { return vi.waitFor(async () => { const result = await call('agentSession.options', { sessionId: SESSION }) expect(result).toMatchObject({ ok: true, result: { models: [{ id: 'gpt-live', defaultEffort: 'medium' }], current: { model: 'gpt-live' } } }) return result }) } beforeEach(async () => { operations = 0 root = await mkdtemp(join(tmpdir(), 'orca-structured-integration-')) codex = fakeCodex() bootEnvironmentReads = 0 codexOverrideReads = 0 configuredCodexProfile = 'configured' shellEnvironmentPolicy = { inheritAll: true, names: [] } const runtime = { getRuntimeId: () => 'runtime-1', getClientSettings: () => ({ experimentalNativeChat: true }), getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), structuredAgentSessionLaunchSeedOptions: () => undefined, resolveStructuredAgentSessionCreateIntent: async () => { const { envelope: _envelope, providerHandle: _providerHandle, ...resolved } = attachParams(null) return { ...resolved, options: { model: 'gpt-live' } } }, publishStructuredAgentSessionTab: () => {}, ensureStructuredAgentSessionHost: () => ensureStructuredAgentSessionHost({ logger: createStructuredAgentSessionLogger(), stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveEnvironment: async () => { bootEnvironmentReads += 1 return { PATH: '/shell/bin:/usr/bin', EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/shell/home' } }, resolveShellEnvironmentPolicy: () => shellEnvironmentPolicy, resolveAgentLaunchEnv: (agent): Record => { if (agent !== 'codex') { return {} } codexOverrideReads += 1 return { CODEX_PROFILE: configuredCodexProfile } }, ...scriptedCodexTransport(codex.openConnection) }).then(() => undefined), registerOwnedSubscriptionCleanup: vi.fn((_id: string, dispose: () => void) => ({ releaseIfCurrent: dispose })) } dispatcher = new RpcDispatcher({ runtime: runtime as unknown as OrcaRuntimeService, methods: STRUCTURED_AGENT_SESSION_METHODS }) }) function itemsOf(frames: AgentSessionSubscribeEvent[]): AgentJournalRenderItem[] { const items = new Map() for (const frame of frames) { const published = frame.type === 'snapshot' || frame.type === 'reset' ? frame.page.items : frame.type === 'batch' ? frame.batch.items : [] for (const item of published) { items.set(item.itemId, item) } } return [...items.values()] } function cursorOf(frames: AgentSessionSubscribeEvent[]): { epoch: string; sequence: number } { for (let index = frames.length - 1; index >= 0; index -= 1) { const frame = frames[index] as AgentSessionSubscribeEvent if (frame.type === 'batch') { return frame.batch.cursor } if (frame.type === 'snapshot' || frame.type === 'reset') { return frame.page.liveCursor ?? frame.page.window.nextCursor } } throw new Error('subscription published no cursor') } afterEach(async () => { await journals.closeAll() await stopStructuredAgentSessionRuntime() await rm(root, { recursive: true, force: true }) }) describe('a structured codex session over agentSession.*', () => { it('hydrates provider options after activating a legacy-imported journal', async () => { const journal = await journals.open({ identity: JOURNAL_IDENTITY, stateDirectory: root }) const rollout = join(root, 'legacy-rollout.jsonl') await writeFile( rollout, `${JSON.stringify({ type: 'event_msg', timestamp: '2026-08-05T10:00:02.000Z', payload: { type: 'user_message', message: 'legacy question', kind: 'plain' } })}\n` ) await importLegacyTranscriptIntoJournal({ journal, agent: 'codex', sessionId: THREAD, fence: 0, options: { filePath: rollout } }) // The previous process exits, closing its database. await journals.closeAll() const created = await ok<{ page: { items: AgentJournalRenderItem[] } }>( 'agentSession.create', createIntentParams() ) expect(created.page.items.map(textOf)).toContain('legacy question') await readKnownOptions() }) it('dispatches and streams a plain first send from a fresh session', async () => { const created = await ok<{ fence: number; page: { items: unknown[] } }>( 'agentSession.create', createIntentParams() ) expect(created.page.items).toEqual([]) expect(codex.live().launch.env).toMatchObject({ CODEX_PROFILE: 'configured', EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/home/dev/.codex' }) const store = await readPersistedTestAgentSessionStoreText(root) // The record this create wrote, so the checks below read the runtime's own rows. expect(store).toContain('/home/dev/.codex') expect(store).not.toContain('EXAMPLE_GATEWAY_TOKEN') expect(store).not.toContain('"launchEnv"') const stream = await subscribe('sub-first-send') const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] } const sent = await ok<{ clientMessageId: string submission: { dispatchState: string; providerItemId: string | null } }>('agentSession.send', { envelope: envelope('agentSession.send', { body }, created.fence), body }) // Admission, not identity. `turn/start` proves Codex owns the message, but a // send coalesced into a running turn is answered with that turn's id, so // which message landed where is knowable only from the echo. expect(sent.submission).toMatchObject({ dispatchState: 'pending', providerItemId: null }) await handedOverAs({ threadId: THREAD, clientUserMessageId: sent.clientMessageId }) codex.notify('turn/started', { turn: { id: TURN } }) // Codex echoes the message back carrying the `clientId` it was sent under, // which is the only thing that names which submission this row settles. codex.notify('item/completed', { item: { type: 'userMessage', id: 'item-0', clientId: sent.clientMessageId, content: [{ type: 'text', text: 'hi' }] } }) codex.notify('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Hello.' }) codex.notify('item/completed', { item: { type: 'agentMessage', id: 'item-1', text: 'Hello.' } }) await drainStreamedEvents() expect(itemsOf(stream).map(textOf).filter(Boolean)).toEqual(['hi', 'Hello.']) // The echo is the first item of this turn, so the settled key is ordinal 0 — // minted by the same `identityFor` a history replay computes with, rather // than guessed from the turn/start response. await vi.waitFor(async () => expect(await submissionOf(sent.clientMessageId)).toMatchObject({ dispatchState: 'accepted', providerItemId: `codex:${THREAD}:${TURN}:0` }) ) }) it('runs create → send → stream → approval → cancel → reconnect → page history', async () => { // ── create ────────────────────────────────────────────────────────────── // No host exists yet; `create` is the call that builds one. expect(getStructuredAgentSessionHost()).toBeNull() const created = await ok<{ fence: number; page: { items: unknown[] } }>( 'agentSession.create', createIntentParams() ) expect(created.page.items).toEqual([]) // Create answers at spawn; the thread opens once Codex has initialized. await vi.waitFor(() => expect(codex.live().calls.find((call) => call.method === 'thread/start')).toMatchObject({ params: { cwd: `/repos/${WORKSPACE}` } }) ) const fence = created.fence const stream = await subscribe('sub-1') expect(stream[0]).toMatchObject({ type: 'snapshot', sessionId: SESSION }) // ── options ───────────────────────────────────────────────────────────── await readKnownOptions() await ok('agentSession.setOption', { envelope: envelope('agentSession.setOption', { key: 'model', value: 'gpt-live' }, fence), key: 'model', value: 'gpt-live' }) await ok('agentSession.setOption', { envelope: envelope('agentSession.setOption', { key: 'effort', value: 'high' }, fence), key: 'effort', value: 'high' }) await vi.waitFor(async () => expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ ok: true, result: { current: { model: 'gpt-live', effort: 'high' } } }) ) expect(itemsOf(stream)).toEqual([]) // ── send ──────────────────────────────────────────────────────────────── const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'list files' }] } const sent = await ok<{ clientMessageId: string submission: { dispatchState: string; providerItemId: string | null } }>('agentSession.send', { envelope: envelope('agentSession.send', { body }, fence), body }) // Codex took the message, so the submission is pending rather than // "delivery unconfirmed" — it carries no identity yet, because the response // to a coalesced send names the running turn rather than this message. expect(sent.submission).toMatchObject({ dispatchState: 'pending', providerItemId: null }) await handedOverAs({ threadId: THREAD, clientUserMessageId: sent.clientMessageId, model: 'gpt-live', effort: 'high' }) // ── stream ────────────────────────────────────────────────────────────── codex.notify('turn/started', { turn: { id: TURN } }) // Codex echoes the user message back as ordinal 0 of the turn, carrying the // `clientId` it was sent under. That echo settles the submission's identity, // and has to reconcile into the bubble the client already has rather than // append a second copy of it. codex.notify('item/completed', { item: { type: 'userMessage', id: 'item-0', clientId: sent.clientMessageId, content: [{ type: 'text', text: 'list files' }] } }) await drainStreamedEvents() expect(itemsOf(stream).filter((item) => textOf(item) === 'list files')).toHaveLength(1) // Settled from the echo's own journal identity, so it is by construction the // key a replay recomputes for this row. await vi.waitFor(async () => expect(await submissionOf(sent.clientMessageId)).toMatchObject({ dispatchState: 'accepted', providerItemId: `codex:${THREAD}:${TURN}:0` }) ) codex.notify('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Two ' }) codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'files.' }) await drainStreamedEvents() // The 60ms window has not elapsed, so no half-written row reached the // journal — the coalescer is holding both deltas. expect(itemsOf(stream).filter((item) => textOf(item).startsWith('Two'))).toEqual([]) codex.notify('item/completed', { item: { type: 'agentMessage', id: 'item-1', text: 'Two files.' } }) await drainStreamedEvents() const answer = itemsOf(stream).find((item) => textOf(item) === 'Two files.') // Keyed by (threadId, turnId, ordinal), so a resumed thread reuses this row. expect(answer?.itemId).toBe(`codex:${THREAD}:${TURN}:1`) // ── approval ──────────────────────────────────────────────────────────── codex.notify('item/started', { item: { type: 'commandExecution', id: 'item-2', command: 'ls -la', status: 'inProgress' } }) codex.ask(7, 'item/commandExecution/requestApproval', { threadId: THREAD, turnId: TURN, itemId: 'item-2', availableDecisions: ['accept', 'decline'] }) await drainStreamedEvents() const approval = itemsOf(stream).find((item) => item.body?.kind === 'approval') expect(approval?.body).toMatchObject({ title: 'Run a command?', detail: 'ls -la' }) const answered = await ok<{ resolution: { state: string; selectedOptionId: string } }>( 'agentSession.respondToApproval', { envelope: envelope( 'agentSession.respondTo:approval', { itemId: approval?.itemId, expectedRevision: approval?.revision, optionId: 'accept' }, fence ), itemId: approval?.itemId, expectedRevision: approval?.revision, optionId: 'accept' } ) expect(answered.resolution).toMatchObject({ state: 'resolved', selectedOptionId: 'accept' }) // The durable journal item id round-tripped back to the live Codex request. expect(codex.live().replies).toEqual([{ id: 7, result: { decision: 'accept' } }]) // ── cancel ────────────────────────────────────────────────────────────── const cancelled = await ok<{ turnId: string; cancelled: boolean }>('agentSession.cancel', { envelope: envelope('agentSession.cancel', { turnId: TURN }, fence), turnId: TURN }) expect(cancelled).toEqual({ turnId: TURN, cancelled: true }) expect(codex.live().calls.at(-1)).toMatchObject({ method: 'turn/interrupt', params: { threadId: THREAD, turnId: TURN } }) // ── reconnect ─────────────────────────────────────────────────────────── // The client drops. Its subscription is reaped; the session and its child // are not. await call('agentSession.unsubscribe', { sessionId: SESSION }) const lastCursor = cursorOf(stream) codex.notify('item/completed', { item: { type: 'agentMessage', id: 'item-3', text: 'Stopped.' } }) codex.notify('turn/completed', { turn: { id: TURN } }) await drainStreamedEvents() // Resubscribing from the cursor it held replays only what it missed. const missed = await subscribe('sub-2', lastCursor) expect(missed[0]?.type).toBe('batch') expect( itemsOf(missed).some( (item) => item.body?.kind === 'tool-call' && item.body.state === 'failed' ) ).toBe(true) expect(itemsOf(missed).map(textOf).filter(Boolean)).toEqual(['Stopped.']) // A runtime taking the session over is the other half of reconnect: the // fence advances, the old child is reaped, and its replacement resumes the // thread this session proved rather than forking a new one. const reaped = codex.live() const resumed = await ok<{ fence: number; page: { items: AgentJournalRenderItem[] } }>( 'agentSession.ensure', attachParams(fence) ) expect(resumed.fence).toBe(fence + 1) expect(reaped.closed).toBe(true) expect(codex.live().resumedThreadId).toBe(THREAD) expect(await readKnownOptions()).toMatchObject({ ok: true, result: { current: { effort: 'high' } } }) // The journal belongs to the session, not to the process that just died. expect(resumed.page.items.map(textOf)).toContain('Two files.') // ── page history ──────────────────────────────────────────────────────── const tail = await historyPage('tail', { limit: 2 }) expect(tail.ok).toBe(true) if (!tail.ok) { throw new Error('history reset') } expect(tail.page.hasOlder).toBe(true) const older = await historyPage('before', { cursor: tail.page.window.nextCursor, limit: 10 }) if (!older.ok) { throw new Error('history reset') } expect(older.page.hasOlder).toBe(false) // Every step of the conversation, in order, from the durable journal alone — // no page overlaps another, and nothing the live stream showed is missing. // The turn's lifecycle row outlives the turn: it is revised, never tombstoned. expect([...older.page.items, ...tail.page.items].map((item) => item.body?.kind)).toEqual([ 'message', 'turn', 'message', 'tool-call', 'approval', 'status', 'message' ]) expect([...older.page.items, ...tail.page.items].map(textOf)).toEqual([ 'list files', '', 'Two files.', '', '', '', 'Stopped.' ]) }) it('caches shell exports but re-reads configured overrides for a resume', async () => { shellEnvironmentPolicy = { inheritAll: false, names: [] } const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) expect(codex.live().launch.env?.EXAMPLE_GATEWAY_TOKEN).toBeUndefined() expect({ bootEnvironmentReads, codexOverrideReads }).toEqual({ bootEnvironmentReads: 1, codexOverrideReads: 1 }) configuredCodexProfile = 'updated' shellEnvironmentPolicy = { inheritAll: false, names: ['EXAMPLE_GATEWAY_TOKEN'] } const resumed = await ok<{ fence: number }>('agentSession.ensure', attachParams(created.fence)) expect(resumed.fence).toBe(created.fence + 1) expect(codex.live().resumedThreadId).toBe(THREAD) expect(codex.live().launch.env).toMatchObject({ CODEX_PROFILE: 'updated', EXAMPLE_GATEWAY_TOKEN: 'shell-exported' }) expect({ bootEnvironmentReads, codexOverrideReads }).toEqual({ bootEnvironmentReads: 1, codexOverrideReads: 2 }) }) it('refuses to build a host for a client that never advertised the capability', async () => { const replies: RpcResponse[] = [] await dispatcher.dispatchStreaming( { id: 'req-gate', authToken: 'token', method: 'agentSession.create', params: attachParams(null) }, (raw) => replies.push(JSON.parse(raw)), { clientKind: 'runtime', clientCapabilities: ['terminal.stream.v1'] } ) expect(replies[0]).toMatchObject({ ok: false }) // Building the host is itself observable — it opens a store and spawns a // child — so the gate has to run before it, not after. expect(getStructuredAgentSessionHost()).toBeNull() expect(codex.connections).toEqual([]) }) it('joins final deferred writes before runtime teardown completes', async () => { await ok<{ fence: number }>('agentSession.create', createIntentParams()) codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) codex.notify('item/started', { threadId: THREAD, turnId: TURN, item: { type: 'agentMessage', id: 'item-final', text: '' } }) await drainStreamedEvents() codex.notify('item/agentMessage/delta', { threadId: THREAD, turnId: TURN, itemId: 'item-final', delta: 'Final text before shutdown.' }) const host = getStructuredAgentSessionHost() const journal = ( host as unknown as { sessions: Map } ).sessions.get(SESSION)!.journal const appendEntered = Promise.withResolvers() const appendGate = Promise.withResolvers() const originalAppend = journal.appendItem.bind(journal) vi.spyOn(journal, 'appendItem').mockImplementationOnce(async (...args) => { appendEntered.resolve() await appendGate.promise return originalAppend(...args) }) let stopped = false const stopping = stopStructuredAgentSessionRuntime().then(() => { stopped = true }) await appendEntered.promise await new Promise((resolve) => setImmediate(resolve)) const waitedForFinalAppend = !stopped appendGate.resolve() await stopping expect(waitedForFinalAppend).toBe(true) const reopened = await journals.open({ identity: JOURNAL_IDENTITY, stateDirectory: root }) expect(reopened.snapshot().items.map(textOf)).toContain('Final text before shutdown.') expect( reopened .snapshot() .items.some( (item) => item.body?.kind === 'status' && item.body.turnLifecycle?.state === 'running' ) ).toBe(false) }) it('persists complete command output while publishing a shortened copy', async () => { await ok<{ fence: number }>('agentSession.create', createIntentParams()) const stream = await subscribe('sub-large-output') const output = 'large command output\n'.repeat(2_000) codex.notify('item/completed', { threadId: THREAD, turnId: TURN, item: { type: 'commandExecution', id: 'item-large-output', command: 'print-many-lines', status: 'completed', exitCode: 0, aggregatedOutput: output } }) await drainStreamedEvents() const snapshot = await getStructuredAgentSessionHost()?.journalSnapshot(SESSION) const item = snapshot?.items.find((candidate) => candidate.body?.kind === 'tool-call') const bounded = item?.body?.kind === 'tool-call' ? item.body.output : undefined expect(bounded).toMatchObject({ head: output, truncated: false, byteLength: output.length }) const published = itemsOf(stream).find((candidate) => candidate.itemId === item?.itemId) expect(published?.shortened).toBe(true) expect(published?.body).toMatchObject({ output: { head: output.slice(0, 16 * 1024), truncated: true, byteLength: output.length } }) }) it('keeps an answered prompt resolved after the provider exits', async () => { const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) codex.notify('item/started', { threadId: THREAD, turnId: TURN, item: { type: 'commandExecution', id: 'item-needs-answer', command: 'build', status: 'inProgress' } }) codex.ask(9, 'item/commandExecution/requestApproval', { threadId: THREAD, turnId: TURN, itemId: 'item-needs-answer', availableDecisions: ['accept', 'decline'] }) await drainStreamedEvents() const host = getStructuredAgentSessionHost() let snapshot = await host?.journalSnapshot(SESSION) const approval = snapshot?.items.find((item) => item.body?.kind === 'approval') expect(approval?.body).toMatchObject({ kind: 'approval', resolution: { state: 'pending' } }) await ok('agentSession.respondToApproval', { envelope: envelope( 'agentSession.respondTo:approval', { itemId: approval?.itemId, expectedRevision: approval?.revision, optionId: 'accept' }, created.fence ), itemId: approval?.itemId, expectedRevision: approval?.revision, optionId: 'accept' }) codex.live().handlers.onExit?.(new Error('provider exited after answer')) await drainStreamedEvents() snapshot = await host?.journalSnapshot(SESSION) expect(snapshot?.items.find((item) => item.itemId === approval?.itemId)?.body).toMatchObject({ kind: 'approval', resolution: { state: 'resolved', selectedOptionId: 'accept' } }) }) })