Files
orca/src/main/codex/codex-structured-session-close.test.ts
T
Merge Sim e980959aef test(codex): type the dispatch fixtures instead of asserting over them
main's new casting gate (#20367 base) flags type assertions on changed
lines. Replace them with checked types: the recording sink already
satisfies its interface, both CodexSession fixtures are now annotated and
carry real collaborators, the settlement assertion compares whole
identities, and the integration helper reads submissions through the
host's public journalSnapshot instead of its private session map.
2026-09-13 13:39:20 -07:00

208 lines
7.2 KiB
TypeScript

import { createCodexDispatchEchoes } from './codex-structured-dispatch-echo'
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import type {
CodexAppServerConnection,
CodexAppServerConnectionHandlers,
openCodexAppServerConnection
} from './codex-app-server-connection'
import {
CodexStructuredSessionAdapter,
type CodexStructuredSessionEvent
} from './codex-structured-session-adapter'
import { handleCodexSessionExit } from './codex-structured-session-close'
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
import { CodexPromptRegistry } from './codex-structured-prompt-replies'
import type { CodexSession } from './codex-structured-session-state'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router'
const THREAD = 'thread-1'
function identity(sessionId: string): AgentSessionJournalIdentity {
return {
sessionId,
workspaceId: 'workspace-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: THREAD }
}
}
function adapterFixture() {
const connections: {
connection: CodexAppServerConnection
handlers: CodexAppServerConnectionHandlers
}[] = []
const events: CodexStructuredSessionEvent[] = []
let generation = 0
const openConnection = (async (_launch, handlers = {}) => {
const connection: CodexAppServerConnection = {
pid: 4321,
closed: false,
request: async (method) => (method === 'thread/start' ? { thread: { id: THREAD } } : {}),
notify: () => {},
respond: () => {},
respondWithError: () => {},
close: async () => true
}
connections.push({ connection, handlers })
return connection
}) as typeof openCodexAppServerConnection
const adapter = new CodexStructuredSessionAdapter({
resolveLaunch: async () => ({
command: 'codex',
args: ['app-server'],
cwd: '/workspace',
codexHome: null,
resumeThreadId: null
}),
openConnection,
readProcessStartTime: async () => 1_700_000_000_000,
mintAcquisitionGeneration: () => `generation-${++generation}`,
onEvent: (event) => events.push(event)
})
return { adapter, connections, events }
}
function claudeAdapterStub(): StructuredAgentSessionAdapter {
return {
acquire: vi.fn(async () => ({ process: { pid: 1 } }) as never),
dispatch: vi.fn(),
cancelTurn: vi.fn(),
answerPrompt: vi.fn(),
setOption: vi.fn()
}
}
describe('Codex structured session close lifecycle', () => {
it('forwards a one-shot exit when lifecycle admission is rejected', () => {
const connection: CodexAppServerConnection = {
pid: 4321,
closed: true,
request: async () => ({}),
notify: () => {},
respond: () => {},
respondWithError: () => {},
close: async () => true
}
const prompts = new CodexPromptRegistry()
const clearPrompts = vi.spyOn(prompts, 'clear')
const translator = {
handle: vi.fn().mockReturnValueOnce({ accepted: false, reason: 'backpressure' as const }),
dispose: vi.fn()
} as unknown as NonNullable<CodexSession['translator']>
const session: CodexSession = {
connection,
backgroundTasks: new CodexBackgroundTaskTracker('thread-1'),
ended: false,
requestedClose: false,
fence: 7,
acquisitionGeneration: 'generation-1',
threadId: THREAD,
historyPath: null,
prompts,
options: new Map(),
reportedOptions: {},
dispatchEchoes: createCodexDispatchEchoes(),
translator
}
const sessions = new Map([['session-1', session]])
const onEvent = vi.fn()
expect(
handleCodexSessionExit({
sessions,
sessionId: 'session-1',
connection,
error: new Error('provider exited'),
prompts,
onEvent
})
).toBe(true)
expect(session.ended).toBe(true)
expect(clearPrompts).toHaveBeenCalledOnce()
expect(onEvent).toHaveBeenCalledOnce()
expect(translator.dispose).toHaveBeenCalledOnce()
expect(onEvent.mock.calls[0]?.[0]).toMatchObject({
cause: 'unexpected-exit',
settlementRetryRequired: true
})
expect(translator.handle).toHaveBeenCalledOnce()
})
it('mints a distinct child generation even when acquisitions share one fence', async () => {
const { adapter } = adapterFixture()
const input = { identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' }
const first = await adapter.acquire(input)
const second = await adapter.acquire(input)
expect(first.acquisitionGeneration).toBe('generation-1')
expect(second.acquisitionGeneration).toBe('generation-2')
})
it('distinguishes an observed provider death from a requested close', async () => {
const { adapter, connections, events } = adapterFixture()
await adapter.acquire({ identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' })
connections[0]?.handlers.onExit?.(new Error('provider exited'))
await adapter.acquire({ identity: identity('session-2'), fence: 9, spawnToken: 'spawn-2' })
await adapter.closeSession('session-2')
expect(events.filter((event) => event.type === 'ended')).toMatchObject([
{
cause: 'unexpected-exit',
fence: 7,
acquisitionGeneration: 'generation-1'
},
{
cause: 'requested-close',
fence: 9,
acquisitionGeneration: 'generation-2'
}
])
})
it('force-close preserves unexpected-exit evidence when the adapter reports exit during close', async () => {
const { adapter, connections, events } = adapterFixture()
await adapter.acquire({ identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' })
const current = connections[0]
if (!current) {
throw new Error('missing connection')
}
current.connection.close = async () => {
current.handlers.onExit?.(new Error('sink failed'))
return true
}
await expect(adapter.forceCloseSession?.('session-1')).resolves.toBe(true)
expect(events.filter((event) => event.type === 'ended')).toMatchObject([
{ cause: 'unexpected-exit', reason: 'sink failed', fence: 7 }
])
})
it('routes Codex sink-failure recovery through force-close and preserves unexpected-exit settlement', async () => {
const { adapter, connections, events } = adapterFixture()
const router = new StructuredAgentSessionAdapterRouter(
{ claude: claudeAdapterStub(), codex: adapter },
async () => {}
)
await router.acquire({ identity: identity('session-1'), fence: 7, spawnToken: 'spawn-1' })
const current = connections[0]
if (!current) {
throw new Error('missing connection')
}
current.connection.close = async () => {
current.handlers.onExit?.(new Error('journal sink failed'))
return true
}
const forceCloseSession = router.forceCloseSession
await expect(forceCloseSession('session-1')).resolves.toBe(true)
expect(events.filter((event) => event.type === 'ended')).toMatchObject([
{ cause: 'unexpected-exit', reason: 'journal sink failed', fence: 7 }
])
})
})