Files
orca/src/main/codex/codex-structured-dispatch-echo.ts
T
Brennan Benson 533b0bd02e fix(native-chat): count a turn from the send that opened it (#21086)
* fix(native-chat): count a turn from the send that opened it

The live turn indicator switched on at the submission but anchored its clock at
the provider turn-open, so it jumped back by exactly the dispatch latency the
moment the turn opened. Measured on a real Claude session: the counter climbed to
"Working for 25s", reset to "Working for 0s", then settled "Worked for 26s" —
three readings of one turn, from two different instants.

The host now resolves the send that opened a turn and publishes it as an additive
optional `requestedAt` on the turn lifecycle row. `startedAt` keeps its exact
meaning, the provider turn-open, and is never rewritten, so clients that cannot be
upgraded see no change to any value they already read. Both providers write it;
it is omitted when no send can be named (provider-resumed turns, replayed history).

Readers take one origin, `requestedAt ?? startedAt`, for both the live counter and
the settled host interval, so the two cannot disagree. The provider's own reported
duration keeps outranking the host interval, unchanged.

The host-to-local clock conversion is now latched once per turn rather than
re-derived per render. `receivedAt - hostNow` carries that sample's one-way
delivery latency as well as skew, and the reducer replaces the sample on every
frame, so re-deriving imported fresh jitter and could move the anchor later — the
same class of backwards jump this change removes. With the conversion fixed, an
origin that improves moves the anchor earlier by exactly that much, so displayed
elapsed only grows. No monotonicity guard is added; the ordering is structural.

Desktop and mobile drove byte-identical copies of the timing hook, so both are
collapsed onto one React-free helper in shared.

Regression tests drive the origin resolution rather than an already-resolved
anchor, assert in milliseconds because second-flooring hides the sub-second case,
and include a deliberate host/client skew so a raw timestamp assignment cannot
pass on a machine where the two clocks agree.

* fix(native-chat): correlate Codex turn origins by echo

* fix(native-chat): preserve causal turn timing ownership

* fix(native-chat): keep settled turn timing continuous
2026-09-16 15:54:04 -07:00

85 lines
3.2 KiB
TypeScript

import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
/** Sends awaiting their echo. A send whose echo never arrives is
* retired by the journal's pending-submission recovery on exit, not from here. */
export const MAX_CODEX_PENDING_DISPATCH_ECHOES = 256
export type CodexDispatchRequestOrigin = {
requestedAt: number
sequence: number
}
/**
* Which sends this session is still waiting to hear back about, keyed by the
* client message id Codex echoes on the user message.
*
* Keyed rather than ordered on purpose: Codex coalesces a `turn/start` issued
* while a turn is running into that turn, so two sends can share one turn id and
* their echoes arrive far apart. Queue position identifies neither.
*/
export type CodexDispatchEchoes = {
/** Arms settlement for a send about to be written; false preserves older waits at capacity. */
arm: (clientMessageId: string, requestedAt?: number) => boolean
/** True once, for a send this session armed and has not yet settled. */
settle: (clientMessageId: string) => boolean
/** Drops an armed send whose write never reached the provider. */
disarm: (clientMessageId: string) => void
/** Submission origin for this exact send, retained until its echo settles it. */
requestOrigin: (clientMessageId: string) => CodexDispatchRequestOrigin | null
/** Highest causal sequence assigned to a dispatch in this session. */
latestSequence: () => number
clear: () => void
readonly size: number
}
export function createCodexDispatchEchoes(): CodexDispatchEchoes {
const armed = new Map<string, { requestedAt: number | null; sequence: number }>()
let nextSequence = 0
return {
arm(clientMessageId, requestedAt) {
const existing = armed.get(clientMessageId)
if (existing) {
if (existing.requestedAt === null && requestedAt !== undefined) {
existing.requestedAt = requestedAt
}
return true
}
if (armed.size >= MAX_CODEX_PENDING_DISPATCH_ECHOES) {
return false
}
armed.set(clientMessageId, { requestedAt: requestedAt ?? null, sequence: nextSequence++ })
return true
},
settle: (clientMessageId) => armed.delete(clientMessageId),
disarm: (clientMessageId) => void armed.delete(clientMessageId),
requestOrigin: (clientMessageId) => {
const origin = armed.get(clientMessageId)
return origin?.requestedAt === null || origin === undefined
? null
: { requestedAt: origin.requestedAt, sequence: origin.sequence }
},
latestSequence: () => nextSequence - 1,
clear: () => {
armed.clear()
nextSequence = 0
},
get size() {
return armed.size
}
}
}
/** The user-message echo a settlement is read off, or null for any other item. */
export function readCodexDispatchEcho(
item: { type: string; id: string } & Record<string, unknown>,
identity: AgentJournalItemIdentity
): { clientMessageId: string; providerIdentity: AgentJournalItemIdentity } | null {
if (item.type !== 'userMessage' || identity.provider !== 'codex') {
return null
}
const clientMessageId = item.clientId
return typeof clientMessageId === 'string' && clientMessageId.length > 0
? { clientMessageId, providerIdentity: identity }
: null
}