Preserve unverifiable timing across older host upgrade

This commit is contained in:
Merge Sim
2026-09-09 23:17:50 -07:00
parent dd196ac75a
commit 5acb404bf2
2 changed files with 59 additions and 54 deletions
@@ -14,58 +14,60 @@ function userItem(itemId: string): AgentJournalRenderItem {
}
describe('authoritative unknown turn duration at the shared status consumer', () => {
it('does not convert a running turn to local Worked for after unverifiable recovery', () => {
const user: AgentJournalRenderItem = {
itemId: 'orca:u1',
revision: 0,
sequence: 1,
observedAt: 1_000,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }
}
const recoveredTurn: AgentJournalRenderItem = {
itemId: 'legacy:codex:s:turn-lifecycle%3At1',
revision: 2,
sequence: 2,
observedAt: 60_000,
body: {
kind: 'turn',
turnId: 't1',
userItemId: user.itemId,
state: 'unverifiable',
startedAt: 1_000
it.each([1_000, undefined])(
'does not convert a running turn to local Worked for after unverifiable recovery (start %s)',
(startedAt) => {
const user: AgentJournalRenderItem = {
itemId: 'orca:u1',
revision: 0,
sequence: 1,
observedAt: 1_000,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }
}
}
const keys = new Set([user.itemId])
const running = reduceNativeChatTurnTiming(
{},
{
const recoveredTurn: AgentJournalRenderItem = {
itemId: 'legacy:codex:s:turn-lifecycle%3At1',
revision: 2,
sequence: 2,
observedAt: 60_000,
body: {
kind: 'turn',
turnId: 't1',
state: 'unverifiable',
...(startedAt === undefined ? {} : { startedAt, userItemId: user.itemId })
}
}
const keys = new Set([user.itemId])
const running = reduceNativeChatTurnTiming(
{},
{
activeTurnKey: user.itemId,
validTurnKeys: keys,
isWorking: true,
workingStartedAt: 1_000,
now: 1_000
}
)
const locallyStopped = reduceNativeChatTurnTiming(running, {
activeTurnKey: user.itemId,
validTurnKeys: keys,
isWorking: true,
workingStartedAt: 1_000,
now: 1_000
isWorking: false,
workingStartedAt: null,
now: 60_000
})
const options = {
activeTurnKey: user.itemId,
isWorking: false,
hasCurrentTurnResponse: true,
settledByTurn: selectStructuredAgentSettledTurns([user, recoveredTurn])
}
)
const locallyStopped = reduceNativeChatTurnTiming(running, {
activeTurnKey: user.itemId,
validTurnKeys: keys,
isWorking: false,
workingStartedAt: null,
now: 60_000
})
const options = {
activeTurnKey: user.itemId,
isWorking: false,
hasCurrentTurnResponse: true,
settledByTurn: selectStructuredAgentSettledTurns([user, recoveredTurn])
}
const mounted = selectNativeChatTurnStatuses(locallyStopped, options)
const reloaded = selectNativeChatTurnStatuses({}, options)
expect(mounted).toEqual(reloaded)
expect(mounted.active).toBeNull()
expect(mounted.completedByTurn).toEqual({})
})
const mounted = selectNativeChatTurnStatuses(locallyStopped, options)
const reloaded = selectNativeChatTurnStatuses({}, options)
expect(mounted).toEqual(reloaded)
expect(mounted.active).toBeNull()
expect(mounted.completedByTurn).toEqual({})
}
)
it.each(['running', 'completed', 'interrupted'] as const)(
'suppresses local completion for a host-recorded %s turn without an endpoint',
@@ -56,11 +56,11 @@ function readTiming(item: AgentJournalRenderItem): StructuredAgentTurnTiming | n
* reached through its alias. Rows from older hosts carry no key and fall back
* to the nearest user message before them in journal order — the submission
* row is written ahead of dispatch, so it always precedes the provider's
* turn-start. Rows without `startedAt` (conversation commands) are skipped. */
* turn-start. Untimed rows are skipped unless explicitly unverifiable (null). */
export function selectStructuredAgentTurnTimings(
items: readonly AgentJournalRenderItem[],
submissions: readonly AgentJournalSubmission[] = []
): ReadonlyMap<string, StructuredAgentTurnTiming> {
): ReadonlyMap<string, StructuredAgentTurnTiming | null> {
const itemIds = new Set(items.map((item) => item.itemId))
const aliases = new Map<string, string>()
// Codex folds a send issued mid-turn into the running turn under the SAME provider
@@ -70,18 +70,19 @@ export function selectStructuredAgentTurnTimings(
aliases.set(submission.providerItemId, agentJournalSubmissionKey(submission.clientMessageId))
}
}
const timings = new Map<string, StructuredAgentTurnTiming>()
const timings = new Map<string, StructuredAgentTurnTiming | null>()
let precedingUserItemId: string | null = null
for (const item of items) {
if (item.body.kind === 'message' && item.body.role === 'user') {
precedingUserItemId = item.itemId
continue
}
const turn = readAgentJournalTurn(item.body)
const timing = readTiming(item)
if (!timing) {
if (!timing && turn?.state !== 'unverifiable') {
continue
}
const key = readAgentJournalTurn(item.body)?.userItemId
const key = turn?.userItemId
const userItemId =
key === undefined ? precedingUserItemId : itemIds.has(key) ? key : (aliases.get(key) ?? null)
if (userItemId !== null) {
@@ -108,7 +109,7 @@ export function selectStructuredAgentRunningTurnTiming(
/** Whole seconds a settled turn ran, or null when the host never observed its end. */
export function completedStructuredAgentTurnSeconds(
timing: StructuredAgentTurnTiming | undefined
timing: StructuredAgentTurnTiming | null | undefined
): number | null {
if (!timing || (timing.state !== 'completed' && timing.state !== 'interrupted')) {
return null
@@ -152,7 +153,9 @@ export function selectStructuredAgentSettledTurns(
const workedSeconds = completedStructuredAgentTurnSeconds(timing)
settled.set(
userItemId,
workedSeconds === null ? null : { startedAt: timing.startedAt, workedSeconds }
workedSeconds === null || timing === null
? null
: { startedAt: timing.startedAt, workedSeconds }
)
}
return settled