mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
* test: remove repeated waits and fixture work * test: give pure queue pause comparisons a unique suite title * test(ci): remove repeated fixtures and deadline waits
424 lines
16 KiB
TypeScript
424 lines
16 KiB
TypeScript
// Where a send a Stop took back is drawn: after the turn it waited on, never above that turn's own
|
|
// rows, and never below a later exchange, a restart included. Codex's resume rewrites every
|
|
// finished turn's start time to its own whole seconds, so the order is decided by the journal alone.
|
|
// Driven through the shipped host, journal and Codex adapter; only the Codex child is fake.
|
|
|
|
import { mkdtemp, rm } 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 { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake'
|
|
import { settledWithin } from '../codex/codex-structured-dispatch-test-support'
|
|
import type * as CodexTurnOpenWait from '../codex/codex-structured-turn-open-wait'
|
|
import { CODEX_TURN_OPEN_WAIT_MS } from '../codex/codex-structured-turn-open-wait'
|
|
import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key'
|
|
import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope'
|
|
import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types'
|
|
import { readAgentJournalTurn } from '../../shared/agent-session-turn-record'
|
|
import { NATIVE_CHAT_STOPPED_BEFORE_START_TEXT } from '../../shared/native-chat-stopped-before-start'
|
|
import { projectNativeChatTranscript } from '../../shared/native-chat-transcript-projection'
|
|
import { nativeChatRowsInDrawOrder } from '../../shared/native-chat-turn-grouping'
|
|
import {
|
|
nativeChatTurnMembership,
|
|
structuredAgentTurnAnchors
|
|
} from '../../shared/native-chat-turn-membership'
|
|
import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection'
|
|
import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection'
|
|
import {
|
|
HOST_TEST_SESSION as SESSION,
|
|
HOST_TEST_THREAD as THREAD,
|
|
hostTestAttachParams,
|
|
hostTestMessage
|
|
} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
|
|
import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
|
|
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
|
|
import {
|
|
ensureStructuredAgentSessionHost,
|
|
stopStructuredAgentSessionRuntime
|
|
} from './structured-agent-session-runtime'
|
|
import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture'
|
|
|
|
const openWaits = vi.hoisted(() => {
|
|
const state: {
|
|
turnIds: string[]
|
|
ended: string[]
|
|
began: (() => void) | null
|
|
releaseAll: (() => void) | null
|
|
} = { turnIds: [], ended: [], began: null, releaseAll: null }
|
|
return state
|
|
})
|
|
vi.mock('../codex/codex-structured-turn-open-wait', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof CodexTurnOpenWait>()
|
|
return {
|
|
...actual,
|
|
createCodexTurnOpenWaits: () => {
|
|
const waits = actual.createCodexTurnOpenWaits()
|
|
openWaits.releaseAll = waits.releaseAll
|
|
return {
|
|
...waits,
|
|
wait: (turnId: string, withinMs: number) => {
|
|
openWaits.turnIds.push(turnId)
|
|
const pending = waits.wait(turnId, withinMs)
|
|
openWaits.began?.()
|
|
return pending.then(() => {
|
|
openWaits.ended.push(turnId)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
const CALLER = { callerKey: 'codex-stopped-send-resume-test' }
|
|
const MODEL = {
|
|
model: 'gpt-test',
|
|
displayName: 'GPT Test',
|
|
hidden: false,
|
|
supportedReasoningEfforts: [],
|
|
defaultReasoningEffort: null,
|
|
isDefault: true
|
|
}
|
|
|
|
let root: string
|
|
let host: StructuredAgentSessionHost
|
|
let fence: number
|
|
let handlers: CodexAppServerConnectionHandlers | undefined
|
|
let answers: number
|
|
let resumes: number
|
|
/** What `thread/resume` reports as the thread's turns, as Codex does: times in whole seconds. */
|
|
let resumeTurns: unknown[] | null
|
|
let turns: ReturnType<typeof codexTurnLifecycleFake>
|
|
/** Codex takes the interrupt and answers it; the test streams more and sends the end later. */
|
|
let interruptEndHeld: boolean
|
|
let operations = 0
|
|
|
|
/** The durable ledger stamps its own clock and refuses an id far from it. */
|
|
const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}`
|
|
|
|
function envelope(method: string, fields: Record<string, unknown>) {
|
|
return {
|
|
sessionId: SESSION,
|
|
clientOperationId: operationId(),
|
|
expectedRuntimeFence: fence,
|
|
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
|
method,
|
|
sessionId: SESSION,
|
|
fields
|
|
})
|
|
}
|
|
}
|
|
|
|
async function send(text: string): Promise<string> {
|
|
const body = hostTestMessage(text)
|
|
const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
|
|
if (!sent.ok) {
|
|
throw new Error(JSON.stringify(sent.refusal))
|
|
}
|
|
return sent.value.clientMessageId
|
|
}
|
|
|
|
/** The chat's Stop; `turnId` names the turn it stops, as the phone does. */
|
|
function stop(turnId?: string): Promise<unknown> {
|
|
const fields = turnId === undefined ? {} : { turnId }
|
|
return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields })
|
|
}
|
|
|
|
function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessageId: string) {
|
|
const submission = submissions.find((entry) => entry.clientMessageId === clientMessageId)
|
|
return submission?.dispatchState === 'rejected'
|
|
? classifyDispatchRejection(submission).category
|
|
: submission?.dispatchState
|
|
}
|
|
|
|
async function submissions(): Promise<readonly AgentJournalSubmission[]> {
|
|
await host.flushStreamedEvents(SESSION)
|
|
return (await host.journalSnapshot(SESSION)).submissions
|
|
}
|
|
|
|
/** Each row's text, in the order every client draws the transcript, turns grouped. */
|
|
async function drawn(): Promise<string[]> {
|
|
await host.flushStreamedEvents(SESSION)
|
|
const { items, submissions } = await host.journalSnapshot(SESSION)
|
|
const journal = { items, submissions }
|
|
const { conversation } = projectNativeChatTranscript(
|
|
projectStructuredAgentSessionMessages(items, [], submissions),
|
|
undefined,
|
|
journal
|
|
)
|
|
return nativeChatRowsInDrawOrder(
|
|
conversation,
|
|
nativeChatTurnMembership(conversation, journal).drawOrder
|
|
).map((message) => message.blocks.map((block) => ('text' in block ? block.text : '')).join(''))
|
|
}
|
|
|
|
const openConnection: typeof openCodexAppServerConnection = async (
|
|
_launch,
|
|
connectionHandlers = {}
|
|
) => {
|
|
handlers = connectionHandlers
|
|
const connection: CodexAppServerConnection = {
|
|
pid: 4321,
|
|
closed: false,
|
|
request: async (method, params) => {
|
|
if (method === 'thread/resume') {
|
|
resumes += 1
|
|
return { thread: { id: THREAD, ...(resumeTurns ? { turns: resumeTurns } : {}) } }
|
|
}
|
|
if (method === 'thread/start') {
|
|
return { thread: { id: THREAD } }
|
|
}
|
|
if (method === 'model/list') {
|
|
return { data: [MODEL], nextCursor: null }
|
|
}
|
|
if (method === 'turn/start') {
|
|
answers += 1
|
|
return turns.routes['turn/start']()
|
|
}
|
|
if (method === 'turn/steer') {
|
|
return turns.routes['turn/steer'](params)
|
|
}
|
|
if (method === 'turn/interrupt' && interruptEndHeld) {
|
|
turns.takeInterrupt()
|
|
return {}
|
|
}
|
|
if (method === 'turn/interrupt') {
|
|
return turns.routes['turn/interrupt'](params)
|
|
}
|
|
return {}
|
|
},
|
|
notify: () => {},
|
|
respond: () => {},
|
|
respondWithError: () => {},
|
|
close: async () => true
|
|
}
|
|
return connection
|
|
}
|
|
|
|
async function startHost(): Promise<void> {
|
|
host = await ensureStructuredAgentSessionHost({
|
|
logger: createStructuredAgentSessionLogger(),
|
|
stateDirectory: root,
|
|
hostId: 'local',
|
|
claimKeyId: 'key-1',
|
|
resolveWorkspacePath: async () => root,
|
|
resolveLaunchArgs: () => [],
|
|
resolveClaudeAuthPolicy: () => ({ account: 'managed' }),
|
|
resolveCodexCommand: () => 'codex',
|
|
resolveEnvironment: async () => ({ PATH: process.env.PATH }),
|
|
...scriptedCodexTransport(openConnection)
|
|
})
|
|
}
|
|
|
|
async function attach(expected: number | null): Promise<void> {
|
|
const params = hostTestAttachParams(expected, { providerHandle: undefined })
|
|
params.envelope.clientOperationId = operationId()
|
|
const attached = await host.attach(CALLER, params)
|
|
if (attached.ok) {
|
|
fence = attached.value.fence
|
|
return
|
|
}
|
|
const refusal = JSON.stringify(attached.refusal)
|
|
const current = /"currentFence":(\d+)/.exec(refusal)?.[1]
|
|
if (current === undefined) {
|
|
throw new Error(refusal)
|
|
}
|
|
await attach(Number(current))
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
openWaits.turnIds = []
|
|
openWaits.ended = []
|
|
openWaits.began = null
|
|
openWaits.releaseAll = null
|
|
root = await mkdtemp(join(tmpdir(), 'orca-codex-stopped-send-resume-'))
|
|
answers = 0
|
|
resumes = 0
|
|
resumeTurns = null
|
|
interruptEndHeld = false
|
|
turns = codexTurnLifecycleFake(
|
|
THREAD,
|
|
() => (method, params) => handlers?.onNotification?.(method, params)
|
|
)
|
|
await startHost()
|
|
await attach(null)
|
|
})
|
|
|
|
afterEach(async () => {
|
|
vi.useRealTimers()
|
|
openWaits.began = null
|
|
openWaits.releaseAll?.()
|
|
await stopStructuredAgentSessionRuntime()
|
|
await rm(root, { recursive: true, force: true })
|
|
})
|
|
|
|
describe("a send made while Codex held the first send's answer, then a Stop", () => {
|
|
it("is drawn after the first send's interrupted turn, which streamed after the Stop", async () => {
|
|
const release = turns.holdNextAnswer()
|
|
const first = await send('first')
|
|
await vi.waitFor(() => expect(answers).toBe(1))
|
|
// Both wait on the session behind the held answer: the second send, then the Stop.
|
|
const second = send('second')
|
|
const stopping = stop()
|
|
interruptEndHeld = true
|
|
release()
|
|
const secondId = await second
|
|
await vi.waitFor(async () => expect(verdictOf(await submissions(), secondId)).toBe('withdrawn'))
|
|
turns.start()
|
|
turns.echo(first)
|
|
// The turn opened after the second send was accepted, and streams after the Stop was pressed.
|
|
handlers?.onNotification?.('item/completed', {
|
|
threadId: THREAD,
|
|
turn: { id: 'turn-1' },
|
|
item: { type: 'agentMessage', id: 'streamed', text: 'streamed after the Stop' }
|
|
})
|
|
await settledWithin(stopping, 3_000)
|
|
turns.end('interrupted')
|
|
await vi.waitFor(async () =>
|
|
expect(
|
|
(await host.journalSnapshot(SESSION)).items
|
|
.map((item) => readAgentJournalTurn(item.body))
|
|
.find((turn) => turn?.turnId === 'turn-1')?.state
|
|
).toBe('interrupted')
|
|
)
|
|
|
|
// The Stop's own note is the host's to place; the order here is the stopped send's.
|
|
const rows = (await drawn()).filter((text) => text !== 'Cancellation requested.')
|
|
expect(rows.slice(rows.indexOf('first'))).toEqual([
|
|
'first',
|
|
'streamed after the Stop',
|
|
'second',
|
|
NATIVE_CHAT_STOPPED_BEFORE_START_TEXT
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('a send a Stop took back, then a send made while it read Stopping', () => {
|
|
it('keeps the stopped send above the later exchange after a restart resumes the thread', async () => {
|
|
const first = await send('look around')
|
|
await vi.waitFor(() => expect(answers).toBe(1))
|
|
// Codex has answered but not opened the turn, so the Stop waits for it to open.
|
|
const stopping = stop()
|
|
expect(await settledWithin(stopping, 500)).toBe('held')
|
|
const second = await send('sent while stopping')
|
|
turns.start()
|
|
await settledWithin(stopping, 3_000)
|
|
await vi.waitFor(async () => expect(verdictOf(await submissions(), first)).toBe('withdrawn'))
|
|
await vi.waitFor(() => expect(answers).toBe(2))
|
|
turns.start()
|
|
turns.echo(second)
|
|
turns.end('completed')
|
|
await vi.waitFor(async () => expect(verdictOf(await submissions(), second)).toBe('accepted'))
|
|
const live = await drawn()
|
|
expect(live).toEqual([
|
|
'look around',
|
|
NATIVE_CHAT_STOPPED_BEFORE_START_TEXT,
|
|
'sent while stopping'
|
|
])
|
|
|
|
// Restart: the resume reports each finished turn with its start in Codex's whole seconds.
|
|
resumeTurns = (await host.journalSnapshot(SESSION)).items.flatMap((item) => {
|
|
const turn = readAgentJournalTurn(item.body)
|
|
return turn?.startedAt !== undefined && turn.turnId.startsWith('turn-')
|
|
? [
|
|
{
|
|
id: turn.turnId,
|
|
status: 'completed',
|
|
startedAt: Math.floor(turn.startedAt / 1000),
|
|
completedAt: Math.ceil((turn.completedAt ?? turn.startedAt) / 1000) + 1,
|
|
items: []
|
|
}
|
|
]
|
|
: []
|
|
})
|
|
await stopStructuredAgentSessionRuntime()
|
|
await startHost()
|
|
await attach(fence)
|
|
await vi.waitFor(() => expect(resumes).toBeGreaterThan(0))
|
|
await vi.waitFor(async () => {
|
|
const restored = (await host.journalSnapshot(SESSION)).items
|
|
.map((item) => readAgentJournalTurn(item.body)?.startedAt)
|
|
.find((startedAt) => startedAt !== undefined)
|
|
expect((restored ?? 1) % 1000).toBe(0)
|
|
})
|
|
|
|
expect(await drawn()).toEqual(live)
|
|
})
|
|
})
|
|
|
|
describe('a send a Stop took back before Codex echoed it', () => {
|
|
it('is drawn as the opener of the turn Codex opened for it, with no row of its own', async () => {
|
|
const sent = await send('look around')
|
|
await vi.waitFor(() => expect(answers).toBe(1))
|
|
turns.start()
|
|
await stop('turn-1')
|
|
await vi.waitFor(async () => expect(verdictOf(await submissions(), sent)).toBe('withdrawn'))
|
|
|
|
// Codex opened its turn before echoing it, so that turn, ended interrupted, is the send's: it
|
|
// carries the stop, and no row of its own follows the send.
|
|
const snapshot = await host.journalSnapshot(SESSION)
|
|
const turnRecord = snapshot.items.find(
|
|
(item) => readAgentJournalTurn(item.body)?.turnId === 'turn-1'
|
|
)
|
|
expect(
|
|
structuredAgentTurnAnchors(snapshot.items, snapshot.submissions).get(turnRecord!.itemId)
|
|
).toBe(agentJournalSubmissionKey(sent))
|
|
expect(await drawn()).toEqual(['look around', 'Cancellation requested.'])
|
|
})
|
|
|
|
it('is drawn where it was sent, then the one row saying it never started, when no turn opened', async () => {
|
|
const warmUp = await send('warm up')
|
|
await vi.waitFor(() => expect(answers).toBe(1))
|
|
turns.start()
|
|
turns.echo(warmUp)
|
|
turns.end('completed')
|
|
const release = turns.holdNextAnswer()
|
|
const sent = await send('look around')
|
|
await vi.waitFor(() => expect(answers).toBe(2))
|
|
await host.flushStreamedEvents(SESSION)
|
|
const began = Promise.withResolvers<void>()
|
|
const registeredBeforeStop = openWaits.turnIds.length
|
|
const endedBeforeStop = openWaits.ended.length
|
|
openWaits.began = began.resolve
|
|
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
|
let stopSettled = false
|
|
const stopping = stop().finally(() => {
|
|
stopSettled = true
|
|
})
|
|
try {
|
|
release()
|
|
await began.promise
|
|
expect(openWaits.turnIds.slice(registeredBeforeStop)).toEqual(['turn-2'])
|
|
await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 1)
|
|
expect(openWaits.ended.slice(endedBeforeStop)).toEqual([])
|
|
expect(stopSettled).toBe(false)
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
expect(openWaits.ended.slice(endedBeforeStop)).toEqual(['turn-2'])
|
|
vi.useRealTimers()
|
|
// The Stop waits out the turn-open wait for a turn that never opens, then ends there.
|
|
expect(await settledWithin(stopping, 2_000)).not.toBe('held')
|
|
} finally {
|
|
vi.useRealTimers()
|
|
openWaits.began = null
|
|
release()
|
|
openWaits.releaseAll?.()
|
|
await stopping
|
|
}
|
|
expect(verdictOf(await submissions(), sent)).toBe('withdrawn')
|
|
|
|
// Every client draws the send where it was sent, then the one row saying it never started.
|
|
await host.flushStreamedEvents(SESSION)
|
|
const { items, submissions: settled } = await host.journalSnapshot(SESSION)
|
|
const messages = projectStructuredAgentSessionMessages(items, [], settled)
|
|
expect(messages.slice(-2).map((message) => [message.role, message.blocks])).toEqual([
|
|
['user', [{ type: 'text', text: 'look around' }]],
|
|
['system', [expect.objectContaining({ text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT })]]
|
|
])
|
|
expect(await drawn()).toEqual(['warm up', 'look around', NATIVE_CHAT_STOPPED_BEFORE_START_TEXT])
|
|
})
|
|
})
|