diff --git a/src/shared/native-chat-unverifiable-turn-status.test.ts b/src/shared/native-chat-unverifiable-turn-status.test.ts index 39787a11dbb..7e93f9cecc4 100644 --- a/src/shared/native-chat-unverifiable-turn-status.test.ts +++ b/src/shared/native-chat-unverifiable-turn-status.test.ts @@ -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', diff --git a/src/shared/structured-agent-session-turn-timing.ts b/src/shared/structured-agent-session-turn-timing.ts index 8e169650f54..ff993815b8c 100644 --- a/src/shared/structured-agent-session-turn-timing.ts +++ b/src/shared/structured-agent-session-turn-timing.ts @@ -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 { +): ReadonlyMap { const itemIds = new Set(items.map((item) => item.itemId)) const aliases = new Map() // 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() + const timings = new Map() 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