Files
orca/src/main/codex/codex-structured-thread-facts.ts
T
Brennan Benson d691486b3f fix(codex): a message whose turn was stopped before Codex took it is withdrawn, not stuck (#23618)
* fix(native-chat): land a late settlement from a streamed turn's end after that turn's rows

A settlement that says a streamed turn ended waits for the session's event
sink to drain before writing its dispatch row. The journal reducer still
refuses to overwrite an accepted or rejected send.

* fix(codex): settle a send from the end of the turn Codex answered it into

The turn/start answer names the turn that holds a send. The send's echo
entry now keeps that binding, in memory only. If the bound turn is
interrupted without echoing the send, the send is withdrawn: Codex clears a
turn's pending input on interrupt, so the model never saw it. If the turn
fails first, the send is rejected in Codex's words. A completed turn settles
nothing, since Codex records pending input when it finishes and the echo is
still due. An answer read after its turn already ended is settled by that
end. The echo is still the acceptance and carries the item key.

* test(codex): a send settles from the end of the turn Codex answered it into

The fake Codex keeps 0.157's turn bookkeeping, and can deliver the turn/start
answer after turn/started or after turn/completed. The tests cover:
- a Stop before any echo withdraws the send, and the working rule reads idle;
- a steered follow-up is withdrawn when the turn is interrupted;
- a failed turn rejects the send in Codex's words;
- a completed turn leaves the send to its echo;
- a normal echo and a late echo;
- two steered sends in one turn;
- an answer read after the turn ended;
- a timed-out answer;
- child-thread turns;
- how a binding dies.

* refactor(native-chat): drop the stream flush before a late turn-end settlement

Nothing reads the order of a dispatch row against the turn's terminal row:
the reducer keeps a settled send terminal and the working state is derived
from both. The echo acceptance on the same path never waited either, and the
wait could drop the settlement on a failed sink barrier.

* fix(codex): settle a failed turn's sends at its end, not at its error

Codex keeps a failed turn's pending input and records it after the error
frame, before turn/completed. Settling at the error rejected a steered
follow-up the model had in fact received, so a Retry would send it twice.

* docs(codex): say a completed turn echoes what it took before it ends

Codex records a completed turn's pending input before `turn/completed`, so
a bound send that turn never echoed is left for recovery, not awaiting an
echo. The comments and one test title said the echo was still due.

* test(codex): settle a send whose answer is read after its turn failed or completed

A failed turn that ended before the answer rejects the send in Codex's words,
once; a completed one leaves it admitted and still armed for its echo.

* refactor(codex): read a failed turn's reason with the typed thread-fact reader
2026-09-28 11:42:03 -07:00

78 lines
3.1 KiB
TypeScript

// The handful of facts Orca reads out of Codex app-server payloads. Codex has
// moved these fields between the envelope and a nested `thread` / `turn` object
// across releases, so each reader accepts both shapes rather than pinning one.
function record(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function nonEmptyString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
/** `thread/start`, `thread/resume`, and `thread/started` all name the thread. */
export function readCodexThreadId(payload: unknown): string | null {
const root = record(payload)
if (!root) {
return null
}
return nonEmptyString(record(root.thread)?.id) ?? nonEmptyString(root.threadId)
}
/** Rollout file for the thread, when Codex reports one. Journal recovery reads
* it; a null just falls back to the existing session-file resolver. */
export function readCodexThreadPath(payload: unknown): string | null {
const root = record(payload)
return root ? nonEmptyString(record(root.thread)?.path) : null
}
/** `turn/start` responses carry `turn.id`; `turn/started` notifications carry
* the same under `turn`, and older builds put `turnId` on the envelope. */
export function readCodexTurnId(payload: unknown): string | null {
const root = record(payload)
if (!root) {
return null
}
return nonEmptyString(record(root.turn)?.id) ?? nonEmptyString(root.turnId)
}
/** `turn/completed` carries `turn.status`; thread history puts `status` on the turn record itself. */
export function readCodexTurnStatus(payload: unknown): string | null {
const root = record(payload)
if (!root) {
return null
}
return nonEmptyString(record(root.turn)?.status) ?? nonEmptyString(root.status)
}
/** A failed `turn/completed` carries Codex's reason as `turn.error.message`. */
export function readCodexTurnErrorMessage(payload: unknown): string | null {
return nonEmptyString(record(record(record(payload)?.turn)?.error)?.message)
}
/** Codex's own turn duration, already in milliseconds; absent or malformed reads as null. */
export function readCodexTurnDurationMs(payload: unknown): number | null {
const root = record(payload)
if (!root) {
return null
}
const value = record(root.turn)?.durationMs ?? root.durationMs
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
}
/** `error` carries `willRetry`: Codex sets it on a stream error it is about to
* retry, and omits it (false) on one that ended the turn the frame names. */
export function readCodexErrorWillRetry(payload: unknown): boolean {
return record(payload)?.willRetry === true
}
/** `thread/status/changed` carries a TAGGED status (`{status:{type}}`), never a
* bare string. `idle` and `systemError` are the two arms that mean the thread
* is not running; `active` and `notLoaded` are not. */
export function codexThreadStoppedRunning(payload: unknown): boolean {
const type = record(record(payload)?.status)?.type
return type === 'idle' || type === 'systemError'
}