mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 16:02:43 +00:00
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.
208 lines
7.2 KiB
TypeScript
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 }
|
|
])
|
|
})
|
|
})
|