Files
orca/src/main/codex/codex-structured-turn-start.ts
T
Brennan Benson 61cb67c7c5 Translate Codex app-server events into journal rows
The adapter now writes what Codex streams straight into the session's
journal, so nothing between the child and the durable record has to remember
state: streamed text folds through the coalescing window, lifecycle events
flush it first, and every write is a highest-revision-wins upsert on the
durable key, which is what lets a completed body land on the row its own
deltas opened.

Journal first, observers second — a tap must never see a row the journal has
not been told about. A session with no sink still runs, unjournaled, which is
how the existing adapter tests keep working unchanged.

Starting or resuming the thread moved into its own module on the way past the
adapter's line cap; the check it carries is unchanged, and still refuses a
resume that lands on a different thread, since recording that would make the
durable handle chain lie about what the session proved.
2026-08-09 16:54:49 -07:00

129 lines
4.7 KiB
TypeScript

import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import type { NativeChatBlock } from '../../shared/native-chat-types'
import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import {
isCodexAppServerRequestError,
type CodexAppServerConnection
} from './codex-app-server-connection'
import { isCodexAppServerUnsupportedError } from './codex-app-server-session'
import { readCodexTurnId } from './codex-structured-thread-facts'
// Starting a Codex turn and learning its id, which are not the same event:
// `turn/start` returns the id on newer builds and acks before it exists on
// older ones, where it arrives as a `turn/started` notification instead.
/** Codex records the user message first in a turn, so the submission Orca just
* accepted is ordinal 0 of `(threadId, turnId)`. */
export const CODEX_USER_MESSAGE_ORDINAL = 0
/** Past this the turn is real but unnameable, which the journal renders as
* delivery unconfirmed rather than failure. */
const TURN_ID_WAIT_MS = 10_000
/** Keys Codex accepts as per-turn overrides. An unlisted key would otherwise
* become an arbitrary client-controlled `turn/start` parameter. */
const CODEX_TURN_OPTION_KEYS = new Set([
'model',
'effort',
'approvalPolicy',
'approvalsReviewer',
'personality',
'serviceTier'
])
export function isCodexTurnOptionKey(key: string): boolean {
return CODEX_TURN_OPTION_KEYS.has(key)
}
/** The session state one turn needs. `turnIdWaiters` is shared with the
* notification handler, which resolves the head of the queue — correct because
* Codex runs one turn per thread, so starts and `turn/started` share an order. */
export type CodexTurnHost = {
connection: Pick<CodexAppServerConnection, 'request'>
threadId: string
options: Map<string, string>
turnIdWaiters: ((turnId: string) => void)[]
}
function turnInputFor(body: AgentJournalMessageItem): Record<string, unknown>[] {
const input: Record<string, unknown>[] = []
for (const block of body.blocks as NativeChatBlock[]) {
if (block.type === 'text' && block.text.length > 0) {
input.push({ type: 'text', text: block.text })
} else if (block.type === 'image-ref' && block.path) {
input.push({ type: 'localImage', path: block.path })
} else if (block.type === 'image-ref' && block.url) {
input.push({ type: 'image', url: block.url })
}
}
return input
}
/**
* Resolves the turn id, or null when Codex owns a turn it never named. Throws
* only for outcomes the wire must not read as acceptance.
*/
export async function startCodexTurn(
host: CodexTurnHost,
input: { clientMessageId: string; body: AgentJournalMessageItem; timeoutMs?: number }
): Promise<string | null> {
// Registered BEFORE the call: on builds that ack first, `turn/started` can
// land while the response is still in flight.
let notified: ((turnId: string) => void) | null = null
const fromNotification = new Promise<string | null>((resolve) => {
notified = resolve
host.turnIdWaiters.push(resolve)
setTimeout(() => resolve(null), TURN_ID_WAIT_MS).unref?.()
})
try {
const started = await host.connection.request(
'turn/start',
{
threadId: host.threadId,
clientUserMessageId: input.clientMessageId,
input: turnInputFor(input.body),
...Object.fromEntries(host.options)
},
{ timeoutMs: input.timeoutMs }
)
return readCodexTurnId(started) ?? (await fromNotification)
} finally {
const index = notified ? host.turnIdWaiters.indexOf(notified) : -1
if (index !== -1) {
host.turnIdWaiters.splice(index, 1)
}
}
}
/**
* One submission's outcome as the wire must read it: accepted names the turn,
* rejected is Codex answering and declining, and unknown covers a turn that is
* real but unnameable — never a failure the user is told their message hit.
*/
export async function dispatchCodexTurn(
session: CodexTurnHost,
input: { clientMessageId: string; body: AgentJournalMessageItem },
timeoutMs: number | undefined
): Promise<AgentSessionDispatchOutcome> {
let turnId: string | null
try {
turnId = await startCodexTurn(session, { ...input, timeoutMs })
} catch (error) {
if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) {
return { state: 'rejected', reason: (error as Error).message }
}
throw error
}
return turnId === null
? { state: 'unknown', reason: 'codex app-server started a turn it did not name in time' }
: {
state: 'accepted',
providerIdentity: {
provider: 'codex',
threadId: session.threadId,
turnId,
ordinal: CODEX_USER_MESSAGE_ORDINAL
}
}
}