Files
orca/src/main/codex/codex-structured-session-cancel.test.ts
T
Merge Sim ff55fccd80 Make the structured turn lifecycle row durable so completed durations survive
A structured-chat turn used to end by tombstoning its running lifecycle item,
which threw away the only durable record of when the turn ended. Completed
"Worked for" labels therefore depended on the renderer having observed the
turn finish, and vanished on reopen.

The lifecycle item is now revised in place, never tombstoned:
- running, with startedAt, at the provider's turn start
- completed or interrupted, with completedAt, at the provider's terminal frame,
  a user stop, or a child exit the host observed
- unverifiable, with no end, when a cold acquire finds a running row from a
  generation whose exit nobody observed

Both timestamps are the execution host's clock at receipt, captured before the
deferred sink, so the completed value is identical on every client and needs
no client clock. Codex history restore uses the provider's own second-granular
endpoints for turns that predate this change. Desktop and mobile read settled
durations off the journal through one shared selector, and anchor the live
counter on the host start with the client's local receipt so a skewed client
clock never leaks into the label. Locally observed durations remain the
fallback for hosts that still tombstone.

Timestamps live inside the existing turnLifecycle field, which old clients
strip, and every working-state consumer keys on state === 'running', so no
capability negotiation is needed.
2026-09-09 00:06:41 -07:00

