Files
orca/src/main/codex/codex-structured-notification-retry.test.ts
T
Merge Sim ff55fccd80 Make the structured turn lifecycle row durable so completed durations survive
A structured-chat turn used to end by tombstoning its running lifecycle item,
which threw away the only durable record of when the turn ended. Completed
"Worked for" labels therefore depended on the renderer having observed the
turn finish, and vanished on reopen.

The lifecycle item is now revised in place, never tombstoned:
- running, with startedAt, at the provider's turn start
- completed or interrupted, with completedAt, at the provider's terminal frame,
  a user stop, or a child exit the host observed
- unverifiable, with no end, when a cold acquire finds a running row from a
  generation whose exit nobody observed

Both timestamps are the execution host's clock at receipt, captured before the
deferred sink, so the completed value is identical on every client and needs
no client clock. Codex history restore uses the provider's own second-granular
endpoints for turns that predate this change. Desktop and mobile read settled
durations off the journal through one shared selector, and anchor the live
counter on the host start with the client's local receipt so a skewed client
clock never leaks into the label. Locally observed durations remain the
fallback for hosts that still tombstone.

Timestamps live inside the existing turnLifecycle field, which old clients
strip, and every working-state consumer keys on state === 'running', so no
capability negotiation is needed.
2026-09-09 00:06:41 -07:00

76 lines
2.9 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import type { CodexAppServerConnection } from './codex-app-server-connection'
import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts'
import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry'
import type { CodexSession } from './codex-structured-session-state'
type Translate = Parameters<typeof createCodexStructuredNotificationRetry>[0]['translate']
function sessionWith(connection: CodexAppServerConnection): CodexSession {
return { connection, ended: false } as CodexSession
}
function translateMock(
...results: CodexJournalTranslationAdmission[]
): ReturnType<typeof vi.fn<Translate>> {
const translate = vi.fn<Translate>()
for (const result of results) {
translate.mockReturnValueOnce(result)
}
return translate
}
describe('createCodexStructuredNotificationRetry', () => {
it('replays a backpressured turn boundary with its original receipt time', async () => {
vi.useFakeTimers()
try {
const connection = {
pauseReading: vi.fn(),
resumeReading: vi.fn()
} as unknown as CodexAppServerConnection
const session = sessionWith(connection)
const translate = translateMock({ accepted: false, reason: 'backpressure' })
translate.mockReturnValue({ accepted: true })
const retries = createCodexStructuredNotificationRetry({
sessionFor: () => session,
translate
})
const admission = retries.handle('session-1', 'turn/started', { turn: { id: 't' } }, 1_000)
expect(admission).toEqual({ accepted: false, reason: 'backpressure' })
await vi.advanceTimersByTimeAsync(50)
expect(translate).toHaveBeenCalledTimes(2)
expect(translate.mock.calls.map((call) => call[4])).toEqual([1_000, 1_000])
expect(connection.resumeReading).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('queues a later notification behind a pending one without inventing a receipt time', () => {
const connection = {
pauseReading: vi.fn(),
resumeReading: vi.fn()
} as unknown as CodexAppServerConnection
const session = sessionWith(connection)
const translate = translateMock()
translate.mockReturnValue({ accepted: false, reason: 'backpressure' })
const retries = createCodexStructuredNotificationRetry({
sessionFor: () => session,
translate
})
retries.handle('session-1', 'turn/started', { turn: { id: 't' } }, 1_000)
retries.handle('session-1', 'item/completed', { item: { id: 'i' } })
retries.clear('session-1', connection)
// Every attempt replays the head of the queue with its own receipt time;
// the later notification never jumps ahead of it.
expect(translate.mock.calls.length).toBeGreaterThan(0)
expect(translate.mock.calls.map((call) => [call[2], call[4]])).toEqual(
translate.mock.calls.map(() => ['turn/started', 1_000])
)
})
})