Files
orca/src/shared/native-chat-transcript-projection.ts
T
Brennan Benson 5e5f4f6603 fix(native-chat): keep a Codex ask's questions in the order it asked them (#23502)
* fix(native-chat): keep a Codex ask's questions in the order it asked them

Codex journals every question of one ask in a single write, so the
questions share a timestamp. The transcript list sorted rows by
timestamp and broke ties by row id, and a question's id ends in the
question id the model chose, so answered, cancelled and still-pending
rows of one ask came out in the alphabetical order of those ids.

The transcript projection now breaks timestamp ties by the order its
source gave: the journal's order for structured sessions. The session
assembler keeps its id tie-break, since the sources it merges share no
order of their own.

* refactor(native-chat): name the id tie-break comparator for what it does

Two shared comparators differed only in whether they break timestamp ties by id,
under near-identical names; spell the id tie-break in the name.

* fix(native-chat): order structured chat rows by their journal position

The desktop list and the host's conversation outline sorted structured rows
by timestamp. The journal's contract is that the sequence orders the
timeline and the timestamp is the provider's clock: a Codex ask writes all
of its questions in one write (one sequence, one timestamp), and a row
recovered after a crash carries an earlier clock at a later sequence.

The host now records each item's place within the write that created it,
keeps it across revisions like the sequence, and sends it as an optional
field. Rows projected from the journal carry that position, and both the
desktop list and the outline order journal rows by it. Rows outside the
journal keep their rules: rank for the streaming and pending tail, outbox
sends after every journal row, and terminal-backed chats keep time then id.

* fix(native-chat): keep a refused send at its journal place, and keep list positions off worker reads

A send the host journalled before the provider refused it is shown from the outbox, and it
sorted after every journal row, so it dropped below whatever the agent wrote after it. It now
takes the journal position of the submission the host recorded.

Structured worker reads and their archives projected journal rows through the same
projection, so they returned the list-only journal position on every message. The worker
payload bound now drops it.

* test(native-chat): a failed restart's row draws below the message it failed

Since a message is accepted before its delivery starts the agent, a restart that fails is
journalled after the message, and the refused message keeps that journal place in the chat.
Both host paths now pin it through the chat's own projection: a start the host could not make,
and a restarted child that exits before proving its start.

Folds the journal reducer's batch item write onto fewer lines, which the merge of main pushed
past the file's line limit.
2026-09-27 23:44:36 -07:00

61 lines
2.3 KiB
TypeScript

// The transcript's projection of a conversation's messages into the rows it draws:
// ordered, tool runs folded into the turn that made them, harness turns dropped.
// Shared so the host's conversation outline asks "which user messages draw a row?"
// of exactly what the renderer's transcript runs, not a second reading of it.
import type { NativeChatMessage } from './native-chat-types'
import { compareAgentJournalPositions } from './agent-session-journal-position'
import { stripNoiseMessages } from './native-chat-noise'
import { foldToolMessages } from './native-chat-tool-fold'
/** Timestamp, then id. A null timestamp sorts first so a source that cannot supply
* one stays in place rather than jumping to the end. */
export function compareNativeChatMessagesByTime(
a: NativeChatMessage,
b: NativeChatMessage
): number {
const at = a.timestamp ?? Number.NEGATIVE_INFINITY
const bt = b.timestamp ?? Number.NEGATIVE_INFINITY
if (at !== bt) {
return at - bt
}
if (a.id < b.id) {
return -1
}
if (a.id > b.id) {
return 1
}
return 0
}
/** Rows the journal holds read in the journal's own order, never its clock: a
* batch shares one timestamp, and a row recovered after a crash carries an
* earlier one. A row not in the journal yet — a send still in the outbox — was
* made after everything the journal holds, so it follows them; only such rows,
* and terminal-backed transcripts, which have no journal, order by time. */
export function compareNativeChatTranscriptMessages(
a: NativeChatMessage,
b: NativeChatMessage
): number {
if (a.journalPosition && b.journalPosition) {
return compareAgentJournalPositions(a.journalPosition, b.journalPosition)
}
if (a.journalPosition || b.journalPosition) {
return a.journalPosition ? -1 : 1
}
return compareNativeChatMessagesByTime(a, b)
}
/** `compare` lets the renderer order its own tail rows (streaming, optimistic
* sends), which never exist on the host. */
export function projectNativeChatTranscriptMessages(
messages: readonly NativeChatMessage[],
compare: (
a: NativeChatMessage,
b: NativeChatMessage
) => number = compareNativeChatTranscriptMessages
): NativeChatMessage[] {
// Not `toSorted`: mobile's Hermes lacks it, and src/shared must stay loadable there.
return stripNoiseMessages(foldToolMessages(Array.from(messages).sort(compare)))
}