309 lines
10 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import type {
AgentJournalMessageItem,
AgentSessionJournalIdentity
} from '../../shared/agent-session-journal-types'
import {
CodexAppServerRequestError,
type CodexAppServerConnection,
type CodexAppServerConnectionHandlers,
type CodexAppServerLaunch,
type openCodexAppServerConnection
} from './codex-app-server-connection'
import { CodexAppServerUnsupportedError } from './codex-app-server-session'
import {
CodexStructuredSessionAdapter,
type CodexStructuredSessionAdapterDeps,
type CodexStructuredSessionEvent
} from './codex-structured-session-adapter'
const THREAD_ID = 'thread-abc'
const USER_MESSAGE: AgentJournalMessageItem = {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'ship it' }]
}
type Route = (params: Record<string, unknown> | undefined) => unknown
type FakeConnection = Omit<CodexAppServerConnection, 'closed'> & {
closed: boolean
launch: CodexAppServerLaunch
handlers: CodexAppServerConnectionHandlers
calls: { method: string; params?: Record<string, unknown> }[]
}
function identity(): AgentSessionJournalIdentity {
return {
sessionId: 'session-1',
workspaceId: 'ws-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: THREAD_ID }
}
}
function fakeCodex(): {
connections: FakeConnection[]
openConnection: typeof openCodexAppServerConnection
routes: Record<string, Route>
} {
const connections: FakeConnection[] = []
const routes: Record<string, Route> = {
'thread/resume': () => ({ thread: { id: THREAD_ID } })
}
const openConnection = (async (launch, handlers = {}) => {
const connection: FakeConnection = {
launch,
handlers,
calls: [],
pid: 4321,
closed: false,
request: async (method, params) => {
connection.calls.push({ method, params })
return routes[method]?.(params) ?? {}
},
notify: () => {},
respond: () => {},
respondWithError: () => {},
close: async () => {
connection.closed = true
return true
}
}
connections.push(connection)
return connection
}) as typeof openCodexAppServerConnection
return { connections, openConnection, routes }
}
async function acquired(
codex: ReturnType<typeof fakeCodex>,
events: CodexStructuredSessionEvent[] = [],
processControl: Partial<
Pick<
CodexStructuredSessionAdapterDeps,
'captureTurnProcesses' | 'terminateTurnProcesses' | 'now'
>
> = {}
): Promise<CodexStructuredSessionAdapter> {
const adapter = new CodexStructuredSessionAdapter({
resolveLaunch: async () => ({
command: 'codex',
args: ['app-server'],
cwd: '/work/repo',
codexHome: null,
resumeThreadId: THREAD_ID
}),
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,
...processControl
})
await adapter.acquire({ identity: identity(), fence: 7, spawnToken: 'spawn-9' })
return adapter
}
function completeTurn(codex: ReturnType<typeof fakeCodex>, turnId = 'turn-1'): void {
codex.connections[0].handlers.onNotification?.('turn/completed', {
threadId: THREAD_ID,
turn: { id: turnId, status: 'interrupted' }
})
}
describe('CodexStructuredSessionAdapter.cancelTurn', () => {
it('confirms an interrupt Codex acknowledged', async () => {
const codex = fakeCodex()
const adapter = await acquired(codex)
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 })
).resolves.toEqual({ cancelled: true })
expect(codex.connections[0].calls.at(-1)).toEqual({
method: 'turn/interrupt',
params: { threadId: THREAD_ID, turnId: 'turn-1' }
})
})
it('reports not-cancelled when Codex declines or lacks the method', async () => {
const declined = fakeCodex()
declined.routes['turn/interrupt'] = () => {
throw new CodexAppServerRequestError('turn/interrupt', -32602, 'no such turn')
}
const absent = fakeCodex()
absent.routes['turn/interrupt'] = () => {
throw new CodexAppServerUnsupportedError('no turn/interrupt')
}
await expect(
(await acquired(declined)).cancelTurn({
sessionId: 'session-1',
turnId: 'turn-1',
fence: 7
})
).resolves.toEqual({ cancelled: false })
await expect(
(await acquired(absent)).cancelTurn({
sessionId: 'session-1',
turnId: 'turn-1',
fence: 7
})
).resolves.toEqual({ cancelled: false })
})
it('rethrows an unsettled interrupt so the turn is not shown as cancelled', async () => {
const codex = fakeCodex()
codex.routes['turn/interrupt'] = () => {
throw new Error('codex app-server turn/interrupt exceeded 30000ms')
}
await expect(
(await acquired(codex)).cancelTurn({
sessionId: 'session-1',
turnId: 'turn-1',
fence: 7
})
).rejects.toThrow('exceeded 30000ms')
})
it('publishes terminal state only after streaming interruption is physically settled', async () => {
const events: CodexStructuredSessionEvent[] = []
let finishTermination!: (terminated: boolean) => void
const termination = new Promise<boolean>((resolve) => {
finishTermination = resolve
})
const codex = fakeCodex()
codex.routes['turn/interrupt'] = () => {
completeTurn(codex)
return {}
}
const adapter = await acquired(codex, events, {
terminateTurnProcesses: async () => termination
})
codex.connections[0].handlers.onNotification?.('item/agentMessage/delta', {
threadId: THREAD_ID,
turnId: 'turn-1',
itemId: 'item-1',
delta: 'still streaming'
})
const pending = adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 })
await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('turn/interrupt'))
expect(events).toContainEqual(expect.objectContaining({ method: 'item/agentMessage/delta' }))
expect(events).not.toContainEqual(expect.objectContaining({ method: 'turn/completed' }))
finishTermination(true)
await expect(pending).resolves.toEqual({ cancelled: true })
expect(events.at(-1)).toMatchObject({ method: 'turn/completed' })
})
it('starts physical termination without waiting for the interrupt receipt', async () => {
let finishInterrupt!: () => void
const interruptReceipt = new Promise<void>((resolve) => {
finishInterrupt = resolve
})
const terminateTurnProcesses = vi.fn(async () => true)
const codex = fakeCodex()
codex.routes['turn/interrupt'] = () => interruptReceipt
const adapter = await acquired(codex, [], { terminateTurnProcesses })
const pending = adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 })
await vi.waitFor(() => expect(terminateTurnProcesses).toHaveBeenCalledOnce())
finishInterrupt()
await expect(pending).resolves.toEqual({ cancelled: true })
})
it('keeps the turn live when process termination cannot be verified', async () => {
const events: CodexStructuredSessionEvent[] = []
const codex = fakeCodex()
codex.routes['turn/interrupt'] = () => {
completeTurn(codex)
return {}
}
const adapter = await acquired(codex, events, {
terminateTurnProcesses: async () => false
})
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 })
).resolves.toEqual({ cancelled: false })
expect(events).toContainEqual(expect.objectContaining({ method: 'turn/completed' }))
})
it('accepts an immediate resend after verified interruption', async () => {
let nextTurn = 0
const codex = fakeCodex()
codex.routes['turn/start'] = () => ({ turn: { id: `turn-${++nextTurn}` } })
codex.routes['turn/interrupt'] = () => {
completeTurn(codex)
return {}
}
const adapter = await acquired(codex)
await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: USER_MESSAGE,
fence: 7
})
await adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 })
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-2',
body: USER_MESSAGE,
fence: 7
})
).resolves.toMatchObject({
state: 'accepted',
providerIdentity: { turnId: 'turn-2' }
})
})
it('keeps the receipt time of a completion deferred behind physical termination', async () => {
const events: CodexStructuredSessionEvent[] = []
let clock = 5_000
let finishTermination!: (terminated: boolean) => void
const termination = new Promise<boolean>((resolve) => {
finishTermination = resolve
})
const codex = fakeCodex()
codex.routes['turn/interrupt'] = () => {
completeTurn(codex)
return {}
}
const adapter = await acquired(codex, events, {
terminateTurnProcesses: async () => termination,
now: () => clock
})
const pending = adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 })
await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('turn/interrupt'))
clock = 9_000
finishTermination(true)
await expect(pending).resolves.toEqual({ cancelled: true })
expect(events.at(-1)).toMatchObject({ method: 'turn/completed', observedAt: 5_000 })
})
it('does not strand a deferred completion when the interrupt receipt fails', async () => {
const events: CodexStructuredSessionEvent[] = []
const codex = fakeCodex()
codex.routes['turn/interrupt'] = () => {
completeTurn(codex)
throw new Error('interrupt receipt lost')
}
const adapter = await acquired(codex, events, {
terminateTurnProcesses: async () => true
})
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 })
).rejects.toThrow('interrupt receipt lost')
expect(events).toContainEqual(expect.objectContaining({ method: 'turn/completed' }))
})
})