mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
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.
This commit is contained in:
@@ -72,6 +72,8 @@ export function MobileNativeChatOverlay({
|
||||
agent={controller.nativeChatAgent}
|
||||
agentWorking={controller.nativeChatAgentWorking}
|
||||
structuredActivityUi={controller.nativeChatStructured}
|
||||
workingStartedAt={controller.nativeChatWorkingStartedAt}
|
||||
settledTurns={controller.nativeChatSettledTurns}
|
||||
streaming={streaming}
|
||||
onStop={controller.handleNativeChatStop}
|
||||
ask={controller.nativeChatAsk}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-ha
|
||||
import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native'
|
||||
import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import type { NativeChatSettledTurn } from '../../../src/shared/native-chat-turn-status'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { styles } from './mobile-native-chat-view-styles'
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
} from './mobile-native-chat-render-data'
|
||||
import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture'
|
||||
import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure'
|
||||
import { useSettledMobileNativeChatInputLock } from './use-mobile-native-chat-input-lease'
|
||||
import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus'
|
||||
import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator'
|
||||
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
|
||||
@@ -33,8 +35,6 @@ import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeCh
|
||||
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
||||
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
|
||||
|
||||
const INPUT_LOCK_SETTLE_MS = 600
|
||||
|
||||
/** Why the composer input is locked: the transport is disconnected, or the
|
||||
* terminal subscription has not acknowledged its input lease yet. */
|
||||
export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting'
|
||||
@@ -52,6 +52,9 @@ type Props = {
|
||||
/** Structured lane: per-turn "Working for N" status plus live tool progress,
|
||||
* replacing the bridge lane's static three-dot working row (desktop parity). */
|
||||
structuredActivityUi?: boolean
|
||||
/** Structured lane: host-recorded turn timing feeding the per-turn status rows. */
|
||||
workingStartedAt?: number | null
|
||||
settledTurns?: ReadonlyMap<string, NativeChatSettledTurn> | null
|
||||
/** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */
|
||||
onStop?: () => void
|
||||
/** Live partial assistant text to show as an in-progress bubble, already gated
|
||||
@@ -130,6 +133,8 @@ export function MobileNativeChatView({
|
||||
agent,
|
||||
agentWorking,
|
||||
structuredActivityUi = false,
|
||||
workingStartedAt,
|
||||
settledTurns,
|
||||
onStop,
|
||||
streaming,
|
||||
hasMore,
|
||||
@@ -262,6 +267,8 @@ export function MobileNativeChatView({
|
||||
messages: data,
|
||||
enabled: structuredActivityUi,
|
||||
isWorking: agentWorking === true,
|
||||
workingStartedAt,
|
||||
settledTurns,
|
||||
scopeKey: sendSurfaceId
|
||||
})
|
||||
|
||||
@@ -285,18 +292,7 @@ export function MobileNativeChatView({
|
||||
const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error)
|
||||
const showLoading = status === 'loading' && messages.length === 0
|
||||
|
||||
// A dead PTY emits subscribed→end; settle both edges so its false lease cannot flash the composer enabled.
|
||||
const rawLockReason = inputLockReason ?? null
|
||||
const rawLockHeld = rawLockReason !== null
|
||||
const [lockHeld, setLockHeld] = useState(false)
|
||||
useEffect(() => {
|
||||
if (rawLockHeld === lockHeld) {
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => setLockHeld(rawLockHeld), INPUT_LOCK_SETTLE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [lockHeld, rawLockHeld])
|
||||
const lockReason = lockHeld ? (rawLockReason ?? 'waiting') : null
|
||||
const lockReason = useSettledMobileNativeChatInputLock(inputLockReason)
|
||||
|
||||
return (
|
||||
<View style={[styles.root, { paddingBottom: bottomPad }]}>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from '../../../src/shared/native-chat-ask'
|
||||
import type { detectAgentPermission } from './mobile-native-chat-permission'
|
||||
import type { parseAgentQuestion } from './mobile-native-chat-question'
|
||||
import type { NativeChatSettledTurn } from '../../../src/shared/native-chat-turn-status'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts'
|
||||
import type { useMobileNativeChatSession } from './use-mobile-native-chat-session'
|
||||
@@ -28,6 +29,9 @@ export type MobileNativeChatController = {
|
||||
/** Structured lane: drives the per-turn status row and live tool progress. */
|
||||
nativeChatStructured: boolean
|
||||
nativeChatAgentWorking: boolean
|
||||
/** Structured lane: host-recorded turn timing for the per-turn status rows. */
|
||||
nativeChatWorkingStartedAt: number | null
|
||||
nativeChatSettledTurns: ReadonlyMap<string, NativeChatSettledTurn> | null
|
||||
nativeChatStreamingText?: string
|
||||
/** Agent mid-turn, regardless of whether chat is the visible view. */
|
||||
nativeChatStreamLive: boolean
|
||||
|
||||
@@ -299,6 +299,8 @@ export function useMobileNativeChatController(args: {
|
||||
/** Structured lane: drives the per-turn status row and live tool progress. */
|
||||
nativeChatStructured: activeChatStructured,
|
||||
nativeChatAgentWorking,
|
||||
nativeChatWorkingStartedAt: activeChatStructured ? structuredNativeChat.workingStartedAt : null,
|
||||
nativeChatSettledTurns: activeChatStructured ? structuredNativeChat.settledTurns : null,
|
||||
nativeChatStreamingText,
|
||||
nativeChatStreamLive,
|
||||
nativeChatStreamScopeKey: streamScopeKey,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { useMobileNativeChatInputLease } from './use-mobile-native-chat-input-lease'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
useMobileNativeChatInputLease,
|
||||
useSettledMobileNativeChatInputLock
|
||||
} from './use-mobile-native-chat-input-lease'
|
||||
|
||||
type Lease = ReturnType<typeof useMobileNativeChatInputLease>
|
||||
|
||||
@@ -61,3 +64,40 @@ describe('useMobileNativeChatInputLease', () => {
|
||||
expect(lease?.clear()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSettledMobileNativeChatInputLock', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let settled: ReturnType<typeof useSettledMobileNativeChatInputLock> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function Harness({ reason }: { reason: 'waiting' | 'disconnected' | null }): null {
|
||||
settled = useSettledMobileNativeChatInputLock(reason)
|
||||
return null
|
||||
}
|
||||
|
||||
it('holds each edge until the lease has stopped flapping', () => {
|
||||
vi.useFakeTimers()
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { reason: 'waiting' }))
|
||||
})
|
||||
expect(settled).toBeNull()
|
||||
act(() => vi.advanceTimersByTime(600))
|
||||
expect(settled).toBe('waiting')
|
||||
|
||||
// A brief unlock that reverts inside the settle window never reaches the composer.
|
||||
act(() => renderer?.update(createElement(Harness, { reason: null })))
|
||||
act(() => vi.advanceTimersByTime(300))
|
||||
act(() => renderer?.update(createElement(Harness, { reason: 'disconnected' })))
|
||||
act(() => vi.advanceTimersByTime(600))
|
||||
expect(settled).toBe('disconnected')
|
||||
|
||||
act(() => renderer?.update(createElement(Harness, { reason: null })))
|
||||
act(() => vi.advanceTimersByTime(600))
|
||||
expect(settled).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,3 +72,23 @@ export function useMobileNativeChatInputLease(args: {
|
||||
clear
|
||||
}
|
||||
}
|
||||
|
||||
const INPUT_LOCK_SETTLE_MS = 600
|
||||
|
||||
/** A dead PTY emits subscribed→end; settle both edges so its false lease cannot
|
||||
* flash the composer enabled. */
|
||||
export function useSettledMobileNativeChatInputLock(
|
||||
reason: MobileNativeChatInputLockReason | null | undefined
|
||||
): MobileNativeChatInputLockReason | null {
|
||||
const rawLockReason = reason ?? null
|
||||
const rawLockHeld = rawLockReason !== null
|
||||
const [lockHeld, setLockHeld] = useState(false)
|
||||
useEffect(() => {
|
||||
if (rawLockHeld === lockHeld) {
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => setLockHeld(rawLockHeld), INPUT_LOCK_SETTLE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [lockHeld, rawLockHeld])
|
||||
return lockHeld ? (rawLockReason ?? 'waiting') : null
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import type { NativeChatSettledTurn } from '../../../src/shared/native-chat-turn-status'
|
||||
import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure'
|
||||
|
||||
function userMessage(id: string): NativeChatMessage {
|
||||
@@ -18,17 +19,20 @@ function Harness({
|
||||
messages,
|
||||
enabled,
|
||||
isWorking = true,
|
||||
settledTurns,
|
||||
scopeKey = 'host\0worktree\0tab-a'
|
||||
}: {
|
||||
messages: readonly NativeChatMessage[]
|
||||
enabled: boolean
|
||||
isWorking?: boolean
|
||||
settledTurns?: ReadonlyMap<string, NativeChatSettledTurn>
|
||||
scopeKey?: string
|
||||
}): React.JSX.Element {
|
||||
const disclosure = useMobileNativeChatTurnDisclosure({
|
||||
messages,
|
||||
enabled,
|
||||
isWorking,
|
||||
settledTurns,
|
||||
scopeKey
|
||||
})
|
||||
return createElement('result', { disclosure })
|
||||
@@ -116,6 +120,30 @@ describe('useMobileNativeChatTurnDisclosure', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the host-recorded duration over the locally observed one', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.setSystemTime(1_000)
|
||||
const messages = [userMessage('u1')]
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { messages, enabled: true }))
|
||||
})
|
||||
// Locally this turn ran 5s; the host says 3m 17s and the host wins.
|
||||
vi.setSystemTime(6_000)
|
||||
const settledTurns = new Map([['u1', { startedAt: 500, workedSeconds: 197 }]])
|
||||
act(() => {
|
||||
renderer?.update(
|
||||
createElement(Harness, { messages, enabled: true, isWorking: false, settledTurns })
|
||||
)
|
||||
})
|
||||
const row = renderer!.root.findByType('result').props.disclosure.resolveRow(0, messages[0])
|
||||
expect(row.turnStatus).toEqual({ startedAt: 500, thinking: false, workedSeconds: 197 })
|
||||
expect(row.turnKey).toBe('u1')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps at most the latest 128 turns expanded', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import type { NativeChatSettledTurn } from '../../../src/shared/native-chat-turn-status'
|
||||
import {
|
||||
MOBILE_UNANCHORED_TURN_KEY,
|
||||
useMobileNativeChatTurnStatus,
|
||||
@@ -25,11 +26,16 @@ export function useMobileNativeChatTurnDisclosure({
|
||||
messages,
|
||||
enabled,
|
||||
isWorking,
|
||||
workingStartedAt,
|
||||
settledTurns,
|
||||
scopeKey
|
||||
}: {
|
||||
messages: readonly NativeChatMessage[]
|
||||
enabled: boolean
|
||||
isWorking: boolean
|
||||
workingStartedAt?: number | null
|
||||
/** Host-recorded durations; they outrank whatever this client observed. */
|
||||
settledTurns?: ReadonlyMap<string, NativeChatSettledTurn> | null
|
||||
/** Host/worktree/tab identity for timing and disclosure isolation. */
|
||||
scopeKey: string
|
||||
}): {
|
||||
@@ -43,6 +49,8 @@ export function useMobileNativeChatTurnDisclosure({
|
||||
messages,
|
||||
enabled,
|
||||
isWorking,
|
||||
workingStartedAt,
|
||||
settledTurns,
|
||||
scopeKey
|
||||
})
|
||||
const [expandedTurns, setExpandedTurns] = useState<{
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
nativeChatTurnHasResponse,
|
||||
reduceNativeChatTurnTiming,
|
||||
selectNativeChatTurnStatuses,
|
||||
type NativeChatSettledTurn,
|
||||
type NativeChatTurnStatus,
|
||||
type NativeChatTurnTimingByTurn
|
||||
} from '../../../src/shared/native-chat-turn-status'
|
||||
@@ -25,12 +26,15 @@ export function useMobileNativeChatTurnStatus({
|
||||
enabled,
|
||||
isWorking,
|
||||
workingStartedAt,
|
||||
settledTurns,
|
||||
scopeKey
|
||||
}: {
|
||||
messages: readonly NativeChatMessage[]
|
||||
enabled: boolean
|
||||
isWorking: boolean
|
||||
workingStartedAt?: number | null
|
||||
/** Host-recorded durations; they outrank whatever this client observed. */
|
||||
settledTurns?: ReadonlyMap<string, NativeChatSettledTurn> | null
|
||||
/** Host/worktree/tab identity. Timings never carry across chat surfaces. */
|
||||
scopeKey: string
|
||||
}): {
|
||||
@@ -91,15 +95,24 @@ export function useMobileNativeChatTurnStatus({
|
||||
// turn re-renders ~20x/s. Without this, every settled turn's row gets fresh
|
||||
// props each tick and the memoized message rows all re-render.
|
||||
const turnIsWorking = enabled && isWorking
|
||||
const settledByTurn = enabled ? (settledTurns ?? undefined) : undefined
|
||||
const statuses = useMemo(
|
||||
() =>
|
||||
selectNativeChatTurnStatuses(timingByTurn, {
|
||||
activeTurnKey,
|
||||
isWorking: turnIsWorking,
|
||||
workingStartedAt,
|
||||
hasCurrentTurnResponse
|
||||
hasCurrentTurnResponse,
|
||||
settledByTurn
|
||||
}),
|
||||
[timingByTurn, activeTurnKey, turnIsWorking, workingStartedAt, hasCurrentTurnResponse]
|
||||
[
|
||||
timingByTurn,
|
||||
activeTurnKey,
|
||||
turnIsWorking,
|
||||
workingStartedAt,
|
||||
hasCurrentTurnResponse,
|
||||
settledByTurn
|
||||
]
|
||||
)
|
||||
return { ...statuses, activeTurnKey }
|
||||
}
|
||||
|
||||
@@ -31,25 +31,27 @@ import type { MobileNativeChatSession } from './use-mobile-native-chat-session'
|
||||
import { useMobileStructuredAgentState } from './use-mobile-structured-agent-state'
|
||||
import { useMobileStructuredPromptResponses } from './use-mobile-structured-prompt-responses'
|
||||
import { useMobileStructuredAgentOptions } from './use-mobile-structured-agent-options'
|
||||
import { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing'
|
||||
|
||||
type StructuredMobileAttachment = StructuredAgentSessionAttachment & { id?: string }
|
||||
|
||||
type StructuredMobileSession = ReturnType<typeof useMobileStructuredAgentOptions> & {
|
||||
session: MobileNativeChatSession
|
||||
isWorking: boolean
|
||||
turnId: string | null
|
||||
sendWithOutcome: (
|
||||
text: string,
|
||||
images?: string[],
|
||||
deadline?: number,
|
||||
attachments?: readonly StructuredMobileAttachment[]
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
cancel: () => void
|
||||
permission: MobileChatPermission | null
|
||||
question: MobileChatQuestion | null
|
||||
respondPermission: (optionId: string) => Promise<boolean>
|
||||
respondQuestion: (answer: string) => Promise<boolean>
|
||||
}
|
||||
type StructuredMobileSession = ReturnType<typeof useMobileStructuredAgentOptions> &
|
||||
ReturnType<typeof useMobileStructuredAgentTurnTiming> & {
|
||||
session: MobileNativeChatSession
|
||||
isWorking: boolean
|
||||
turnId: string | null
|
||||
sendWithOutcome: (
|
||||
text: string,
|
||||
images?: string[],
|
||||
deadline?: number,
|
||||
attachments?: readonly StructuredMobileAttachment[]
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
cancel: () => void
|
||||
permission: MobileChatPermission | null
|
||||
question: MobileChatQuestion | null
|
||||
respondPermission: (optionId: string) => Promise<boolean>
|
||||
respondQuestion: (answer: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export function useMobileStructuredAgentSession(args: {
|
||||
client: RpcClient | null
|
||||
@@ -269,6 +271,8 @@ export function useMobileStructuredAgentSession(args: {
|
||||
() => projectStructuredAgentSessionMessages(state.items, [], state.submissions),
|
||||
[state.items, state.submissions]
|
||||
)
|
||||
const turnId = activeStructuredAgentSessionTurnId(state.items)
|
||||
const turnTiming = useMobileStructuredAgentTurnTiming(state.items, turnId)
|
||||
const status = state.status === 'idle' ? 'idle' : state.status
|
||||
const approvalPrompt = useMemo(
|
||||
() => state.items.find(pendingStructuredApproval) ?? null,
|
||||
@@ -291,8 +295,9 @@ export function useMobileStructuredAgentSession(args: {
|
||||
loadingEarlier: loadingOlder,
|
||||
loadEarlier
|
||||
},
|
||||
isWorking: activeStructuredAgentSessionTurnId(state.items) !== null,
|
||||
turnId: activeStructuredAgentSessionTurnId(state.items),
|
||||
isWorking: turnId !== null,
|
||||
turnId,
|
||||
...turnTiming,
|
||||
sendWithOutcome,
|
||||
cancel,
|
||||
permission: projectStructuredPermission(approvalPrompt),
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
|
||||
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
|
||||
|
||||
function ok(result: unknown) {
|
||||
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
|
||||
function snapshotEvent(items: AgentJournalRenderItem[]): AgentSessionSubscribeEvent {
|
||||
return {
|
||||
type: 'snapshot',
|
||||
sessionId: 'session-1',
|
||||
fence: 3,
|
||||
page: {
|
||||
sessionId: 'session-1',
|
||||
epoch: 'epoch-1',
|
||||
fence: 3,
|
||||
direction: 'tail',
|
||||
items,
|
||||
removedItemIds: [],
|
||||
submissions: [],
|
||||
window: {
|
||||
oldest: null,
|
||||
newest: null,
|
||||
nextCursor: { epoch: 'epoch-1', sequence: 0 }
|
||||
},
|
||||
liveCursor: { epoch: 'epoch-1', sequence: 0 },
|
||||
hasOlder: false,
|
||||
hasNewer: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function user(itemId: string, sequence: number, observedAt: number): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId,
|
||||
revision: 0,
|
||||
sequence,
|
||||
observedAt,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: itemId }] }
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRequest(method: string) {
|
||||
if (method === 'agentSession.options') {
|
||||
return ok({
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-fast',
|
||||
label: 'GPT Fast',
|
||||
isDefault: true,
|
||||
defaultEffort: 'low',
|
||||
efforts: [{ value: 'low', label: 'Low' }]
|
||||
}
|
||||
],
|
||||
current: { model: 'gpt-fast', effort: 'low' }
|
||||
})
|
||||
}
|
||||
return ok({})
|
||||
}
|
||||
|
||||
describe('useMobileStructuredAgentSession turn timing', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let hook: ReturnType<typeof useMobileStructuredAgentSession> | null = null
|
||||
let listener: ((value: unknown) => void) | null = null
|
||||
const subscribe = vi.fn((_method: string, _params: unknown, onData: (value: unknown) => void) => {
|
||||
listener = onData
|
||||
return vi.fn()
|
||||
})
|
||||
const client = { sendRequest: vi.fn(sendRequest), subscribe } as unknown as RpcClient
|
||||
|
||||
function Harness(): null {
|
||||
hook = useMobileStructuredAgentSession({
|
||||
client,
|
||||
sessionId: 'session-1',
|
||||
sourceIdentity: 'host-a\0workspace-a',
|
||||
enabled: true,
|
||||
connected: true,
|
||||
agent: 'codex',
|
||||
onSendError: vi.fn()
|
||||
} as never)
|
||||
return null
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
listener = null
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
hook = null
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('exposes host-settled durations and a skew-free live anchor', async () => {
|
||||
vi.setSystemTime(50_000)
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
await vi.waitFor(() => expect(listener).toEqual(expect.any(Function)))
|
||||
act(() =>
|
||||
listener?.(
|
||||
snapshotEvent([
|
||||
user('u1', 1, 9_000_000),
|
||||
{
|
||||
itemId: 'l1',
|
||||
revision: 1,
|
||||
sequence: 2,
|
||||
observedAt: 9_000_100,
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Done',
|
||||
turnLifecycle: {
|
||||
turnId: 't1',
|
||||
state: 'completed',
|
||||
startedAt: 9_000_000,
|
||||
completedAt: 9_004_000
|
||||
}
|
||||
}
|
||||
},
|
||||
user('u2', 3, 9_010_000),
|
||||
{
|
||||
itemId: 'l2',
|
||||
revision: 1,
|
||||
sequence: 4,
|
||||
observedAt: 9_010_300,
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Working',
|
||||
turnLifecycle: { turnId: 't2', state: 'running', startedAt: 9_010_000 }
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
)
|
||||
expect(hook?.isWorking).toBe(true)
|
||||
const anchor = hook!.workingStartedAt
|
||||
// Client clock (50s) is nowhere near the host's (9,010s): only the 300ms append lag moves it.
|
||||
expect(anchor).toBe(Date.now() - 300)
|
||||
expect([...hook!.settledTurns]).toEqual([['u1', { startedAt: 9_000_000, workedSeconds: 4 }]])
|
||||
vi.setSystemTime(Date.now() + 30_000)
|
||||
act(() => renderer?.update(createElement(Harness)))
|
||||
expect(hook?.workingStartedAt).toBe(anchor)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
|
||||
import { useMobileStructuredAgentTurnTiming } from './use-mobile-structured-agent-turn-timing'
|
||||
|
||||
// Host clock sits an hour ahead of the client's so any leak of a host timestamp
|
||||
// into the local anchor shows up as a huge offset.
|
||||
const HOST_START = 3_600_000_000
|
||||
const CLIENT_NOW = 12_345_000
|
||||
|
||||
function user(itemId: string, sequence: number): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId,
|
||||
revision: 0,
|
||||
sequence,
|
||||
observedAt: HOST_START + sequence,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: itemId }] }
|
||||
}
|
||||
}
|
||||
|
||||
function lifecycle(
|
||||
turnId: string,
|
||||
sequence: number,
|
||||
turnLifecycle: Omit<
|
||||
NonNullable<Extract<AgentJournalRenderItem['body'], { kind: 'status' }>['turnLifecycle']>,
|
||||
'turnId'
|
||||
>,
|
||||
observedAt: number
|
||||
): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: `lifecycle-${turnId}`,
|
||||
revision: 1,
|
||||
sequence,
|
||||
observedAt,
|
||||
body: { kind: 'status', text: 'Working', turnLifecycle: { turnId, ...turnLifecycle } }
|
||||
}
|
||||
}
|
||||
|
||||
type Timing = ReturnType<typeof useMobileStructuredAgentTurnTiming>
|
||||
|
||||
describe('useMobileStructuredAgentTurnTiming', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let timing: Timing | null = null
|
||||
|
||||
function Harness({
|
||||
items,
|
||||
turnId
|
||||
}: {
|
||||
items: readonly AgentJournalRenderItem[]
|
||||
turnId: string | null
|
||||
}): null {
|
||||
timing = useMobileStructuredAgentTurnTiming(items, turnId)
|
||||
return null
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
timing = null
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('hands settled host durations through keyed by user message', () => {
|
||||
const items = [
|
||||
user('u1', 1),
|
||||
lifecycle(
|
||||
't1',
|
||||
2,
|
||||
{ state: 'interrupted', startedAt: HOST_START, completedAt: HOST_START + 61_000 },
|
||||
HOST_START + 5
|
||||
)
|
||||
]
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { items, turnId: null }))
|
||||
})
|
||||
expect(timing?.workingStartedAt).toBeNull()
|
||||
expect([...timing!.settledTurns]).toEqual([
|
||||
['u1', { startedAt: HOST_START, workedSeconds: 61 }]
|
||||
])
|
||||
})
|
||||
|
||||
it('anchors the live counter on the local clock, once per turn, free of host skew', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(CLIENT_NOW)
|
||||
const running = [
|
||||
user('u1', 1),
|
||||
lifecycle('t1', 2, { state: 'running', startedAt: HOST_START }, HOST_START + 2_500)
|
||||
]
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { items: running, turnId: 't1' }))
|
||||
})
|
||||
expect(timing?.workingStartedAt).toBe(CLIENT_NOW - 2_500)
|
||||
|
||||
vi.setSystemTime(CLIENT_NOW + 30_000)
|
||||
act(() => renderer?.update(createElement(Harness, { items: [...running], turnId: 't1' })))
|
||||
expect(timing?.workingStartedAt).toBe(CLIENT_NOW - 2_500)
|
||||
|
||||
act(() => renderer?.update(createElement(Harness, { items: running, turnId: null })))
|
||||
expect(timing?.workingStartedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves the anchor null when an older host records no start', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(CLIENT_NOW)
|
||||
const items = [user('u1', 1), lifecycle('t1', 2, { state: 'running' }, HOST_START)]
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { items, turnId: 't1' }))
|
||||
})
|
||||
expect(timing?.workingStartedAt).toBeNull()
|
||||
expect(timing?.settledTurns.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
|
||||
import type { NativeChatSettledTurn } from '../../../src/shared/native-chat-turn-status'
|
||||
import {
|
||||
selectStructuredAgentRunningTurnTiming,
|
||||
selectStructuredAgentSettledTurns,
|
||||
structuredAgentTurnLocalStartedAt
|
||||
} from '../../../src/shared/structured-agent-session-turn-timing'
|
||||
|
||||
type TurnAnchor = { turnId: string; startedAt: number | null }
|
||||
|
||||
/** The live turn's local-clock anchor. Null when its row carries no host start
|
||||
* (older hosts), so local observation applies. */
|
||||
function anchorRunningTurn(items: readonly AgentJournalRenderItem[], turnId: string): TurnAnchor {
|
||||
const timing = selectStructuredAgentRunningTurnTiming(items, turnId)
|
||||
return {
|
||||
turnId,
|
||||
startedAt: timing ? structuredAgentTurnLocalStartedAt(timing, Date.now()) : null
|
||||
}
|
||||
}
|
||||
|
||||
/** Host-recorded turn timing for the structured lane: settled durations straight
|
||||
* off the journal, and a skew-free start for the live counter stamped once per
|
||||
* turn so re-renders never move it. */
|
||||
export function useMobileStructuredAgentTurnTiming(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
turnId: string | null
|
||||
): { settledTurns: ReadonlyMap<string, NativeChatSettledTurn>; workingStartedAt: number | null } {
|
||||
const settledTurns = useMemo(() => selectStructuredAgentSettledTurns(items), [items])
|
||||
const [anchor, setAnchor] = useState<TurnAnchor | null>(null)
|
||||
// Stamp during render (React's derive-from-props pattern) so the first paint of
|
||||
// a new turn already counts from the right instant.
|
||||
if (turnId === null) {
|
||||
if (anchor !== null) {
|
||||
setAnchor(null)
|
||||
}
|
||||
return { settledTurns, workingStartedAt: null }
|
||||
}
|
||||
if (anchor?.turnId !== turnId) {
|
||||
const next = anchorRunningTurn(items, turnId)
|
||||
setAnchor(next)
|
||||
return { settledTurns, workingStartedAt: next.startedAt }
|
||||
}
|
||||
return { settledTurns, workingStartedAt: anchor.startedAt }
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import type {
|
||||
StructuredAgentSessionAppendOptions,
|
||||
StructuredAgentSessionEventSink
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { createClaudeJournalTranslator } from './claude-structured-journal-translation'
|
||||
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state'
|
||||
import { acquired, fakeClaude } from './claude-structured-session-test-support'
|
||||
|
||||
type Append = {
|
||||
identity: AgentJournalItemIdentity
|
||||
body: AgentJournalItemBody
|
||||
options: StructuredAgentSessionAppendOptions | undefined
|
||||
}
|
||||
|
||||
function sinkState() {
|
||||
const items: Append[] = []
|
||||
const tombstones: AgentJournalItemIdentity[] = []
|
||||
const sink: StructuredAgentSessionEventSink = {
|
||||
appendItem: (identity, body, options) => items.push({ identity, body, options }),
|
||||
appendTombstone: (identity) => tombstones.push(identity),
|
||||
publish: vi.fn()
|
||||
}
|
||||
const lifecycle = () =>
|
||||
items.flatMap((item) =>
|
||||
item.body.kind === 'status' && item.body.turnLifecycle
|
||||
? [{ ...item.body.turnLifecycle, options: item.options }]
|
||||
: []
|
||||
)
|
||||
return { sink, items, tombstones, lifecycle }
|
||||
}
|
||||
|
||||
function userTurn(uuid: string, observedAt?: number): ClaudeStructuredSessionEvent {
|
||||
return {
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
startsTurn: true,
|
||||
...(observedAt === undefined ? {} : { observedAt }),
|
||||
message: {
|
||||
type: 'user',
|
||||
uuid,
|
||||
session_id: 'claude-session',
|
||||
parent_tool_use_id: null,
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'go' }] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function result(observedAt?: number): ClaudeStructuredSessionEvent {
|
||||
return {
|
||||
type: 'message',
|
||||
sessionId: 'orca-session',
|
||||
...(observedAt === undefined ? {} : { observedAt }),
|
||||
message: {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
uuid: 'result-1',
|
||||
session_id: 'claude-session',
|
||||
is_error: false,
|
||||
result: 'done'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Claude structured turn timing', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('stamps the running row with the host start time as both startedAt and row ts', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(userTurn('user-1', 1_000))
|
||||
|
||||
expect(state.lifecycle()).toEqual([
|
||||
{ turnId: 'user-1', state: 'running', startedAt: 1_000, options: { observedAt: 1_000 } }
|
||||
])
|
||||
})
|
||||
|
||||
it('revises the running row to completed with the result receipt time', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(userTurn('user-1', 1_000))
|
||||
translator.handle(result(4_500))
|
||||
|
||||
expect(state.tombstones).toEqual([])
|
||||
expect(state.lifecycle().at(-1)).toEqual({
|
||||
turnId: 'user-1',
|
||||
state: 'completed',
|
||||
startedAt: 1_000,
|
||||
completedAt: 4_500,
|
||||
options: {}
|
||||
})
|
||||
expect(state.items.at(-1)?.identity).toEqual(state.items[0]?.identity)
|
||||
})
|
||||
|
||||
it('revises the running row to interrupted when the result reports a user abort', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(userTurn('user-1', 1_000))
|
||||
const aborted = result(4_500)
|
||||
if (aborted.type === 'message') {
|
||||
aborted.message = {
|
||||
...aborted.message,
|
||||
subtype: 'error_during_execution',
|
||||
is_error: true,
|
||||
terminal_reason: 'aborted_streaming'
|
||||
}
|
||||
}
|
||||
translator.handle(aborted)
|
||||
|
||||
expect(state.tombstones).toEqual([])
|
||||
expect(state.lifecycle().at(-1)).toMatchObject({
|
||||
turnId: 'user-1',
|
||||
state: 'interrupted',
|
||||
startedAt: 1_000,
|
||||
completedAt: 4_500
|
||||
})
|
||||
})
|
||||
|
||||
it('revises an open turn to interrupted when the session ends without a result', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(userTurn('user-1', 1_000))
|
||||
translator.handle({
|
||||
type: 'ended',
|
||||
sessionId: 'orca-session',
|
||||
reason: 'child exited',
|
||||
observedAt: 2_250
|
||||
})
|
||||
|
||||
expect(state.tombstones).toEqual([])
|
||||
expect(state.lifecycle().at(-1)).toMatchObject({
|
||||
turnId: 'user-1',
|
||||
state: 'interrupted',
|
||||
startedAt: 1_000,
|
||||
completedAt: 2_250
|
||||
})
|
||||
})
|
||||
|
||||
it('interrupts the open turn at the receipt time of the turn that replaces it', () => {
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(userTurn('user-1', 1_000))
|
||||
translator.handle(userTurn('user-2', 3_000))
|
||||
|
||||
expect(state.lifecycle().map(({ options: _options, ...row }) => row)).toEqual([
|
||||
{ turnId: 'user-1', state: 'running', startedAt: 1_000 },
|
||||
{ turnId: 'user-1', state: 'interrupted', startedAt: 1_000, completedAt: 3_000 },
|
||||
{ turnId: 'user-2', state: 'running', startedAt: 3_000 }
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the host clock when an event carries no receipt time', () => {
|
||||
vi.useFakeTimers({ now: 50_000 })
|
||||
const state = sinkState()
|
||||
const translator = createClaudeJournalTranslator({ sink: state.sink })
|
||||
|
||||
translator.handle(userTurn('user-1'))
|
||||
vi.setSystemTime(56_000)
|
||||
translator.handle(result())
|
||||
|
||||
expect(state.lifecycle().at(-1)).toMatchObject({
|
||||
state: 'completed',
|
||||
startedAt: 50_000,
|
||||
completedAt: 56_000
|
||||
})
|
||||
})
|
||||
|
||||
it('acquisition stamps turn boundaries from the host clock, never the frame timestamp', async () => {
|
||||
const claude = fakeClaude({ replayUuid: null })
|
||||
const events: ClaudeStructuredSessionEvent[] = []
|
||||
const adapter = await acquired(claude, {}, events)
|
||||
const dispatch = adapter.dispatch({
|
||||
sessionId: 'session-1',
|
||||
clientMessageId: 'client-1',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'ship it' }] },
|
||||
fence: 7
|
||||
})
|
||||
await Promise.resolve()
|
||||
const connection = claude.connections[0]!
|
||||
connection.handlers.onMessage?.({
|
||||
...connection.sent[0],
|
||||
uuid: 'turn-1',
|
||||
timestamp: '2001-01-01T00:00:00.000Z'
|
||||
})
|
||||
await dispatch
|
||||
connection.handlers.onMessage?.({
|
||||
type: 'assistant',
|
||||
uuid: 'assistant-1',
|
||||
session_id: connection.sent[0]?.session_id,
|
||||
timestamp: '2001-01-01T00:00:01.000Z',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] }
|
||||
})
|
||||
connection.handlers.onMessage?.({
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
uuid: 'result-1',
|
||||
session_id: connection.sent[0]?.session_id,
|
||||
timestamp: '2001-01-01T00:00:02.000Z',
|
||||
is_error: false,
|
||||
result: 'ok'
|
||||
})
|
||||
|
||||
const messages = events.filter(
|
||||
(event) => event.type === 'message' && event.message.type !== 'system'
|
||||
)
|
||||
expect(messages).toEqual([
|
||||
expect.objectContaining({ startsTurn: true, observedAt: 1_700_000_000_500 }),
|
||||
expect.not.objectContaining({ observedAt: expect.anything() }),
|
||||
expect.objectContaining({
|
||||
message: expect.objectContaining({ type: 'result' }),
|
||||
observedAt: 1_700_000_000_500
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,17 @@ function sinkState() {
|
||||
return { sink, items, tombstones }
|
||||
}
|
||||
|
||||
/** `[recordId, state]` of every lifecycle append, in journal order. */
|
||||
function lifecycleAppends(
|
||||
items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[]
|
||||
) {
|
||||
return items.flatMap((item) =>
|
||||
item.identity.provider === 'legacy' && item.body.kind === 'status' && item.body.turnLifecycle
|
||||
? [[item.identity.recordId, item.body.turnLifecycle.state]]
|
||||
: []
|
||||
)
|
||||
}
|
||||
|
||||
function message(
|
||||
type: 'assistant' | 'user',
|
||||
uuid: string,
|
||||
@@ -346,11 +357,13 @@ describe('Claude structured journal translation', () => {
|
||||
expect(
|
||||
state.items.some((item) => item.body.kind === 'status' && !item.body.turnLifecycle)
|
||||
).toBe(false)
|
||||
expect(
|
||||
state.tombstones.flatMap((identity) =>
|
||||
identity.provider === 'legacy' ? [identity.recordId] : []
|
||||
)
|
||||
).toEqual(['turn-lifecycle:user-replay-1', 'turn-lifecycle:user-interrupt'])
|
||||
expect(state.tombstones).toEqual([])
|
||||
expect(lifecycleAppends(state.items)).toEqual([
|
||||
['turn-lifecycle:user-replay-1', 'running'],
|
||||
['turn-lifecycle:user-replay-1', 'completed'],
|
||||
['turn-lifecycle:user-interrupt', 'running'],
|
||||
['turn-lifecycle:user-interrupt', 'interrupted']
|
||||
])
|
||||
})
|
||||
|
||||
it('does not reopen a completed turn when the SDK replays its user row after restart', () => {
|
||||
@@ -371,11 +384,9 @@ describe('Claude structured journal translation', () => {
|
||||
|
||||
liveTranslator.handle({ ...replay, startsTurn: true })
|
||||
liveTranslator.handle(resultFrame('success', { is_error: false, result: '' }))
|
||||
expect(live.tombstones).toContainEqual({
|
||||
provider: 'legacy',
|
||||
agent: 'claude',
|
||||
sessionId: 'claude-session',
|
||||
recordId: 'turn-lifecycle:picker-command-1'
|
||||
expect(live.items.at(-1)).toMatchObject({
|
||||
identity: { provider: 'legacy', recordId: 'turn-lifecycle:picker-command-1' },
|
||||
body: { turnLifecycle: { turnId: 'picker-command-1', state: 'completed' } }
|
||||
})
|
||||
liveTranslator.dispose()
|
||||
|
||||
@@ -419,11 +430,7 @@ describe('Claude structured journal translation', () => {
|
||||
text: 'API Error: 529 upstream overloaded'
|
||||
})
|
||||
// The turn still settles: the error is an extra row, not a stuck lifecycle.
|
||||
expect(
|
||||
state.tombstones.flatMap((identity) =>
|
||||
identity.provider === 'legacy' ? [identity.recordId] : []
|
||||
)
|
||||
).toEqual(['turn-lifecycle:user-1'])
|
||||
expect(lifecycleAppends(state.items).at(-1)).toEqual(['turn-lifecycle:user-1', 'completed'])
|
||||
})
|
||||
|
||||
it('drops the stream state of turns that ended without their final frame', () => {
|
||||
@@ -551,11 +558,8 @@ describe('Claude structured journal translation', () => {
|
||||
sessionId: 'orca-session',
|
||||
message: { type: 'result', session_id: 'claude-session', uuid: 'result-1' }
|
||||
})
|
||||
expect(state.tombstones.at(-1)).toMatchObject({
|
||||
provider: 'legacy',
|
||||
agent: 'claude',
|
||||
recordId: 'turn-lifecycle:user-1'
|
||||
})
|
||||
expect(state.tombstones).toEqual([])
|
||||
expect(lifecycleAppends(state.items).at(-1)).toEqual(['turn-lifecycle:user-1', 'completed'])
|
||||
})
|
||||
|
||||
it('bounds persisted thinking text to the shared journal payload limit', () => {
|
||||
@@ -584,7 +588,7 @@ describe('Claude structured journal translation', () => {
|
||||
expect(state.items.at(-1)?.body).toEqual({
|
||||
kind: 'status',
|
||||
text: 'Claude is working…',
|
||||
turnLifecycle: { turnId: 'user-image', state: 'running' }
|
||||
turnLifecycle: { turnId: 'user-image', state: 'running', startedAt: expect.any(Number) }
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -39,6 +39,12 @@ import {
|
||||
import { ClaudeSubagentRoster } from './claude-subagent-roster'
|
||||
import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity'
|
||||
import { createClaudeStreamedTextCheckpoints } from './claude-streamed-text-checkpoints'
|
||||
import {
|
||||
claudeTurnEndForResult,
|
||||
claudeTurnLifecycleItem,
|
||||
type ClaudeCurrentTurn,
|
||||
type ClaudeTurnEnd
|
||||
} from './claude-turn-lifecycle-item'
|
||||
|
||||
export type ClaudeJournalTranslatorDeps = {
|
||||
sink: StructuredAgentSessionEventSink
|
||||
@@ -71,23 +77,14 @@ export function createClaudeSessionJournalTranslator(
|
||||
: null
|
||||
}
|
||||
|
||||
function lifecycleIdentity(sessionId: string, turnId: string): AgentJournalItemIdentity {
|
||||
return {
|
||||
provider: 'legacy',
|
||||
agent: 'claude',
|
||||
sessionId,
|
||||
recordId: `turn-lifecycle:${turnId}`
|
||||
}
|
||||
}
|
||||
|
||||
export function createClaudeJournalTranslator(
|
||||
deps: ClaudeJournalTranslatorDeps
|
||||
): ClaudeJournalTranslator {
|
||||
const tools = new Map<string, ClaudeToolUse>()
|
||||
const promptItems = new Map<string, AgentJournalItemIdentity[]>()
|
||||
const streamedBlocks = createClaudeStreamedBlockRegistry()
|
||||
let currentTurn: { sessionId: string; turnId: string } | null = null
|
||||
const groupKeyOf = (turn: { sessionId: string; turnId: string } | null): string | null =>
|
||||
let currentTurn: ClaudeCurrentTurn | null = null
|
||||
const groupKeyOf = (turn: ClaudeCurrentTurn | null): string | null =>
|
||||
turn ? `${turn.sessionId}:${turn.turnId}` : null
|
||||
const providerFallback = createClaudeProviderFrameFallback(
|
||||
deps.sink,
|
||||
@@ -106,21 +103,11 @@ export function createClaudeJournalTranslator(
|
||||
}
|
||||
})
|
||||
|
||||
const publishLifecycle = (sessionId: string, turnId: string, running: boolean): void => {
|
||||
const identity = lifecycleIdentity(sessionId, turnId)
|
||||
if (running) {
|
||||
deps.sink.appendItem(identity, {
|
||||
kind: 'status',
|
||||
text: 'Claude is working…',
|
||||
turnLifecycle: { turnId, state: 'running' }
|
||||
})
|
||||
} else {
|
||||
deps.sink.appendTombstone(identity)
|
||||
}
|
||||
const publishLifecycle = (turn: ClaudeCurrentTurn, end?: ClaudeTurnEnd): void => {
|
||||
const item = claudeTurnLifecycleItem(turn, end)
|
||||
deps.sink.appendItem(item.identity, item.body, item.options)
|
||||
// Preserve first-work evidence when completion arrives before the journal drains.
|
||||
deps.sink.publish({
|
||||
coalescingKey: running ? `turn-start:${sessionId}:${turnId}` : 'publish'
|
||||
})
|
||||
deps.sink.publish({ coalescingKey: item.publishCoalescingKey })
|
||||
}
|
||||
|
||||
const publishActivity = (kind: string, payload: unknown): void => {
|
||||
@@ -142,7 +129,11 @@ export function createClaudeJournalTranslator(
|
||||
return true
|
||||
}
|
||||
|
||||
const handleMessage = (message: Record<string, unknown>, startsTurn: boolean): boolean => {
|
||||
const handleMessage = (
|
||||
message: Record<string, unknown>,
|
||||
startsTurn: boolean,
|
||||
observedAt: number
|
||||
): boolean => {
|
||||
const envelope = readClaudeMessageEnvelope(message)
|
||||
if (!envelope) {
|
||||
return false
|
||||
@@ -205,10 +196,10 @@ export function createClaudeJournalTranslator(
|
||||
// A new turn starting is the only end the previous one gets when its
|
||||
// result never arrives; settling it later would sweep THIS turn.
|
||||
subagents.settleTurn(groupKeyOf(currentTurn))
|
||||
publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
|
||||
publishLifecycle(currentTurn, { state: 'interrupted', completedAt: observedAt })
|
||||
}
|
||||
currentTurn = { sessionId: envelope.sessionId, turnId: envelope.uuid }
|
||||
publishLifecycle(envelope.sessionId, envelope.uuid, true)
|
||||
currentTurn = { sessionId: envelope.sessionId, turnId: envelope.uuid, startedAt: observedAt }
|
||||
publishLifecycle(currentTurn)
|
||||
deps.sink.setActivity?.(null)
|
||||
}
|
||||
if (changed) {
|
||||
@@ -248,7 +239,11 @@ export function createClaudeJournalTranslator(
|
||||
// No event will ever settle a child once the provider is gone.
|
||||
subagents.settleSession()
|
||||
if (currentTurn) {
|
||||
publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
|
||||
// The host saw the child end, so the turn's end is observed, not lost.
|
||||
publishLifecycle(currentTurn, {
|
||||
state: 'interrupted',
|
||||
completedAt: event.observedAt ?? Date.now()
|
||||
})
|
||||
currentTurn = null
|
||||
}
|
||||
deps.sink.setActivity?.(null)
|
||||
@@ -271,7 +266,10 @@ export function createClaudeJournalTranslator(
|
||||
// reported as working will never be settled by an event.
|
||||
subagents.settleTurn(groupKeyOf(currentTurn))
|
||||
if (currentTurn) {
|
||||
publishLifecycle(currentTurn.sessionId, currentTurn.turnId, false)
|
||||
publishLifecycle(
|
||||
currentTurn,
|
||||
claudeTurnEndForResult(event.message, event.observedAt ?? Date.now())
|
||||
)
|
||||
currentTurn = null
|
||||
}
|
||||
deps.sink.setActivity?.(null)
|
||||
@@ -291,7 +289,9 @@ export function createClaudeJournalTranslator(
|
||||
// fallback below still drops the raw frame instead of printing an opcode.
|
||||
subagents.observeSystemFrame(event.message)
|
||||
const kind = claudeProviderFrameKind(event.message)
|
||||
if (!handleMessage(event.message, event.startsTurn === true)) {
|
||||
if (
|
||||
!handleMessage(event.message, event.startsTurn === true, event.observedAt ?? Date.now())
|
||||
) {
|
||||
providerFallback.append(kind, event.message)
|
||||
}
|
||||
publishActivity(kind, event.message)
|
||||
|
||||
@@ -121,12 +121,16 @@ export async function acquireClaudeSession({
|
||||
deps.onDispatchSettledLate?.({ sessionId, ...settlement })
|
||||
)
|
||||
: false
|
||||
// Turn endpoints are stamped on the host clock, never the frame's own timestamp.
|
||||
const observedAt =
|
||||
startsTurn || message.type === 'result' ? { observedAt: deps.now?.() ?? Date.now() } : {}
|
||||
callbacks.deliver(attempt, sessionId, () =>
|
||||
callbacks.emit(liveSession, input.events, {
|
||||
type: 'message',
|
||||
sessionId,
|
||||
message,
|
||||
...(startsTurn ? { startsTurn: true } : {})
|
||||
...(startsTurn ? { startsTurn: true } : {}),
|
||||
...observedAt
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -154,7 +154,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
|
||||
reason: exit.error.message,
|
||||
cause: 'unexpected-exit',
|
||||
fence: exit.session.fence,
|
||||
acquisitionGeneration: exit.session.acquisitionGeneration
|
||||
acquisitionGeneration: exit.session.acquisitionGeneration,
|
||||
observedAt: this.deps.now?.() ?? Date.now()
|
||||
}
|
||||
try {
|
||||
this.emit(exit.session, ended)
|
||||
|
||||
@@ -109,7 +109,8 @@ async function finalizeClaudePublishedSession(
|
||||
const ended = {
|
||||
type: 'ended',
|
||||
sessionId: input.sessionId,
|
||||
reason: 'claude session closed'
|
||||
reason: 'claude session closed',
|
||||
observedAt: Date.now()
|
||||
} as const
|
||||
let callbackError: unknown
|
||||
let callbackThrew = false
|
||||
|
||||
@@ -598,7 +598,8 @@ describe('ClaudeStructuredSessionAdapter transcript-derived recovery', () => {
|
||||
reason: 'crashed before replacement',
|
||||
cause: 'unexpected-exit',
|
||||
fence: 7,
|
||||
acquisitionGeneration: firstAcquisition.acquisitionGeneration
|
||||
acquisitionGeneration: firstAcquisition.acquisitionGeneration,
|
||||
observedAt: 1_700_000_000_500
|
||||
}
|
||||
])
|
||||
expect(replacement.link).toMatchObject({
|
||||
|
||||
@@ -31,6 +31,8 @@ export type ClaudeStructuredSessionEvent =
|
||||
message: Record<string, unknown>
|
||||
/** Present only when this replay acknowledged Orca's in-flight dispatch. */
|
||||
startsTurn?: true
|
||||
/** Host clock at receipt; stamped on turn boundaries only. */
|
||||
observedAt?: number
|
||||
}
|
||||
| { type: 'provider-frame'; sessionId: string; kind: string; payload: unknown }
|
||||
| { type: 'prompt'; sessionId: string; prompt: ClaudePendingPrompt }
|
||||
@@ -53,6 +55,8 @@ export type ClaudeStructuredSessionEvent =
|
||||
fence?: number
|
||||
acquisitionGeneration?: string
|
||||
settlementRetryRequired?: boolean
|
||||
/** Host clock when the end was observed. */
|
||||
observedAt?: number
|
||||
}
|
||||
|
||||
export type ClaudeStructuredSessionAdapterDeps = {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
AgentJournalItemIdentity,
|
||||
AgentJournalStatusItem
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import type { StructuredAgentSessionAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { claudeText } from './claude-structured-item-translation'
|
||||
|
||||
export type ClaudeCurrentTurn = { sessionId: string; turnId: string; startedAt: number }
|
||||
|
||||
export type ClaudeTurnEnd = { state: 'completed' | 'interrupted'; completedAt: number }
|
||||
|
||||
/** A result the SDK reports as aborted is the user's stop, not the model's end. */
|
||||
export function claudeTurnEndForResult(
|
||||
message: Record<string, unknown>,
|
||||
completedAt: number
|
||||
): ClaudeTurnEnd {
|
||||
const reason = message.is_error === true ? claudeText(message.terminal_reason) : null
|
||||
return {
|
||||
state:
|
||||
reason === 'aborted_streaming' || reason === 'aborted_tools' ? 'interrupted' : 'completed',
|
||||
completedAt
|
||||
}
|
||||
}
|
||||
|
||||
export function claudeTurnLifecycleIdentity(
|
||||
sessionId: string,
|
||||
turnId: string
|
||||
): AgentJournalItemIdentity {
|
||||
return {
|
||||
provider: 'legacy',
|
||||
agent: 'claude',
|
||||
sessionId,
|
||||
recordId: `turn-lifecycle:${turnId}`
|
||||
}
|
||||
}
|
||||
|
||||
/** The lifecycle row is revised to its terminal state, never tombstoned, so the
|
||||
* turn's host-clock endpoints outlive the turn. */
|
||||
export function claudeTurnLifecycleItem(
|
||||
turn: ClaudeCurrentTurn,
|
||||
end?: ClaudeTurnEnd
|
||||
): {
|
||||
identity: AgentJournalItemIdentity
|
||||
body: AgentJournalStatusItem
|
||||
options: StructuredAgentSessionAppendOptions
|
||||
publishCoalescingKey: string
|
||||
} {
|
||||
const { sessionId, turnId, startedAt } = turn
|
||||
return {
|
||||
identity: claudeTurnLifecycleIdentity(sessionId, turnId),
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Claude is working…',
|
||||
turnLifecycle: end
|
||||
? { turnId, state: end.state, startedAt, completedAt: end.completedAt }
|
||||
: { turnId, state: 'running', startedAt }
|
||||
},
|
||||
// The running row's ts is the turn start itself, so clients read no append lag.
|
||||
options: end ? {} : { observedAt: startedAt },
|
||||
publishCoalescingKey: end ? 'publish' : `turn-start:${sessionId}:${turnId}`
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import type { CodexStructuredSessionEvent } from './codex-structured-session-ada
|
||||
|
||||
export type CodexJournalTranslatorDeps = {
|
||||
sink: StructuredAgentSessionEventSink
|
||||
/** Keys restored lifecycle rows to the live identity; without it history restore skips them. */
|
||||
sessionId?: string
|
||||
now?: () => number
|
||||
bindPromptItemId?: (journalItemId: string, threadId: string, promptKey: string) => void
|
||||
primaryThreadId?: () => string | null
|
||||
coalesceMs?: number
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity
|
||||
AgentJournalItemIdentity,
|
||||
AgentJournalTurnLifecycle
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import { partitionJournalLifecycleMutations } from '../native-chat/agent-session-journal/journal-lifecycle-batch-partition'
|
||||
import type { JournalLifecycleMutationInput } from '../native-chat/agent-session-journal/journal-row-builders'
|
||||
@@ -20,6 +21,10 @@ import {
|
||||
} from './codex-structured-item-translation'
|
||||
import type { CodexStructuredItemStreams } from './codex-structured-item-streams'
|
||||
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
|
||||
import {
|
||||
codexTurnLifecycleBody,
|
||||
codexTurnLifecycleIdentity
|
||||
} from './codex-structured-journal-translation-turns'
|
||||
|
||||
export type CodexActiveJournalItem = {
|
||||
threadId: string
|
||||
@@ -44,6 +49,8 @@ export function settleCodexJournalSession(input: {
|
||||
currentTurnIds: ReadonlyMap<string, ReadonlySet<string>>
|
||||
primaryThreadId: string | null
|
||||
ordinals: CodexTurnOrdinals
|
||||
/** Terminal lifecycle for a turn the provider left running when it ended. */
|
||||
settledTurnLifecycle: (threadId: string, turnId: string) => AgentJournalTurnLifecycle
|
||||
}): StructuredAgentSessionSinkAdmission {
|
||||
const mutations: JournalLifecycleMutationInput[] = []
|
||||
const turnOrdinalsToForget: { threadId: string; turnId: string }[] = []
|
||||
@@ -83,13 +90,9 @@ export function settleCodexJournalSession(input: {
|
||||
}
|
||||
for (const turnId of turnIds) {
|
||||
mutations.push({
|
||||
kind: 'tombstone',
|
||||
identity: {
|
||||
provider: 'legacy',
|
||||
agent: 'codex',
|
||||
sessionId: input.event.sessionId,
|
||||
recordId: `turn-lifecycle:${turnId}`
|
||||
}
|
||||
kind: 'item',
|
||||
identity: codexTurnLifecycleIdentity(input.event.sessionId, turnId),
|
||||
body: codexTurnLifecycleBody(input.settledTurnLifecycle(threadId, turnId))
|
||||
})
|
||||
turnOrdinalsToForget.push({ threadId, turnId })
|
||||
}
|
||||
@@ -108,6 +111,8 @@ export function settleCodexJournalTurn(input: {
|
||||
sessionId: string
|
||||
threadId: string
|
||||
turnId: string
|
||||
/** Null off the primary thread: only the primary turn owns a lifecycle row. */
|
||||
turnLifecycle: AgentJournalTurnLifecycle | null
|
||||
sink: StructuredAgentSessionEventSink
|
||||
streams: CodexStructuredItemStreams
|
||||
activeItems: Map<string, CodexActiveJournalItem>
|
||||
@@ -128,15 +133,17 @@ export function settleCodexJournalTurn(input: {
|
||||
}
|
||||
activeItemsToForget.push({ key, threadId: active.threadId, itemId: active.item.id })
|
||||
}
|
||||
mutations.push({
|
||||
kind: 'tombstone',
|
||||
identity: {
|
||||
provider: 'legacy',
|
||||
agent: 'codex',
|
||||
sessionId: input.sessionId,
|
||||
recordId: `turn-lifecycle:${input.turnId}`
|
||||
}
|
||||
})
|
||||
// Revised, never tombstoned: the terminal row keeps the turn's duration durable.
|
||||
if (input.turnLifecycle) {
|
||||
mutations.push({
|
||||
kind: 'item',
|
||||
identity: codexTurnLifecycleIdentity(input.sessionId, input.turnId),
|
||||
body: codexTurnLifecycleBody(input.turnLifecycle)
|
||||
})
|
||||
}
|
||||
if (mutations.length === 0) {
|
||||
return ADMITTED
|
||||
}
|
||||
const admission = appendLifecycleMutations(
|
||||
input.sink,
|
||||
`turn-completed:${input.sessionId}:${input.threadId}:${input.turnId}`,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { AgentJournalTurnLifecycle } from '../../shared/agent-session-journal-types'
|
||||
import type { CodexTurnOrdinals } from './codex-structured-item-translation'
|
||||
import {
|
||||
readCodexJournalRecord,
|
||||
readCodexJournalString
|
||||
} from './codex-structured-journal-translation-values'
|
||||
import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation'
|
||||
import { codexTurnLifecycleState } from './codex-structured-journal-translation-turns'
|
||||
import { readCodexTurnStatus } from './codex-structured-thread-facts'
|
||||
|
||||
/** Old providers may return the complete thread from resume. Keep that fallback
|
||||
* bounded before admitting any rows to the asynchronous sink. */
|
||||
@@ -20,6 +23,10 @@ export function restoreCodexJournalThread(input: {
|
||||
method: string
|
||||
params: unknown
|
||||
}) => CodexJournalTranslationAdmission
|
||||
/** Absent when the caller has no session identity to key lifecycle rows by. */
|
||||
restoreTurnLifecycle?: (
|
||||
turnLifecycle: AgentJournalTurnLifecycle
|
||||
) => CodexJournalTranslationAdmission
|
||||
flush: () => void
|
||||
}): CodexJournalTranslationAdmission {
|
||||
const turns = Array.isArray(input.thread.turns) ? input.thread.turns : []
|
||||
@@ -30,8 +37,14 @@ export function restoreCodexJournalThread(input: {
|
||||
? (Array.isArray(turn.items) ? turn.items : []).map((item) => ({ turnId, item }))
|
||||
: []
|
||||
})
|
||||
const lifecycles = input.restoreTurnLifecycle
|
||||
? turns.flatMap((rawTurn) => historicalTurnLifecycle(readCodexJournalRecord(rawTurn)) ?? [])
|
||||
: []
|
||||
const encodedBytes = Buffer.byteLength(JSON.stringify(items), 'utf8')
|
||||
if (items.length > CODEX_RESTORE_MAX_OPERATIONS || encodedBytes > CODEX_RESTORE_MAX_BYTES) {
|
||||
if (
|
||||
items.length + lifecycles.length > CODEX_RESTORE_MAX_OPERATIONS ||
|
||||
encodedBytes > CODEX_RESTORE_MAX_BYTES
|
||||
) {
|
||||
return { accepted: false, reason: 'backpressure' }
|
||||
}
|
||||
for (const rawTurn of turns) {
|
||||
@@ -53,7 +66,36 @@ export function restoreCodexJournalThread(input: {
|
||||
}
|
||||
input.currentTurnIds.delete(input.threadId)
|
||||
input.ordinals.forgetTurn(input.threadId, turnId)
|
||||
const lifecycle = input.restoreTurnLifecycle ? historicalTurnLifecycle(turn) : null
|
||||
if (lifecycle) {
|
||||
const admission = input.restoreTurnLifecycle?.(lifecycle) ?? { accepted: true }
|
||||
if (!admission.accepted) {
|
||||
return admission
|
||||
}
|
||||
}
|
||||
}
|
||||
input.flush()
|
||||
return { accepted: true }
|
||||
}
|
||||
|
||||
/** Codex reports both endpoints in unix seconds; a turn missing either has no durable duration. */
|
||||
function historicalTurnLifecycle(turn: Record<string, unknown>): AgentJournalTurnLifecycle | null {
|
||||
const turnId = readCodexJournalString(turn, 'id')
|
||||
const startedAt = turn.startedAt
|
||||
const completedAt = turn.completedAt
|
||||
if (
|
||||
!turnId ||
|
||||
typeof startedAt !== 'number' ||
|
||||
typeof completedAt !== 'number' ||
|
||||
!Number.isFinite(startedAt) ||
|
||||
!Number.isFinite(completedAt)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
turnId,
|
||||
state: codexTurnLifecycleState(readCodexTurnStatus(turn)),
|
||||
startedAt: startedAt * 1000,
|
||||
completedAt: completedAt * 1000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,10 +220,14 @@ describe('codex journal translation', () => {
|
||||
expect(bodies).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'status',
|
||||
turnLifecycle: { turnId: TURN_ID, state: 'running' }
|
||||
turnLifecycle: expect.objectContaining({ turnId: TURN_ID, state: 'running' })
|
||||
}),
|
||||
expect.objectContaining({ kind: 'tool-call', state: 'running' }),
|
||||
expect.objectContaining({ kind: 'tool-call', state: 'failed' })
|
||||
expect.objectContaining({ kind: 'tool-call', state: 'failed' }),
|
||||
expect.objectContaining({
|
||||
kind: 'status',
|
||||
turnLifecycle: expect.objectContaining({ turnId: TURN_ID, state: 'completed' })
|
||||
})
|
||||
])
|
||||
expect(publishes).toHaveLength(2)
|
||||
})
|
||||
@@ -265,7 +269,7 @@ describe('codex journal translation', () => {
|
||||
expect(bodies).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'status',
|
||||
turnLifecycle: { turnId: TURN_ID, state: 'running' }
|
||||
turnLifecycle: expect.objectContaining({ turnId: TURN_ID, state: 'running' })
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: 'approval',
|
||||
@@ -275,7 +279,11 @@ describe('codex journal translation', () => {
|
||||
kind: 'approval',
|
||||
resolution: expect.objectContaining({ state: 'cancelled' })
|
||||
}),
|
||||
{ kind: 'status', text: 'Provider exited: lost child' }
|
||||
{ kind: 'status', text: 'Provider exited: lost child' },
|
||||
expect.objectContaining({
|
||||
kind: 'status',
|
||||
turnLifecycle: expect.objectContaining({ turnId: TURN_ID, state: 'interrupted' })
|
||||
})
|
||||
])
|
||||
expect(publishes).toHaveLength(2)
|
||||
})
|
||||
@@ -349,7 +357,12 @@ describe('codex journal translation', () => {
|
||||
expect.objectContaining({
|
||||
body: { kind: 'status', text: 'Provider exited: lost child' }
|
||||
}),
|
||||
expect.objectContaining({ kind: 'tombstone' })
|
||||
expect.objectContaining({
|
||||
kind: 'item',
|
||||
body: expect.objectContaining({
|
||||
turnLifecycle: expect.objectContaining({ turnId: TURN_ID, state: 'interrupted' })
|
||||
})
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
@@ -422,7 +435,10 @@ describe('codex journal translation', () => {
|
||||
kind: 'item',
|
||||
body: { kind: 'status', text: 'Provider exited: lost child' }
|
||||
})
|
||||
expect(flattened.at(-1)).toMatchObject({ kind: 'tombstone' })
|
||||
expect(flattened.at(-1)).toMatchObject({
|
||||
kind: 'item',
|
||||
body: { kind: 'status', turnLifecycle: { state: 'interrupted' } }
|
||||
})
|
||||
expectLifecycleBatchBounds(batches)
|
||||
})
|
||||
|
||||
@@ -542,15 +558,25 @@ describe('codex journal translation', () => {
|
||||
output: expect.objectContaining({ head: 'partial' })
|
||||
})
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: 'tombstone',
|
||||
{
|
||||
kind: 'item',
|
||||
identity: {
|
||||
provider: 'legacy',
|
||||
agent: 'codex',
|
||||
sessionId: SESSION_ID,
|
||||
recordId: `turn-lifecycle:${TURN_ID}`
|
||||
},
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Codex is working…',
|
||||
turnLifecycle: {
|
||||
turnId: TURN_ID,
|
||||
state: 'completed',
|
||||
startedAt: expect.any(Number),
|
||||
completedAt: expect.any(Number)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
@@ -486,7 +486,8 @@ describe('codex journal translation', () => {
|
||||
expect(translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))).toEqual({
|
||||
accepted: true
|
||||
})
|
||||
expect(tap.tombstones).toContain('legacy:codex:session-1:turn-lifecycle%3Aturn-1')
|
||||
// Lifecycle rows are revised in place, never tombstoned.
|
||||
expect(tap.tombstones).toEqual([])
|
||||
// The two maps share one bounded bucket budget; this assertion documents
|
||||
// the contract for future changes even though the maps are private.
|
||||
expect(MAX_CODEX_GENERIC_BOOKKEEPING_ENTRIES).toBeGreaterThanOrEqual(
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { AgentJournalTurnLifecycle } from '../../shared/agent-session-journal-types'
|
||||
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import {
|
||||
CODEX_JOURNAL_ADMITTED,
|
||||
type CodexJournalTranslationAdmission
|
||||
} from './codex-structured-journal-contracts'
|
||||
import type { CodexJournalItems } from './codex-structured-journal-items'
|
||||
import { settleCodexJournalTurn } from './codex-structured-journal-settlement'
|
||||
import type { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state'
|
||||
import {
|
||||
codexTurnLifecycleState,
|
||||
publishCodexTurnLifecycle
|
||||
} from './codex-structured-journal-translation-turns'
|
||||
import { readCodexTurnId, readCodexTurnStatus } from './codex-structured-thread-facts'
|
||||
|
||||
type TurnBoundaryEvent = {
|
||||
sessionId: string
|
||||
threadId: string
|
||||
params: unknown
|
||||
observedAt?: number
|
||||
}
|
||||
|
||||
/** Opens and settles the durable lifecycle row for each primary-thread turn. */
|
||||
export class CodexJournalTurnBoundaries {
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
sink: StructuredAgentSessionEventSink
|
||||
primaryThreadId: () => string | null
|
||||
activeTurns: CodexJournalActiveTurns
|
||||
items: Pick<CodexJournalItems, 'streams' | 'activeItems' | 'ordinals'>
|
||||
flushSuppression: () => CodexJournalTranslationAdmission
|
||||
resetActivity: (threadId: string) => void
|
||||
now?: () => number
|
||||
}
|
||||
) {}
|
||||
|
||||
start(event: TurnBoundaryEvent): CodexJournalTranslationAdmission {
|
||||
const turnId = readCodexTurnId(event.params)
|
||||
if (!turnId) {
|
||||
return CODEX_JOURNAL_ADMITTED
|
||||
}
|
||||
if (!this.deps.activeTurns.canRemember(event.threadId, turnId)) {
|
||||
return { accepted: false, reason: 'backpressure' }
|
||||
}
|
||||
const startedAt = this.receiptTime(event)
|
||||
const admission = publishCodexTurnLifecycle({
|
||||
sink: this.deps.sink,
|
||||
primaryThreadId: this.deps.primaryThreadId(),
|
||||
sessionId: event.sessionId,
|
||||
threadId: event.threadId,
|
||||
turnId,
|
||||
state: 'running',
|
||||
startedAt
|
||||
})
|
||||
if (admission.accepted) {
|
||||
this.deps.activeTurns.remember(event.threadId, turnId, startedAt)
|
||||
this.deps.resetActivity(event.threadId)
|
||||
}
|
||||
return admission
|
||||
}
|
||||
|
||||
complete(event: TurnBoundaryEvent): CodexJournalTranslationAdmission {
|
||||
const suppressionAdmission = this.deps.flushSuppression()
|
||||
if (!suppressionAdmission.accepted) {
|
||||
return suppressionAdmission
|
||||
}
|
||||
const turnId = readCodexTurnId(event.params) ?? this.deps.activeTurns.current(event.threadId)
|
||||
if (!turnId) {
|
||||
return CODEX_JOURNAL_ADMITTED
|
||||
}
|
||||
// The roster is deliberately NOT swept here. `spawn_agent` children outlive
|
||||
// the turn that spawned them and go on reporting into the same group, so a
|
||||
// turn boundary is no evidence contact was lost. Only `settleSession` may
|
||||
// write `unverifiable`.
|
||||
const admission = settleCodexJournalTurn({
|
||||
sink: this.deps.sink,
|
||||
sessionId: event.sessionId,
|
||||
threadId: event.threadId,
|
||||
turnId,
|
||||
turnLifecycle:
|
||||
event.threadId === this.deps.primaryThreadId()
|
||||
? this.settled(
|
||||
event.threadId,
|
||||
turnId,
|
||||
codexTurnLifecycleState(readCodexTurnStatus(event.params)),
|
||||
this.receiptTime(event)
|
||||
)
|
||||
: null,
|
||||
streams: this.deps.items.streams,
|
||||
activeItems: this.deps.items.activeItems
|
||||
})
|
||||
if (admission.accepted) {
|
||||
this.deps.items.ordinals.forgetTurn(event.threadId, turnId)
|
||||
this.deps.activeTurns.forget(event.threadId, turnId)
|
||||
this.deps.resetActivity(event.threadId)
|
||||
}
|
||||
return admission
|
||||
}
|
||||
|
||||
/** Terminal lifecycle for a remembered turn; `startedAt` is absent when the start was never seen. */
|
||||
settled(
|
||||
threadId: string,
|
||||
turnId: string,
|
||||
state: 'completed' | 'interrupted',
|
||||
completedAt: number
|
||||
): AgentJournalTurnLifecycle {
|
||||
const startedAt = this.deps.activeTurns.startedAt(threadId, turnId)
|
||||
return {
|
||||
turnId,
|
||||
state,
|
||||
...(startedAt !== undefined ? { startedAt } : {}),
|
||||
completedAt
|
||||
}
|
||||
}
|
||||
|
||||
private receiptTime(event: TurnBoundaryEvent): number {
|
||||
return event.observedAt ?? this.deps.now?.() ?? Date.now()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalItemIdentity
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
|
||||
import { projectStructuredAgentSessionStatus } from '../../shared/structured-agent-session-projection'
|
||||
import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open'
|
||||
import {
|
||||
createDeferredStructuredAgentSessionEventSink,
|
||||
type StructuredAgentSessionEventSink
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
|
||||
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
|
||||
|
||||
const SESSION_ID = 'session-1'
|
||||
const THREAD_ID = 'thread-abc'
|
||||
const TURN_ID = 'turn-1'
|
||||
const LIFECYCLE_KEY = 'legacy:codex:session-1:turn-lifecycle%3Aturn-1'
|
||||
|
||||
type Row = { key: string; body: AgentJournalItemBody }
|
||||
|
||||
function recorder() {
|
||||
const rows: Row[] = []
|
||||
const tombstones: string[] = []
|
||||
const sink: StructuredAgentSessionEventSink = {
|
||||
appendItem: (identity: AgentJournalItemIdentity, body) =>
|
||||
rows.push({ key: agentJournalItemKey(identity), body }),
|
||||
appendTombstone: (identity) => tombstones.push(agentJournalItemKey(identity)),
|
||||
publish: () => {}
|
||||
}
|
||||
return { sink, rows, tombstones }
|
||||
}
|
||||
|
||||
/** Latest body per identity, in first-seen order: what the journal reducer keeps. */
|
||||
function reduced(rows: readonly Row[]): Row[] {
|
||||
const latest = new Map<string, Row>()
|
||||
for (const row of rows) {
|
||||
latest.set(row.key, row)
|
||||
}
|
||||
return [...latest.values()]
|
||||
}
|
||||
|
||||
function notification(
|
||||
method: string,
|
||||
params: unknown,
|
||||
observedAt?: number
|
||||
): CodexStructuredSessionEvent {
|
||||
return {
|
||||
type: 'notification',
|
||||
sessionId: SESSION_ID,
|
||||
threadId: THREAD_ID,
|
||||
method,
|
||||
params,
|
||||
...(observedAt !== undefined ? { observedAt } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function translatorFor(tap: ReturnType<typeof recorder>, now?: () => number) {
|
||||
return createCodexJournalTranslator({
|
||||
sink: tap.sink,
|
||||
sessionId: SESSION_ID,
|
||||
primaryThreadId: () => THREAD_ID,
|
||||
...(now ? { now } : {})
|
||||
})
|
||||
}
|
||||
|
||||
const journals = createTrackedJournalOpener()
|
||||
let root: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-codex-turn-lifecycle-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('codex turn lifecycle rows', () => {
|
||||
it('opens the running row with the host receipt time and pins the row time to it', async () => {
|
||||
const journal = await journals.open({
|
||||
identity: {
|
||||
sessionId: SESSION_ID,
|
||||
workspaceId: 'workspace-1',
|
||||
hostId: 'local',
|
||||
agent: 'codex',
|
||||
providerHandle: { kind: 'codex', threadId: THREAD_ID }
|
||||
},
|
||||
now: () => 9_000,
|
||||
journalDir: join(root, SESSION_ID)
|
||||
})
|
||||
const deferred = createDeferredStructuredAgentSessionEventSink()
|
||||
const translator = createCodexJournalTranslator({
|
||||
sink: deferred.sink,
|
||||
sessionId: SESSION_ID,
|
||||
primaryThreadId: () => THREAD_ID
|
||||
})
|
||||
deferred.bind({ journal, fence: 1, publish: () => {} })
|
||||
|
||||
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }, 1_000))
|
||||
await expect(deferred.drained()).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(journal.snapshot().items).toEqual([
|
||||
expect.objectContaining({
|
||||
observedAt: 1_000,
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Codex is working…',
|
||||
turnLifecycle: { turnId: TURN_ID, state: 'running', startedAt: 1_000 }
|
||||
}
|
||||
})
|
||||
])
|
||||
deferred.close()
|
||||
})
|
||||
|
||||
it('revises the running row to completed, carrying the start time forward', () => {
|
||||
const tap = recorder()
|
||||
const translator = translatorFor(tap)
|
||||
|
||||
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }, 1_000))
|
||||
translator.handle(
|
||||
notification('turn/completed', { turn: { id: TURN_ID, status: 'completed' } }, 4_500)
|
||||
)
|
||||
|
||||
expect(tap.tombstones).toEqual([])
|
||||
expect(tap.rows).toEqual([
|
||||
{
|
||||
key: LIFECYCLE_KEY,
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Codex is working…',
|
||||
turnLifecycle: { turnId: TURN_ID, state: 'running', startedAt: 1_000 }
|
||||
}
|
||||
},
|
||||
{
|
||||
key: LIFECYCLE_KEY,
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Codex is working…',
|
||||
turnLifecycle: {
|
||||
turnId: TURN_ID,
|
||||
state: 'completed',
|
||||
startedAt: 1_000,
|
||||
completedAt: 4_500
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
expect(
|
||||
projectStructuredAgentSessionStatus(
|
||||
reduced(tap.rows).map((row, sequence) => ({
|
||||
itemId: row.key,
|
||||
revision: 1,
|
||||
sequence: sequence + 1,
|
||||
observedAt: sequence + 1,
|
||||
body: row.body
|
||||
}))
|
||||
)
|
||||
).toBe('idle')
|
||||
})
|
||||
|
||||
it.each(['interrupted', 'failed', 'cancelled'])(
|
||||
'maps a %s turn status to an interrupted lifecycle',
|
||||
(status) => {
|
||||
const tap = recorder()
|
||||
const translator = translatorFor(tap)
|
||||
|
||||
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }, 1_000))
|
||||
translator.handle(notification('turn/completed', { turn: { id: TURN_ID, status } }, 2_000))
|
||||
|
||||
expect(tap.rows.at(-1)?.body).toMatchObject({
|
||||
turnLifecycle: { state: 'interrupted', startedAt: 1_000, completedAt: 2_000 }
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('stamps the host clock when a boundary arrives without a receipt time', () => {
|
||||
const tap = recorder()
|
||||
let clock = 10_000
|
||||
const translator = translatorFor(tap, () => (clock += 250))
|
||||
|
||||
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
|
||||
translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))
|
||||
|
||||
expect(tap.rows.map((row) => row.body)).toMatchObject([
|
||||
{ turnLifecycle: { state: 'running', startedAt: 10_250 } },
|
||||
{ turnLifecycle: { state: 'completed', startedAt: 10_250, completedAt: 10_500 } }
|
||||
])
|
||||
})
|
||||
|
||||
it('writes only the end time when the start was never observed', () => {
|
||||
const tap = recorder()
|
||||
const translator = translatorFor(tap)
|
||||
|
||||
translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }, 3_000))
|
||||
|
||||
expect(tap.rows).toEqual([
|
||||
{
|
||||
key: LIFECYCLE_KEY,
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Codex is working…',
|
||||
turnLifecycle: { turnId: TURN_ID, state: 'completed', completedAt: 3_000 }
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('revises every open turn to interrupted when the provider ends', () => {
|
||||
const tap = recorder()
|
||||
const translator = translatorFor(tap, () => 7_000)
|
||||
|
||||
translator.handle(notification('turn/started', { turn: { id: 'turn-a' } }, 1_000))
|
||||
translator.handle(notification('turn/started', { turn: { id: 'turn-b' } }, 2_000))
|
||||
translator.handle({ type: 'ended', sessionId: SESSION_ID, reason: 'app-server exited' })
|
||||
|
||||
expect(tap.tombstones).toEqual([])
|
||||
expect(reduced(tap.rows).map((row) => row.body)).toMatchObject([
|
||||
{
|
||||
turnLifecycle: {
|
||||
turnId: 'turn-a',
|
||||
state: 'interrupted',
|
||||
startedAt: 1_000,
|
||||
completedAt: 7_000
|
||||
}
|
||||
},
|
||||
{
|
||||
turnLifecycle: {
|
||||
turnId: 'turn-b',
|
||||
state: 'interrupted',
|
||||
startedAt: 2_000,
|
||||
completedAt: 7_000
|
||||
}
|
||||
},
|
||||
{ text: 'Provider exited: app-server exited' }
|
||||
])
|
||||
})
|
||||
|
||||
it('restores terminal rows for historical turns with both endpoints, in milliseconds', () => {
|
||||
const tap = recorder()
|
||||
const translator = translatorFor(tap)
|
||||
|
||||
expect(
|
||||
translator.restoreThread(THREAD_ID, {
|
||||
turns: [
|
||||
{
|
||||
id: 'turn-done',
|
||||
status: 'completed',
|
||||
startedAt: 1_700_000_000,
|
||||
completedAt: 1_700_000_042,
|
||||
items: [{ type: 'agentMessage', id: 'agent-done', text: 'done' }]
|
||||
},
|
||||
{
|
||||
id: 'turn-cut',
|
||||
status: 'interrupted',
|
||||
startedAt: 1_700_000_100,
|
||||
completedAt: 1_700_000_101,
|
||||
items: []
|
||||
},
|
||||
{ id: 'turn-open', status: 'inProgress', startedAt: 1_700_000_200, items: [] },
|
||||
{ id: 'turn-untimed', status: 'completed', items: [] }
|
||||
]
|
||||
})
|
||||
).toEqual({ accepted: true })
|
||||
|
||||
expect(tap.rows).toEqual([
|
||||
expect.objectContaining({ body: expect.objectContaining({ kind: 'message' }) }),
|
||||
{
|
||||
key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-done',
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Codex is working…',
|
||||
turnLifecycle: {
|
||||
turnId: 'turn-done',
|
||||
state: 'completed',
|
||||
startedAt: 1_700_000_000_000,
|
||||
completedAt: 1_700_000_042_000
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-cut',
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Codex is working…',
|
||||
turnLifecycle: {
|
||||
turnId: 'turn-cut',
|
||||
state: 'interrupted',
|
||||
startedAt: 1_700_000_100_000,
|
||||
completedAt: 1_700_000_101_000
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
expect(tap.tombstones).toEqual([])
|
||||
})
|
||||
|
||||
it('restores no lifecycle rows without a session identity to key them by', () => {
|
||||
const tap = recorder()
|
||||
const translator = createCodexJournalTranslator({
|
||||
sink: tap.sink,
|
||||
primaryThreadId: () => THREAD_ID
|
||||
})
|
||||
|
||||
translator.restoreThread(THREAD_ID, {
|
||||
turns: [{ id: 'turn-done', status: 'completed', startedAt: 1, completedAt: 2, items: [] }]
|
||||
})
|
||||
|
||||
expect(tap.rows).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -40,4 +40,16 @@ describe('CodexJournalActiveTurns', () => {
|
||||
expect(active.size).toBe(0)
|
||||
expect(active.bytes).toBe(0)
|
||||
})
|
||||
|
||||
it('remembers each turn start time until the turn is forgotten', () => {
|
||||
const active = new CodexJournalActiveTurns()
|
||||
|
||||
expect(active.remember('thread', 'turn-1', 1_000)).toBe(true)
|
||||
expect(active.remember('thread', 'turn-1', 2_000)).toBe(true)
|
||||
expect(active.startedAt('thread', 'turn-1')).toBe(1_000)
|
||||
expect(active.startedAt('thread', 'turn-missing')).toBeUndefined()
|
||||
|
||||
active.forget('thread', 'turn-1')
|
||||
expect(active.startedAt('thread', 'turn-1')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,8 @@ export class CodexJournalActiveTurns {
|
||||
/** Bounds active turn keys retained across provider threads. */
|
||||
static readonly MAX_ENTRIES = MAX_CODEX_ACTIVE_TURNS
|
||||
readonly byThread = new Map<string, Set<string>>()
|
||||
/** Host turn-start receipt per remembered turn; the terminal row carries it forward. */
|
||||
private readonly startedAtByTurn = new Map<string, number>()
|
||||
private activeCount = 0
|
||||
private retainedBytes = 0
|
||||
|
||||
@@ -16,6 +18,10 @@ export class CodexJournalActiveTurns {
|
||||
return this.retainedBytes
|
||||
}
|
||||
|
||||
private turnKey(threadId: string, turnId: string): string {
|
||||
return `${encodeURIComponent(threadId)}:${encodeURIComponent(turnId)}`
|
||||
}
|
||||
|
||||
private entryBytes(threadId: string, turnId: string): number {
|
||||
return Buffer.byteLength(threadId, 'utf8') + Buffer.byteLength(turnId, 'utf8')
|
||||
}
|
||||
@@ -33,7 +39,11 @@ export class CodexJournalActiveTurns {
|
||||
return [...(this.byThread.get(threadId) ?? [])].at(-1) ?? null
|
||||
}
|
||||
|
||||
remember(threadId: string, turnId: string): boolean {
|
||||
startedAt(threadId: string, turnId: string): number | undefined {
|
||||
return this.startedAtByTurn.get(this.turnKey(threadId, turnId))
|
||||
}
|
||||
|
||||
remember(threadId: string, turnId: string, startedAt?: number): boolean {
|
||||
const active = this.byThread.get(threadId)
|
||||
if (active?.has(turnId)) {
|
||||
return true
|
||||
@@ -41,6 +51,9 @@ export class CodexJournalActiveTurns {
|
||||
if (!this.canRemember(threadId, turnId)) {
|
||||
return false
|
||||
}
|
||||
if (startedAt !== undefined) {
|
||||
this.startedAtByTurn.set(this.turnKey(threadId, turnId), startedAt)
|
||||
}
|
||||
if (active) {
|
||||
active.add(turnId)
|
||||
} else {
|
||||
@@ -52,6 +65,7 @@ export class CodexJournalActiveTurns {
|
||||
}
|
||||
|
||||
forget(threadId: string, turnId: string): void {
|
||||
this.startedAtByTurn.delete(this.turnKey(threadId, turnId))
|
||||
const active = this.byThread.get(threadId)
|
||||
if (active?.delete(turnId)) {
|
||||
this.activeCount -= 1
|
||||
@@ -64,6 +78,7 @@ export class CodexJournalActiveTurns {
|
||||
|
||||
clear(): void {
|
||||
this.byThread.clear()
|
||||
this.startedAtByTurn.clear()
|
||||
this.activeCount = 0
|
||||
this.retainedBytes = 0
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import type {
|
||||
AgentJournalItemIdentity,
|
||||
AgentJournalStatusItem,
|
||||
AgentJournalTurnLifecycle,
|
||||
AgentJournalTurnLifecycleState
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import type {
|
||||
StructuredAgentSessionEventSink,
|
||||
StructuredAgentSessionSinkAdmission
|
||||
@@ -5,56 +11,65 @@ import type {
|
||||
|
||||
const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true }
|
||||
|
||||
export function codexTurnLifecycleIdentity(
|
||||
sessionId: string,
|
||||
turnId: string
|
||||
): AgentJournalItemIdentity {
|
||||
return {
|
||||
provider: 'legacy',
|
||||
agent: 'codex',
|
||||
sessionId,
|
||||
recordId: `turn-lifecycle:${turnId}`
|
||||
}
|
||||
}
|
||||
|
||||
export function codexTurnLifecycleBody(
|
||||
turnLifecycle: AgentJournalTurnLifecycle
|
||||
): AgentJournalStatusItem {
|
||||
return { kind: 'status', text: 'Codex is working…', turnLifecycle }
|
||||
}
|
||||
|
||||
/** `turn/completed` is Codex's only turn-end notification; a missing status is a clean finish. */
|
||||
export function codexTurnLifecycleState(
|
||||
status: string | null
|
||||
): Extract<AgentJournalTurnLifecycleState, 'completed' | 'interrupted'> {
|
||||
return status === null || status === 'completed' ? 'completed' : 'interrupted'
|
||||
}
|
||||
|
||||
export function publishCodexTurnLifecycle(input: {
|
||||
sink: StructuredAgentSessionEventSink
|
||||
primaryThreadId: string | null
|
||||
sessionId: string
|
||||
threadId: string
|
||||
turnId: string
|
||||
state: 'running' | 'completed'
|
||||
state: AgentJournalTurnLifecycleState
|
||||
startedAt?: number
|
||||
completedAt?: number
|
||||
}): StructuredAgentSessionSinkAdmission {
|
||||
if (input.primaryThreadId !== input.threadId) {
|
||||
return ADMITTED
|
||||
}
|
||||
const identity = {
|
||||
provider: 'legacy' as const,
|
||||
agent: 'codex' as const,
|
||||
sessionId: input.sessionId,
|
||||
recordId: `turn-lifecycle:${input.turnId}`
|
||||
const identity = codexTurnLifecycleIdentity(input.sessionId, input.turnId)
|
||||
const body = codexTurnLifecycleBody({
|
||||
turnId: input.turnId,
|
||||
state: input.state,
|
||||
...(input.startedAt !== undefined ? { startedAt: input.startedAt } : {}),
|
||||
...(input.completedAt !== undefined ? { completedAt: input.completedAt } : {})
|
||||
})
|
||||
// The running row's `ts` is the host's turn-start receipt so clients can anchor a live counter.
|
||||
const appendOptions = {
|
||||
lifecycle: true,
|
||||
...(input.state === 'running' && input.startedAt !== undefined
|
||||
? { observedAt: input.startedAt }
|
||||
: {})
|
||||
}
|
||||
if (input.state === 'completed') {
|
||||
if (input.sink.tryAppendTombstone) {
|
||||
const admission = input.sink.tryAppendTombstone(identity, { lifecycle: true })
|
||||
if (!admission.accepted) {
|
||||
return admission
|
||||
}
|
||||
} else {
|
||||
input.sink.appendTombstone(identity, { lifecycle: true })
|
||||
}
|
||||
} else {
|
||||
const admission = input.sink.tryAppendItem
|
||||
? input.sink.tryAppendItem(
|
||||
identity,
|
||||
{
|
||||
kind: 'status',
|
||||
text: 'Codex is working…',
|
||||
turnLifecycle: { turnId: input.turnId, state: input.state }
|
||||
},
|
||||
{ lifecycle: true }
|
||||
)
|
||||
: (input.sink.appendItem(
|
||||
identity,
|
||||
{
|
||||
kind: 'status',
|
||||
text: 'Codex is working…',
|
||||
turnLifecycle: { turnId: input.turnId, state: input.state }
|
||||
},
|
||||
{ lifecycle: true }
|
||||
),
|
||||
ADMITTED)
|
||||
if (input.sink.tryAppendItem) {
|
||||
const admission = input.sink.tryAppendItem(identity, body, appendOptions)
|
||||
if (!admission.accepted) {
|
||||
return admission
|
||||
}
|
||||
} else {
|
||||
input.sink.appendItem(identity, body, appendOptions)
|
||||
}
|
||||
// Preserve first-work evidence when completion arrives before the journal drains.
|
||||
const publishOptions = {
|
||||
|
||||
@@ -49,6 +49,15 @@ function recorder() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Latest body per identity, in first-seen order: what the journal reducer keeps. */
|
||||
function reduced(rows: readonly Row[]): Row[] {
|
||||
const latest = new Map<string, Row>()
|
||||
for (const row of rows) {
|
||||
latest.set(row.key, row)
|
||||
}
|
||||
return [...latest.values()]
|
||||
}
|
||||
|
||||
/** Fires the coalescing window on demand instead of on wall time. */
|
||||
function manualWindow() {
|
||||
const pending: (() => void)[] = []
|
||||
@@ -226,11 +235,24 @@ describe('codex journal translation', () => {
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Codex is working…',
|
||||
turnLifecycle: { turnId: TURN_ID, state: 'running' }
|
||||
turnLifecycle: { turnId: TURN_ID, state: 'running', startedAt: expect.any(Number) }
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-1',
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Codex is working…',
|
||||
turnLifecycle: {
|
||||
turnId: TURN_ID,
|
||||
state: 'completed',
|
||||
startedAt: expect.any(Number),
|
||||
completedAt: expect.any(Number)
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
expect(tap.tombstones).toEqual(['legacy:codex:session-1:turn-lifecycle%3Aturn-1'])
|
||||
expect(tap.tombstones).toEqual([])
|
||||
})
|
||||
|
||||
it('closes every active turn when the provider session ends after a later turn starts', () => {
|
||||
@@ -244,29 +266,34 @@ describe('codex journal translation', () => {
|
||||
translator.handle(notification('turn/started', { turn: { id: 'turn-later' } }))
|
||||
translator.handle({ type: 'ended', sessionId: SESSION_ID, reason: 'app-server exited' })
|
||||
|
||||
expect(tap.rows.filter((row) => row.body.kind === 'status')).toHaveLength(3)
|
||||
expect(tap.rows.filter((row) => row.body.kind === 'status')).toHaveLength(5)
|
||||
expect(tap.rows.map((row) => row.body)).toEqual([
|
||||
expect.objectContaining({ turnLifecycle: { turnId: 'turn-stale', state: 'running' } }),
|
||||
expect.objectContaining({ turnLifecycle: { turnId: 'turn-later', state: 'running' } }),
|
||||
expect.objectContaining({ text: 'Provider exited: app-server exited' })
|
||||
expect.objectContaining({
|
||||
turnLifecycle: expect.objectContaining({ turnId: 'turn-stale', state: 'running' })
|
||||
}),
|
||||
expect.objectContaining({
|
||||
turnLifecycle: expect.objectContaining({ turnId: 'turn-later', state: 'running' })
|
||||
}),
|
||||
expect.objectContaining({ text: 'Provider exited: app-server exited' }),
|
||||
expect.objectContaining({
|
||||
turnLifecycle: expect.objectContaining({ turnId: 'turn-stale', state: 'interrupted' })
|
||||
}),
|
||||
expect.objectContaining({
|
||||
turnLifecycle: expect.objectContaining({ turnId: 'turn-later', state: 'interrupted' })
|
||||
})
|
||||
])
|
||||
expect(tap.tombstones).toEqual([
|
||||
'legacy:codex:session-1:turn-lifecycle%3Aturn-stale',
|
||||
'legacy:codex:session-1:turn-lifecycle%3Aturn-later'
|
||||
])
|
||||
// The tombstones remove both running rows from the reduced journal; no
|
||||
// lifecycle identity remains live after a session end.
|
||||
expect(tap.tombstones).toEqual([])
|
||||
// Both running rows are revised to interrupted, so no lifecycle identity
|
||||
// remains live after a session end.
|
||||
expect(
|
||||
projectStructuredAgentSessionStatus(
|
||||
tap.rows
|
||||
.filter((row) => !tap.tombstones.includes(row.key))
|
||||
.map((row, sequence) => ({
|
||||
itemId: row.key,
|
||||
revision: 1,
|
||||
sequence: sequence + 1,
|
||||
observedAt: sequence + 1,
|
||||
body: row.body
|
||||
}))
|
||||
reduced(tap.rows).map((row, sequence) => ({
|
||||
itemId: row.key,
|
||||
revision: 1,
|
||||
sequence: sequence + 1,
|
||||
observedAt: sequence + 1,
|
||||
body: row.body
|
||||
}))
|
||||
)
|
||||
).toBe('idle')
|
||||
})
|
||||
@@ -283,21 +310,24 @@ describe('codex journal translation', () => {
|
||||
translator.handle(notification('turn/completed', { turn: { id: 'turn-stale' } }))
|
||||
translator.handle(notification('turn/completed', { turn: { id: 'turn-later' } }))
|
||||
|
||||
expect(tap.tombstones).toEqual([
|
||||
'legacy:codex:session-1:turn-lifecycle%3Aturn-stale',
|
||||
'legacy:codex:session-1:turn-lifecycle%3Aturn-later'
|
||||
expect(tap.tombstones).toEqual([])
|
||||
expect(reduced(tap.rows).map((row) => row.body)).toEqual([
|
||||
expect.objectContaining({
|
||||
turnLifecycle: expect.objectContaining({ turnId: 'turn-stale', state: 'completed' })
|
||||
}),
|
||||
expect.objectContaining({
|
||||
turnLifecycle: expect.objectContaining({ turnId: 'turn-later', state: 'completed' })
|
||||
})
|
||||
])
|
||||
expect(
|
||||
projectStructuredAgentSessionStatus(
|
||||
tap.rows
|
||||
.filter((row) => !tap.tombstones.includes(row.key))
|
||||
.map((row, sequence) => ({
|
||||
itemId: row.key,
|
||||
revision: 1,
|
||||
sequence: sequence + 1,
|
||||
observedAt: sequence + 1,
|
||||
body: row.body
|
||||
}))
|
||||
reduced(tap.rows).map((row, sequence) => ({
|
||||
itemId: row.key,
|
||||
revision: 1,
|
||||
sequence: sequence + 1,
|
||||
observedAt: sequence + 1,
|
||||
body: row.body
|
||||
}))
|
||||
)
|
||||
).toBe('idle')
|
||||
})
|
||||
@@ -431,7 +461,7 @@ describe('codex journal translation', () => {
|
||||
expect(window.idle()).toBe(true)
|
||||
})
|
||||
|
||||
it('settles tools, prompts, exit status, and turn tombstone in one ordered batch', () => {
|
||||
it('settles tools, prompts, exit status, and turn lifecycle in one ordered batch', () => {
|
||||
const tap = recorder()
|
||||
const batches: { settlementId: string; mutations: unknown[] }[] = []
|
||||
tap.sink.appendLifecycleBatch = (settlementId, mutations) => {
|
||||
@@ -488,7 +518,12 @@ describe('codex journal translation', () => {
|
||||
kind: 'item',
|
||||
body: { kind: 'status', text: 'Provider exited: lost child' }
|
||||
}),
|
||||
expect.objectContaining({ kind: 'tombstone' })
|
||||
expect.objectContaining({
|
||||
kind: 'item',
|
||||
body: expect.objectContaining({
|
||||
turnLifecycle: expect.objectContaining({ turnId: TURN_ID, state: 'interrupted' })
|
||||
})
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
@@ -683,7 +718,7 @@ describe('codex journal translation', () => {
|
||||
expect(bodies).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'status',
|
||||
turnLifecycle: { turnId: TURN_ID, state: 'running' }
|
||||
turnLifecycle: expect.objectContaining({ turnId: TURN_ID, state: 'running' })
|
||||
})
|
||||
])
|
||||
expect(publishes).toHaveLength(1)
|
||||
|
||||
@@ -15,12 +15,10 @@ import {
|
||||
type CodexJournalTranslator,
|
||||
type CodexJournalTranslatorDeps
|
||||
} from './codex-structured-journal-contracts'
|
||||
import {
|
||||
settleCodexJournalSession,
|
||||
settleCodexJournalTurn
|
||||
} from './codex-structured-journal-settlement'
|
||||
import { settleCodexJournalSession } from './codex-structured-journal-settlement'
|
||||
import { settleCodexOversizedNotificationFrame } from './codex-structured-journal-translation-frames'
|
||||
import { restoreCodexJournalThread } from './codex-structured-journal-translation-restore'
|
||||
import { CodexJournalTurnBoundaries } from './codex-structured-journal-translation-turn-boundaries'
|
||||
import { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state'
|
||||
import { publishCodexTurnLifecycle } from './codex-structured-journal-translation-turns'
|
||||
import { readCodexTurnId } from './codex-structured-thread-facts'
|
||||
@@ -69,6 +67,21 @@ export function createCodexJournalTranslator(
|
||||
const flushStreams = (): CodexJournalTranslationAdmission =>
|
||||
items.streams.flush() ? CODEX_JOURNAL_ADMITTED : { accepted: false, reason: 'backpressure' }
|
||||
let readActivity = createCodexProviderActivityReader()
|
||||
const resetActivity = (threadId: string): void => {
|
||||
if (threadId === (deps.primaryThreadId?.() ?? null)) {
|
||||
readActivity = createCodexProviderActivityReader()
|
||||
deps.sink.setActivity?.(null)
|
||||
}
|
||||
}
|
||||
const turnBoundaries = new CodexJournalTurnBoundaries({
|
||||
sink: deps.sink,
|
||||
primaryThreadId: () => deps.primaryThreadId?.() ?? null,
|
||||
activeTurns,
|
||||
items,
|
||||
flushSuppression: () => genericFrames.flush(),
|
||||
resetActivity,
|
||||
...(deps.now ? { now: deps.now } : {})
|
||||
})
|
||||
const publishActivity = (
|
||||
event: Extract<CodexStructuredSessionEvent, { type: 'notification' }>,
|
||||
admission: CodexJournalTranslationAdmission
|
||||
@@ -107,6 +120,18 @@ export function createCodexJournalTranslator(
|
||||
? translated.admission
|
||||
: { accepted: false, reason: 'untranslated' }
|
||||
},
|
||||
...(deps.sessionId !== undefined
|
||||
? {
|
||||
restoreTurnLifecycle: (turnLifecycle) =>
|
||||
publishCodexTurnLifecycle({
|
||||
sink: deps.sink,
|
||||
primaryThreadId: deps.primaryThreadId?.() ?? null,
|
||||
sessionId: deps.sessionId as string,
|
||||
threadId,
|
||||
...turnLifecycle
|
||||
})
|
||||
}
|
||||
: {}),
|
||||
flush: items.streams.flush
|
||||
})
|
||||
},
|
||||
@@ -128,7 +153,9 @@ export function createCodexJournalTranslator(
|
||||
pendingPrompts: prompts.pending,
|
||||
currentTurnIds: activeTurns.byThread,
|
||||
primaryThreadId: deps.primaryThreadId?.() ?? null,
|
||||
ordinals: items.ordinals
|
||||
ordinals: items.ordinals,
|
||||
settledTurnLifecycle: (threadId, turnId) =>
|
||||
turnBoundaries.settled(threadId, turnId, 'interrupted', deps.now?.() ?? Date.now())
|
||||
})
|
||||
if (!admission.accepted) {
|
||||
return admission
|
||||
@@ -175,14 +202,14 @@ export function createCodexJournalTranslator(
|
||||
return genericFrames.appendUnhandled(event.kind, event.payload, event.threadId)
|
||||
}
|
||||
if (event.method === 'turn/started') {
|
||||
return startTurn(event)
|
||||
return turnBoundaries.start(event)
|
||||
}
|
||||
const compaction = compactions.handle(event)
|
||||
if (compaction) {
|
||||
return publishActivity(event, compaction)
|
||||
}
|
||||
if (event.method === 'turn/completed') {
|
||||
return completeTurn(event)
|
||||
return turnBoundaries.complete(event)
|
||||
}
|
||||
if (event.method === CODEX_TOKEN_USAGE_METHOD) {
|
||||
// Classified `status-chrome`, so the generic-frame path swallows it
|
||||
@@ -252,70 +279,4 @@ export function createCodexJournalTranslator(
|
||||
activeItems: items.activeItems
|
||||
})
|
||||
}
|
||||
|
||||
function startTurn(
|
||||
event: Extract<CodexStructuredSessionEvent, { type: 'notification' }>
|
||||
): CodexJournalTranslationAdmission {
|
||||
const turnId = readCodexTurnId(event.params)
|
||||
if (!turnId) {
|
||||
return CODEX_JOURNAL_ADMITTED
|
||||
}
|
||||
if (!activeTurns.canRemember(event.threadId, turnId)) {
|
||||
return { accepted: false, reason: 'backpressure' }
|
||||
}
|
||||
const admission = publishCodexTurnLifecycle({
|
||||
sink: deps.sink,
|
||||
primaryThreadId: deps.primaryThreadId?.() ?? null,
|
||||
sessionId: event.sessionId,
|
||||
threadId: event.threadId,
|
||||
turnId,
|
||||
state: 'running'
|
||||
})
|
||||
if (admission.accepted) {
|
||||
activeTurns.remember(event.threadId, turnId)
|
||||
if (event.threadId === (deps.primaryThreadId?.() ?? null)) {
|
||||
readActivity = createCodexProviderActivityReader()
|
||||
deps.sink.setActivity?.(null)
|
||||
}
|
||||
}
|
||||
return admission
|
||||
}
|
||||
|
||||
function completeTurn(event: {
|
||||
sessionId: string
|
||||
threadId: string
|
||||
params: unknown
|
||||
}): CodexJournalTranslationAdmission {
|
||||
const suppressionAdmission = genericFrames.flush()
|
||||
if (!suppressionAdmission.accepted) {
|
||||
return suppressionAdmission
|
||||
}
|
||||
const turnId = readCodexTurnId(event.params) ?? activeTurns.current(event.threadId)
|
||||
if (!turnId) {
|
||||
return CODEX_JOURNAL_ADMITTED
|
||||
}
|
||||
// The roster is deliberately NOT swept here. `spawn_agent` children outlive
|
||||
// the turn that spawned them and go on reporting into the same group, so a
|
||||
// turn boundary is no evidence contact was lost — and `turn/completed` is
|
||||
// the only turn-end notification Codex sends, so an abort cannot be told
|
||||
// apart from a clean finish either. Only `settleSession` may write
|
||||
// `unverifiable`.
|
||||
const admission = settleCodexJournalTurn({
|
||||
sink: deps.sink,
|
||||
sessionId: event.sessionId,
|
||||
threadId: event.threadId,
|
||||
turnId,
|
||||
streams: items.streams,
|
||||
activeItems: items.activeItems
|
||||
})
|
||||
if (admission.accepted) {
|
||||
items.ordinals.forgetTurn(event.threadId, turnId)
|
||||
activeTurns.forget(event.threadId, turnId)
|
||||
if (event.threadId === (deps.primaryThreadId?.() ?? null)) {
|
||||
readActivity = createCodexProviderActivityReader()
|
||||
deps.sink.setActivity?.(null)
|
||||
}
|
||||
}
|
||||
return admission
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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])
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@ const MAX_RETRY_EVENTS = 256
|
||||
const MAX_RETRY_BYTES = 8 * 1024 * 1024
|
||||
const RETRY_DELAY_MS = 25
|
||||
|
||||
type PendingNotification = { method: string; params: unknown; bytes: number }
|
||||
type PendingNotification = { method: string; params: unknown; bytes: number; observedAt?: number }
|
||||
type RetryState = {
|
||||
connection: CodexAppServerConnection
|
||||
events: PendingNotification[]
|
||||
@@ -22,7 +22,8 @@ export function createCodexStructuredNotificationRetry(deps: {
|
||||
sessionId: string,
|
||||
session: CodexSession,
|
||||
method: string,
|
||||
params: unknown
|
||||
params: unknown,
|
||||
observedAt?: number
|
||||
) => CodexJournalTranslationAdmission
|
||||
}) {
|
||||
const states = new Map<string, RetryState>()
|
||||
@@ -48,7 +49,13 @@ export function createCodexStructuredNotificationRetry(deps: {
|
||||
fail(sessionId, state, 'notification retry owner is no longer live')
|
||||
break
|
||||
}
|
||||
const admission = deps.translate(sessionId, session, pending.method, pending.params)
|
||||
const admission = deps.translate(
|
||||
sessionId,
|
||||
session,
|
||||
pending.method,
|
||||
pending.params,
|
||||
pending.observedAt
|
||||
)
|
||||
if (!admission.accepted) {
|
||||
if (admission.reason === 'backpressure') {
|
||||
state.timer = setTimeout(() => {
|
||||
@@ -97,7 +104,8 @@ export function createCodexStructuredNotificationRetry(deps: {
|
||||
sessionId: string,
|
||||
connection: CodexAppServerConnection,
|
||||
method: string,
|
||||
params: unknown
|
||||
params: unknown,
|
||||
observedAt: number | undefined
|
||||
): void => {
|
||||
const bytes = Buffer.byteLength(JSON.stringify({ method, params }), 'utf8')
|
||||
let state = states.get(sessionId)
|
||||
@@ -111,7 +119,12 @@ export function createCodexStructuredNotificationRetry(deps: {
|
||||
fail(sessionId, state, 'notification retry queue overflow')
|
||||
return
|
||||
}
|
||||
state.events.push({ method, params, bytes })
|
||||
state.events.push({
|
||||
method,
|
||||
params,
|
||||
bytes,
|
||||
...(observedAt !== undefined ? { observedAt } : {})
|
||||
})
|
||||
state.bytes += bytes
|
||||
connection.pauseReading?.()
|
||||
}
|
||||
@@ -120,7 +133,8 @@ export function createCodexStructuredNotificationRetry(deps: {
|
||||
handle: (
|
||||
sessionId: string,
|
||||
method: string,
|
||||
params: unknown
|
||||
params: unknown,
|
||||
observedAt?: number
|
||||
): CodexJournalTranslationAdmission => {
|
||||
const session = deps.sessionFor(sessionId)
|
||||
if (!session) {
|
||||
@@ -128,13 +142,13 @@ export function createCodexStructuredNotificationRetry(deps: {
|
||||
}
|
||||
const state = states.get(sessionId)
|
||||
if (state && state.events.length > 0) {
|
||||
enqueue(sessionId, state.connection, method, params)
|
||||
enqueue(sessionId, state.connection, method, params, observedAt)
|
||||
retry(sessionId, state.connection)
|
||||
return { accepted: false, reason: 'backpressure' }
|
||||
}
|
||||
const admission = deps.translate(sessionId, session, method, params)
|
||||
const admission = deps.translate(sessionId, session, method, params, observedAt)
|
||||
if (!admission.accepted) {
|
||||
enqueue(sessionId, session.connection, method, params)
|
||||
enqueue(sessionId, session.connection, method, params, observedAt)
|
||||
retry(sessionId, session.connection)
|
||||
}
|
||||
return admission
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
import type { CodexAppServerServerRequest } from './codex-app-server-connection'
|
||||
import { disposeCodexServerRequest } from './codex-server-request-disposition'
|
||||
import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation'
|
||||
import * as codexRewind from './codex-structured-rewind'
|
||||
import type { CodexSession, CodexStructuredSessionEvent } from './codex-structured-session-state'
|
||||
import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts'
|
||||
import type { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation'
|
||||
|
||||
type EmitCodexEvent = (
|
||||
session: CodexSession,
|
||||
event: CodexStructuredSessionEvent
|
||||
) => CodexJournalTranslationAdmission
|
||||
|
||||
/** One live notification's journal entry: rewind bookkeeping, cancellation deferral, delivery. */
|
||||
export function translateCodexNotification(input: {
|
||||
sessionId: string
|
||||
session: CodexSession
|
||||
method: string
|
||||
params: unknown
|
||||
observedAt?: number
|
||||
turnCancellation: Pick<CodexStructuredTurnCancellation, 'handleNotification'>
|
||||
emit: EmitCodexEvent
|
||||
}): CodexJournalTranslationAdmission {
|
||||
const { sessionId, session, method, params, observedAt } = input
|
||||
codexRewind.observeCodexRewindActivity(session, method, params)
|
||||
if (input.turnCancellation.handleNotification(sessionId, session, method, params, observedAt)) {
|
||||
return { accepted: true }
|
||||
}
|
||||
return deliverCodexNotification(sessionId, session, method, params, input.emit, observedAt)
|
||||
}
|
||||
|
||||
export function deliverCodexNotification(
|
||||
sessionId: string,
|
||||
session: CodexSession | undefined,
|
||||
method: string,
|
||||
params: unknown,
|
||||
emit: EmitCodexEvent
|
||||
emit: EmitCodexEvent,
|
||||
observedAt?: number
|
||||
): CodexJournalTranslationAdmission {
|
||||
if (!session) {
|
||||
return { accepted: true }
|
||||
@@ -23,7 +44,14 @@ export function deliverCodexNotification(
|
||||
const turnId =
|
||||
method === 'turn/started' && threadId === session.threadId ? readCodexTurnId(params) : null
|
||||
const turnWaiter = turnId ? session.turnIdWaiters[0] : undefined
|
||||
const admission = emit(session, { type: 'notification', sessionId, threadId, method, params })
|
||||
const admission = emit(session, {
|
||||
type: 'notification',
|
||||
sessionId,
|
||||
threadId,
|
||||
method,
|
||||
params,
|
||||
...(observedAt !== undefined ? { observedAt } : {})
|
||||
})
|
||||
if (method === 'turn/started' && threadId === session.threadId) {
|
||||
if (admission.accepted && turnId && session.turnIdWaiters[0] === turnWaiter) {
|
||||
session.turnIdWaiters.shift()
|
||||
|
||||
@@ -77,6 +77,8 @@ export async function acquireCodexStructuredSession(input: {
|
||||
const translator = acquireInput.events
|
||||
? createCodexJournalTranslator({
|
||||
sink: acquireInput.events,
|
||||
sessionId,
|
||||
...(deps.now ? { now: deps.now } : {}),
|
||||
primaryThreadId: () => primaryThreadId,
|
||||
bindPromptItemId: (journalItemId, threadId, promptKey) =>
|
||||
acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey)
|
||||
@@ -109,13 +111,16 @@ export async function acquireCodexStructuredSession(input: {
|
||||
env: buildCodexStructuredChildEnvironment(launch, acquireInput.spawnToken, sessionId)
|
||||
},
|
||||
{
|
||||
onNotification: (method, params) =>
|
||||
onNotification: (method, params) => {
|
||||
// Stamped at receipt, ahead of any pre-publication buffering or retry.
|
||||
const observedAt = isCodexTurnBoundary(method) ? (deps.now?.() ?? Date.now()) : undefined
|
||||
input.deliver(
|
||||
acquisition,
|
||||
sessionId,
|
||||
() => notificationRetries.handle(sessionId, method, params),
|
||||
() => notificationRetries.handle(sessionId, method, params, observedAt),
|
||||
Buffer.byteLength(JSON.stringify(params ?? null), 'utf8')
|
||||
),
|
||||
)
|
||||
},
|
||||
onServerRequest: (request) =>
|
||||
input.deliver(
|
||||
acquisition,
|
||||
@@ -233,3 +238,7 @@ export async function acquireCodexStructuredSession(input: {
|
||||
attempt.finish()
|
||||
}
|
||||
}
|
||||
|
||||
function isCodexTurnBoundary(method: string): boolean {
|
||||
return method === 'turn/started' || method === 'turn/completed'
|
||||
}
|
||||
|
||||
@@ -232,11 +232,14 @@ describe('CodexStructuredSessionAdapter lifecycle', () => {
|
||||
it('flushes the final coalesced text before a graceful close', async () => {
|
||||
const codex = fakeCodex()
|
||||
const bodies: AgentJournalMessageItem[] = []
|
||||
const lifecycles: unknown[] = []
|
||||
const tombstones: unknown[] = []
|
||||
const sink: StructuredAgentSessionEventSink = {
|
||||
appendItem: (_identity, body) => {
|
||||
appendItem: (identity, body) => {
|
||||
if (body.kind === 'message') {
|
||||
bodies.push(body)
|
||||
} else if (body.kind === 'status' && body.turnLifecycle) {
|
||||
lifecycles.push({ identity, turnLifecycle: body.turnLifecycle })
|
||||
}
|
||||
},
|
||||
appendTombstone: (identity) => {
|
||||
@@ -266,11 +269,21 @@ describe('CodexStructuredSessionAdapter lifecycle', () => {
|
||||
await adapter.closeSession('session-1')
|
||||
|
||||
expect(bodies.at(-1)?.blocks).toEqual([{ type: 'text', text: 'last words' }])
|
||||
expect(tombstones).toContainEqual({
|
||||
provider: 'legacy',
|
||||
agent: 'codex',
|
||||
sessionId: 'session-1',
|
||||
recordId: 'turn-lifecycle:turn-1'
|
||||
// A requested close interrupts the open turn; the row is revised, not removed.
|
||||
expect(tombstones).toEqual([])
|
||||
expect(lifecycles.at(-1)).toEqual({
|
||||
identity: {
|
||||
provider: 'legacy',
|
||||
agent: 'codex',
|
||||
sessionId: 'session-1',
|
||||
recordId: 'turn-lifecycle:turn-1'
|
||||
},
|
||||
turnLifecycle: {
|
||||
turnId: 'turn-1',
|
||||
state: 'interrupted',
|
||||
startedAt: expect.any(Number),
|
||||
completedAt: expect.any(Number)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,9 +34,9 @@ import {
|
||||
type CodexStructuredSessionEvent
|
||||
} from './codex-structured-session-state'
|
||||
import {
|
||||
deliverCodexNotification,
|
||||
deliverCodexServerRequest,
|
||||
deliverCodexUnhandledFrame
|
||||
deliverCodexUnhandledFrame,
|
||||
translateCodexNotification
|
||||
} from './codex-structured-provider-events'
|
||||
import { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation'
|
||||
import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry'
|
||||
@@ -58,8 +58,16 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
|
||||
constructor(private readonly deps: CodexStructuredSessionAdapterDeps) {
|
||||
this.notificationRetries = createCodexStructuredNotificationRetry({
|
||||
sessionFor: (sessionId) => this.sessions.get(sessionId),
|
||||
translate: (sessionId, session, method, params) =>
|
||||
this.translateNotification(sessionId, session, method, params)
|
||||
translate: (sessionId, session, method, params, observedAt) =>
|
||||
translateCodexNotification({
|
||||
sessionId,
|
||||
session,
|
||||
method,
|
||||
params,
|
||||
observedAt,
|
||||
turnCancellation: this.turnCancellation,
|
||||
emit: (current, event) => this.emit(current, event)
|
||||
})
|
||||
})
|
||||
this.turnCancellation = new CodexStructuredTurnCancellation({
|
||||
captureTurnProcesses: deps.captureTurnProcesses,
|
||||
@@ -68,7 +76,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
|
||||
emit: (session, event) => {
|
||||
const admission = this.emit(session, event)
|
||||
if (!admission.accepted && event.type === 'notification') {
|
||||
this.notificationRetries.handle(event.sessionId, event.method, event.params)
|
||||
const { sessionId, method, params, observedAt } = event
|
||||
this.notificationRetries.handle(sessionId, method, params, observedAt)
|
||||
}
|
||||
return admission
|
||||
}
|
||||
@@ -114,21 +123,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
|
||||
}
|
||||
}
|
||||
|
||||
private translateNotification(
|
||||
sessionId: string,
|
||||
session: CodexSession,
|
||||
method: string,
|
||||
params: unknown
|
||||
): CodexJournalTranslationAdmission {
|
||||
codexRewind.observeCodexRewindActivity(session, method, params)
|
||||
if (this.turnCancellation.handleNotification(sessionId, session, method, params)) {
|
||||
return { accepted: true }
|
||||
}
|
||||
return deliverCodexNotification(sessionId, session, method, params, (current, event) =>
|
||||
this.emit(current, event)
|
||||
)
|
||||
}
|
||||
|
||||
/** Journal first so observers never see an event ahead of its durable row. */
|
||||
private emit(
|
||||
session: CodexSession,
|
||||
|
||||
@@ -80,7 +80,10 @@ async function acquired(
|
||||
codex: ReturnType<typeof fakeCodex>,
|
||||
events: CodexStructuredSessionEvent[] = [],
|
||||
processControl: Partial<
|
||||
Pick<CodexStructuredSessionAdapterDeps, 'captureTurnProcesses' | 'terminateTurnProcesses'>
|
||||
Pick<
|
||||
CodexStructuredSessionAdapterDeps,
|
||||
'captureTurnProcesses' | 'terminateTurnProcesses' | 'now'
|
||||
>
|
||||
> = {}
|
||||
): Promise<CodexStructuredSessionAdapter> {
|
||||
const adapter = new CodexStructuredSessionAdapter({
|
||||
@@ -260,6 +263,32 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the receipt time of a completion deferred behind physical termination', async () => {
|
||||
const events: CodexStructuredSessionEvent[] = []
|
||||
let clock = 5_000
|
||||
let finishTermination!: (terminated: boolean) => void
|
||||
const termination = new Promise<boolean>((resolve) => {
|
||||
finishTermination = resolve
|
||||
})
|
||||
const codex = fakeCodex()
|
||||
codex.routes['turn/interrupt'] = () => {
|
||||
completeTurn(codex)
|
||||
return {}
|
||||
}
|
||||
const adapter = await acquired(codex, events, {
|
||||
terminateTurnProcesses: async () => termination,
|
||||
now: () => clock
|
||||
})
|
||||
|
||||
const pending = adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 })
|
||||
await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('turn/interrupt'))
|
||||
clock = 9_000
|
||||
finishTermination(true)
|
||||
await expect(pending).resolves.toEqual({ cancelled: true })
|
||||
|
||||
expect(events.at(-1)).toMatchObject({ method: 'turn/completed', observedAt: 5_000 })
|
||||
})
|
||||
|
||||
it('does not strand a deferred completion when the interrupt receipt fails', async () => {
|
||||
const events: CodexStructuredSessionEvent[] = []
|
||||
const codex = fakeCodex()
|
||||
|
||||
@@ -21,7 +21,15 @@ export type CodexStructuredLaunch = {
|
||||
}
|
||||
|
||||
export type CodexStructuredSessionEvent =
|
||||
| { type: 'notification'; sessionId: string; threadId: string; method: string; params: unknown }
|
||||
| {
|
||||
type: 'notification'
|
||||
sessionId: string
|
||||
threadId: string
|
||||
method: string
|
||||
params: unknown
|
||||
/** Host receipt time of a turn boundary; survives retry and deferral so a replay is not re-stamped. */
|
||||
observedAt?: number
|
||||
}
|
||||
| { type: 'server-request'; sessionId: string; threadId: string; method: string; params: unknown }
|
||||
| { type: 'provider-frame'; sessionId: string; threadId: string; kind: string; payload: unknown }
|
||||
| {
|
||||
|
||||
@@ -37,3 +37,12 @@ export function readCodexTurnId(payload: unknown): string | null {
|
||||
}
|
||||
return nonEmptyString(record(root.turn)?.id) ?? nonEmptyString(root.turnId)
|
||||
}
|
||||
|
||||
/** `turn/completed` carries `turn.status`; thread history puts `status` on the turn record itself. */
|
||||
export function readCodexTurnStatus(payload: unknown): string | null {
|
||||
const root = record(payload)
|
||||
if (!root) {
|
||||
return null
|
||||
}
|
||||
return nonEmptyString(record(root.turn)?.status) ?? nonEmptyString(root.status)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,8 @@ export class CodexStructuredTurnCancellation {
|
||||
sessionId: string,
|
||||
session: CodexSession,
|
||||
method: string,
|
||||
params: unknown
|
||||
params: unknown,
|
||||
observedAt?: number
|
||||
): boolean {
|
||||
const threadId = readCodexThreadId(params) ?? session.threadId
|
||||
if (method !== 'turn/completed' || threadId !== session.threadId) {
|
||||
@@ -66,7 +67,8 @@ export class CodexStructuredTurnCancellation {
|
||||
sessionId,
|
||||
threadId,
|
||||
method,
|
||||
params
|
||||
params,
|
||||
...(observedAt !== undefined ? { observedAt } : {})
|
||||
}
|
||||
state.deferredCompletions.set(turnId, event)
|
||||
return true
|
||||
|
||||
@@ -262,6 +262,10 @@ describe('provider turn activity routing', () => {
|
||||
claudeMessage({ type: 'result', subtype: 'success', is_error: false, result: 'Done' })
|
||||
)
|
||||
expect(state.activities.at(-1)).toBeNull()
|
||||
expect(state.tombstones).toHaveLength(1)
|
||||
expect(state.tombstones).toHaveLength(0)
|
||||
expect(state.rows.at(-1)).toMatchObject({
|
||||
kind: 'status',
|
||||
turnLifecycle: { turnId: TURN_ID, state: 'completed' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,10 +46,13 @@ export type AttachFlowInput = {
|
||||
callerKey: string
|
||||
params: AgentSessionAttachParams
|
||||
now: () => number
|
||||
/** Publishes the journal before clients can send against the new owner. */
|
||||
/** Publishes the journal before clients can send against the new owner. `acquiredOwner` is
|
||||
* true only when this attach spawned the provider child, so a re-attach to a live one is not
|
||||
* mistaken for a cold acquire. */
|
||||
onAttached: (
|
||||
attached: AttachedJournal,
|
||||
acquisitionGeneration: string | null
|
||||
acquisitionGeneration: string | null,
|
||||
acquiredOwner: boolean
|
||||
) => Promise<void> | void
|
||||
/** Host-owned provider sink, bound to the journal inside `onAttached`. */
|
||||
eventSink?: StructuredAgentSessionEventSink
|
||||
@@ -84,6 +87,7 @@ export async function performAttach(
|
||||
|
||||
let record: AgentSessionRecord
|
||||
let acquisitionGeneration: string | null = null
|
||||
let acquiredOwner = false
|
||||
let reservedRecord: AgentSessionRecord | null = null
|
||||
let unsupportedReservationSettlementAttempted = false
|
||||
let replayed = false
|
||||
@@ -139,6 +143,7 @@ export async function performAttach(
|
||||
const acquired = await acquireOwner(input, record)
|
||||
record = acquired.record
|
||||
acquisitionGeneration = acquired.acquisitionGeneration
|
||||
acquiredOwner = true
|
||||
}
|
||||
} catch (error) {
|
||||
const spawnToken = reservedRecord?.lease.reservedSpawnToken
|
||||
@@ -213,7 +218,7 @@ export async function performAttach(
|
||||
adapter: input.adapter
|
||||
})
|
||||
await importAdoptedTranscript(params, attached, record, preparedTranscript.items)
|
||||
await input.onAttached(attached, acquisitionGeneration)
|
||||
await input.onAttached(attached, acquisitionGeneration, acquiredOwner)
|
||||
await store.recordOperationOutcome({
|
||||
callerKey: input.callerKey,
|
||||
operationId: params.envelope.clientOperationId,
|
||||
|
||||
+11
-1
@@ -22,6 +22,7 @@ import {
|
||||
} from './structured-agent-session-launch-env'
|
||||
import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission'
|
||||
import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry'
|
||||
import { settleStaleRunningTurnsOnAcquire } from './structured-agent-session-stale-turn-verdict'
|
||||
import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context'
|
||||
import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
|
||||
import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry'
|
||||
@@ -95,13 +96,22 @@ export function attachStructuredAgentSession(
|
||||
eventSink.close()
|
||||
context.runtimeState.discardEventSink(sessionId)
|
||||
},
|
||||
onAttached: async (attached, acquisitionGeneration) => {
|
||||
onAttached: async (attached, acquisitionGeneration, acquiredOwner) => {
|
||||
const fence = context.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? 0
|
||||
const previous = context.sessions.get(sessionId)
|
||||
const previousFence = previous?.fence
|
||||
// Site 8: the provisional journal has no owner until the map takes it,
|
||||
// and the barrier below throws by design.
|
||||
try {
|
||||
if (acquiredOwner) {
|
||||
// Before the drain: the buffered events are the new child's, never a stale row's.
|
||||
await settleStaleRunningTurnsOnAcquire({
|
||||
journal: attached.journal,
|
||||
sessionId,
|
||||
fence,
|
||||
acquisitionGeneration
|
||||
})
|
||||
}
|
||||
await bindAndDrain(eventSink, attached.journal, fence, (activity) =>
|
||||
context.subscribers.publish(sessionId, attached.journal, activity)
|
||||
)
|
||||
|
||||
@@ -27,6 +27,8 @@ export type StructuredAgentSessionAppendOptions = {
|
||||
coalescingKey?: string
|
||||
/** Marks a critical lifecycle operation for lifecycle barriers and diagnostics. */
|
||||
lifecycle?: boolean
|
||||
/** Host clock to stamp on the row instead of its append time. */
|
||||
observedAt?: number
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionEventSink = {
|
||||
@@ -162,7 +164,11 @@ export function createDeferredStructuredAgentSessionEventSink(
|
||||
{
|
||||
bytes: estimateStructuredAgentSessionItemBytes(identity, body),
|
||||
coalescingKey: options.coalescingKey,
|
||||
run: (bound) => bound.journal.appendItem(identity, body, { fence: bound.fence })
|
||||
run: (bound) =>
|
||||
bound.journal.appendItem(identity, body, {
|
||||
fence: bound.fence,
|
||||
...(options.observedAt === undefined ? {} : { observedAt: options.observedAt })
|
||||
})
|
||||
},
|
||||
options
|
||||
)
|
||||
@@ -172,7 +178,11 @@ export function createDeferredStructuredAgentSessionEventSink(
|
||||
{
|
||||
bytes: estimateStructuredAgentSessionItemBytes(identity, body),
|
||||
coalescingKey: options.coalescingKey,
|
||||
run: (bound) => bound.journal.appendItem(identity, body, { fence: bound.fence })
|
||||
run: (bound) =>
|
||||
bound.journal.appendItem(identity, body, {
|
||||
fence: bound.fence,
|
||||
...(options.observedAt === undefined ? {} : { observedAt: options.observedAt })
|
||||
})
|
||||
},
|
||||
options
|
||||
),
|
||||
|
||||
@@ -329,8 +329,8 @@ export class StructuredAgentSessionHost {
|
||||
history: StructuredAgentSessionBackgroundTaskChannel['history'] = (request) =>
|
||||
this.backgroundTasks.history(request)
|
||||
|
||||
/** The fully reduced timeline, for readers that cannot tolerate a page's ambiguity — a settled
|
||||
* turn is tombstoned, so an item's ABSENCE from a bounded page proves nothing. */
|
||||
/** The fully reduced timeline, for readers that cannot tolerate a page's ambiguity — rows are
|
||||
* revised or tombstoned in place, so an item's ABSENCE from a bounded page proves nothing. */
|
||||
journalSnapshot = (sessionId: string): AgentJournalSnapshot =>
|
||||
this.requireSession(sessionId).journal.snapshot()
|
||||
|
||||
|
||||
+4
-1
@@ -4,6 +4,7 @@ import type {
|
||||
StructuredAgentSessionHostDeps,
|
||||
StructuredAgentSessionHostSession
|
||||
} from './structured-agent-session-host-types'
|
||||
import { turnVerdictFromDeathEvidence } from './structured-agent-session-stale-turn-verdict'
|
||||
import {
|
||||
retryUnexpectedExitSettlement,
|
||||
type StructuredAgentSessionUnexpectedExitContext
|
||||
@@ -80,7 +81,9 @@ export async function retryLoadedStructuredAgentSessionSettlement(input: {
|
||||
acquisitionGeneration: retrySession.acquisitionGeneration ?? 'recovery'
|
||||
},
|
||||
session: retrySession,
|
||||
stableSettlementId: record.lease.settlementRetryId
|
||||
stableSettlementId: record.lease.settlementRetryId,
|
||||
// Only an observed exit earns an end time; a probe-proven death never saw one.
|
||||
verdict: turnVerdictFromDeathEvidence(record.lease.deathEvidence)
|
||||
})
|
||||
if (!ok) {
|
||||
return false
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import {
|
||||
runningTurnLifecycleRevisions,
|
||||
settleStaleRunningTurnsOnAcquire,
|
||||
turnVerdictFromDeathEvidence
|
||||
} from './structured-agent-session-stale-turn-verdict'
|
||||
|
||||
const THREAD = 'thread-1'
|
||||
const RUNNING_IDENTITY = {
|
||||
provider: 'codex' as const,
|
||||
threadId: THREAD,
|
||||
turnId: 'turn-2',
|
||||
ordinal: 0
|
||||
}
|
||||
|
||||
function lifecycleItem(
|
||||
turnId: string,
|
||||
state: 'running' | 'completed',
|
||||
sequence: number,
|
||||
extra: { startedAt?: number; completedAt?: number } = {}
|
||||
): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: agentJournalItemKey({ provider: 'codex', threadId: THREAD, turnId, ordinal: 0 }),
|
||||
revision: 1,
|
||||
sequence,
|
||||
observedAt: sequence,
|
||||
body: { kind: 'status', text: 'Working', turnLifecycle: { turnId, state, ...extra } }
|
||||
}
|
||||
}
|
||||
|
||||
describe('turn verdict from death evidence', () => {
|
||||
it('earns an end time only from an observed exit', () => {
|
||||
expect(
|
||||
turnVerdictFromDeathEvidence({ kind: 'exit-observed', detail: 'exit', observedAt: 500 })
|
||||
).toEqual({ state: 'interrupted', completedAt: 500 })
|
||||
expect(
|
||||
turnVerdictFromDeathEvidence({ kind: 'pid-absent', detail: 'gone', observedAt: 500 })
|
||||
).toEqual({ state: 'unverifiable' })
|
||||
expect(
|
||||
turnVerdictFromDeathEvidence({ kind: 'identity-mismatch', detail: 'pid', observedAt: 500 })
|
||||
).toEqual({ state: 'unverifiable' })
|
||||
expect(turnVerdictFromDeathEvidence(null)).toEqual({ state: 'unverifiable' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('running turn lifecycle revisions', () => {
|
||||
it('revises only running rows, keeping identity, text and start', () => {
|
||||
const items = [
|
||||
lifecycleItem('turn-1', 'completed', 1, { startedAt: 10, completedAt: 20 }),
|
||||
lifecycleItem('turn-2', 'running', 2, { startedAt: 30 })
|
||||
]
|
||||
expect(runningTurnLifecycleRevisions(items, { state: 'interrupted', completedAt: 40 })).toEqual(
|
||||
[
|
||||
{
|
||||
kind: 'item',
|
||||
identity: RUNNING_IDENTITY,
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Working',
|
||||
turnLifecycle: {
|
||||
turnId: 'turn-2',
|
||||
state: 'interrupted',
|
||||
startedAt: 30,
|
||||
completedAt: 40
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
it('never carries or invents an end time for an unverifiable verdict', () => {
|
||||
const [revision] = runningTurnLifecycleRevisions(
|
||||
[lifecycleItem('turn-2', 'running', 2, { startedAt: 30, completedAt: 99 })],
|
||||
{ state: 'unverifiable' }
|
||||
)
|
||||
expect(
|
||||
revision?.kind === 'item' && revision.body.kind === 'status' && revision.body.turnLifecycle
|
||||
).toEqual({ turnId: 'turn-2', state: 'unverifiable', startedAt: 30 })
|
||||
})
|
||||
|
||||
it('skips rows without a parseable identity', () => {
|
||||
const item = { ...lifecycleItem('turn-2', 'running', 2), itemId: 'not-an-item-key' }
|
||||
expect(runningTurnLifecycleRevisions([item], { state: 'unverifiable' })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('stale running turns on a cold acquire', () => {
|
||||
function journalWith(items: AgentJournalRenderItem[]) {
|
||||
const appendLifecycleBatch = vi.fn(async () => ({ epoch: 'epoch-1', sequence: 9 }))
|
||||
const journal = {
|
||||
snapshot: () => ({ items }),
|
||||
cursor: () => ({ epoch: 'epoch-1', sequence: 8 }),
|
||||
appendLifecycleBatch
|
||||
} as unknown as AgentSessionJournal
|
||||
return { journal, appendLifecycleBatch }
|
||||
}
|
||||
|
||||
it('marks a running row from the dead generation unverifiable without an end time', async () => {
|
||||
const { journal, appendLifecycleBatch } = journalWith([
|
||||
lifecycleItem('turn-1', 'completed', 1, { startedAt: 10, completedAt: 20 }),
|
||||
lifecycleItem('turn-2', 'running', 2, { startedAt: 30 })
|
||||
])
|
||||
|
||||
await expect(
|
||||
settleStaleRunningTurnsOnAcquire({
|
||||
journal,
|
||||
sessionId: 'session-1',
|
||||
fence: 14,
|
||||
acquisitionGeneration: 'generation-2'
|
||||
})
|
||||
).resolves.toBe(1)
|
||||
|
||||
expect(appendLifecycleBatch).toHaveBeenCalledExactlyOnceWith({
|
||||
settlementId: 'stale-turn:session-1:14:generation-2',
|
||||
fence: 14,
|
||||
recovered: true,
|
||||
mutations: [
|
||||
{
|
||||
kind: 'item',
|
||||
identity: RUNNING_IDENTITY,
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Working',
|
||||
turnLifecycle: { turnId: 'turn-2', state: 'unverifiable', startedAt: 30 }
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('writes nothing when no turn is running', async () => {
|
||||
const { journal, appendLifecycleBatch } = journalWith([
|
||||
lifecycleItem('turn-1', 'completed', 1, { startedAt: 10, completedAt: 20 })
|
||||
])
|
||||
await expect(
|
||||
settleStaleRunningTurnsOnAcquire({
|
||||
journal,
|
||||
sessionId: 'session-1',
|
||||
fence: 14,
|
||||
acquisitionGeneration: 'generation-2'
|
||||
})
|
||||
).resolves.toBe(0)
|
||||
expect(appendLifecycleBatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keys the settlement on the journal position when the adapter minted no generation', async () => {
|
||||
const { journal, appendLifecycleBatch } = journalWith([lifecycleItem('turn-2', 'running', 2)])
|
||||
await settleStaleRunningTurnsOnAcquire({
|
||||
journal,
|
||||
sessionId: 'session-1',
|
||||
fence: 14,
|
||||
acquisitionGeneration: null
|
||||
})
|
||||
expect(appendLifecycleBatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ settlementId: 'stale-turn:session-1:14:seq-8' })
|
||||
)
|
||||
})
|
||||
})
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
// What the host may durably say about a turn whose provider child is gone.
|
||||
//
|
||||
// `interrupted` requires the host to have seen the child exit; that receipt is the only end time it
|
||||
// is allowed to record. Everything weaker — a pid probe, an identity mismatch, a journal found
|
||||
// running on a cold acquire — is `unverifiable` and carries no end at all.
|
||||
|
||||
import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalTurnLifecycle
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record'
|
||||
import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition'
|
||||
import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
|
||||
export type StructuredAgentSessionTurnVerdict =
|
||||
| { state: 'interrupted'; completedAt: number }
|
||||
| { state: 'unverifiable' }
|
||||
|
||||
export const UNVERIFIABLE_TURN_VERDICT: StructuredAgentSessionTurnVerdict = {
|
||||
state: 'unverifiable'
|
||||
}
|
||||
|
||||
export function turnVerdictFromDeathEvidence(
|
||||
evidence: AgentSessionDeathEvidence | null | undefined
|
||||
): StructuredAgentSessionTurnVerdict {
|
||||
return evidence?.kind === 'exit-observed'
|
||||
? { state: 'interrupted', completedAt: evidence.observedAt }
|
||||
: UNVERIFIABLE_TURN_VERDICT
|
||||
}
|
||||
|
||||
/** Revises every still-running lifecycle item in place, keeping its identity and start. */
|
||||
export function runningTurnLifecycleRevisions(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
verdict: StructuredAgentSessionTurnVerdict
|
||||
): JournalLifecycleMutationInput[] {
|
||||
const revisions: JournalLifecycleMutationInput[] = []
|
||||
for (const item of items) {
|
||||
if (item.body.kind !== 'status' || item.body.turnLifecycle?.state !== 'running') {
|
||||
continue
|
||||
}
|
||||
const identity = parseAgentJournalItemKey(item.itemId)
|
||||
if (!identity) {
|
||||
continue
|
||||
}
|
||||
revisions.push({
|
||||
kind: 'item',
|
||||
identity,
|
||||
body: { ...item.body, turnLifecycle: settledLifecycle(item.body.turnLifecycle, verdict) }
|
||||
})
|
||||
}
|
||||
return revisions
|
||||
}
|
||||
|
||||
function settledLifecycle(
|
||||
lifecycle: AgentJournalTurnLifecycle,
|
||||
verdict: StructuredAgentSessionTurnVerdict
|
||||
): AgentJournalTurnLifecycle {
|
||||
const settled: AgentJournalTurnLifecycle = { turnId: lifecycle.turnId, state: verdict.state }
|
||||
if (lifecycle.startedAt !== undefined) {
|
||||
settled.startedAt = lifecycle.startedAt
|
||||
}
|
||||
if (verdict.state === 'interrupted') {
|
||||
settled.completedAt = verdict.completedAt
|
||||
}
|
||||
return settled
|
||||
}
|
||||
|
||||
/** A running row found when a NEW child is acquired belongs to a generation whose exit nobody
|
||||
* observed. Must run before that child's buffered events land, or a live turn would be judged. */
|
||||
export async function settleStaleRunningTurnsOnAcquire(input: {
|
||||
journal: AgentSessionJournal
|
||||
sessionId: string
|
||||
fence: number
|
||||
acquisitionGeneration: string | null
|
||||
}): Promise<number> {
|
||||
const { journal } = input
|
||||
const revisions = runningTurnLifecycleRevisions(
|
||||
journal.snapshot().items,
|
||||
UNVERIFIABLE_TURN_VERDICT
|
||||
)
|
||||
const generation = input.acquisitionGeneration ?? `seq-${journal.cursor().sequence}`
|
||||
const settlementId = `stale-turn:${input.sessionId}:${input.fence}:${generation}`
|
||||
for (const chunk of partitionJournalLifecycleMutations(settlementId, revisions)) {
|
||||
await journal.appendLifecycleBatch({
|
||||
settlementId: chunk.settlementId,
|
||||
fence: input.fence,
|
||||
recovered: true,
|
||||
mutations: chunk.mutations
|
||||
})
|
||||
}
|
||||
return revisions.length
|
||||
}
|
||||
+106
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
|
||||
import {
|
||||
@@ -92,6 +94,110 @@ describe('provider-exit recovery tickets', () => {
|
||||
expect(session.hasProviderChild).toBe(false)
|
||||
})
|
||||
|
||||
it('revises a running lifecycle row to interrupted at exit receipt instead of tombstoning it', async () => {
|
||||
const running = {
|
||||
provider: 'codex' as const,
|
||||
threadId: 'thread-1',
|
||||
turnId: 'turn-2',
|
||||
ordinal: 0
|
||||
}
|
||||
const items: AgentJournalRenderItem[] = [
|
||||
{
|
||||
itemId: agentJournalItemKey({ ...running, turnId: 'turn-1' }),
|
||||
revision: 1,
|
||||
sequence: 1,
|
||||
observedAt: 1,
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Done',
|
||||
turnLifecycle: { turnId: 'turn-1', state: 'completed', startedAt: 10, completedAt: 20 }
|
||||
}
|
||||
},
|
||||
{
|
||||
itemId: agentJournalItemKey(running),
|
||||
revision: 1,
|
||||
sequence: 2,
|
||||
observedAt: 2,
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Working',
|
||||
turnLifecycle: { turnId: 'turn-2', state: 'running', startedAt: 30 }
|
||||
}
|
||||
}
|
||||
]
|
||||
const appendLifecycleBatch = vi.fn(async () => ({ epoch: 'epoch-1', sequence: 3 }))
|
||||
const session = {
|
||||
hasProviderChild: true,
|
||||
fence: 7,
|
||||
acquisitionGeneration: GENERATION,
|
||||
journal: { snapshot: () => ({ items }), appendLifecycleBatch }
|
||||
} as unknown as StructuredAgentSessionHostSession
|
||||
|
||||
await settleUnexpectedStructuredAgentSessionExit(
|
||||
{
|
||||
store: {
|
||||
getRecord: () => ({
|
||||
lease: {
|
||||
handoffStage: null,
|
||||
runtimeFence: 7,
|
||||
runtimeKind: 'native',
|
||||
claimStatus: 'live',
|
||||
ownerProcess: 'provider',
|
||||
reservedSpawnToken: null,
|
||||
processlessAt: null
|
||||
}
|
||||
}),
|
||||
transitionHandoff: async () => ({ lease: { runtimeFence: 8 } })
|
||||
},
|
||||
sessions: new Map([[SESSION, session]]),
|
||||
flushLifecycle: async () => ({ ok: true }),
|
||||
publishFence: vi.fn(),
|
||||
hasResumeCapableHolder: () => true,
|
||||
serialize: async (_sessionId, task) => task(),
|
||||
now: () => 1_234
|
||||
} as never,
|
||||
{
|
||||
type: 'ended',
|
||||
sessionId: SESSION,
|
||||
reason: 'provider exited',
|
||||
cause: 'unexpected-exit',
|
||||
fence: 7,
|
||||
acquisitionGeneration: GENERATION,
|
||||
settlementRetryRequired: true
|
||||
}
|
||||
)
|
||||
|
||||
expect(appendLifecycleBatch).toHaveBeenCalledExactlyOnceWith({
|
||||
settlementId: `provider-exit:${SESSION}:7:${GENERATION}`,
|
||||
fence: 7,
|
||||
recovered: true,
|
||||
mutations: [
|
||||
{
|
||||
kind: 'item',
|
||||
identity: {
|
||||
provider: 'orca',
|
||||
clientMessageId: `provider-exit:${SESSION}:7:${GENERATION}`
|
||||
},
|
||||
body: { kind: 'status', text: 'Provider exited: provider exited' }
|
||||
},
|
||||
{
|
||||
kind: 'item',
|
||||
identity: running,
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Working',
|
||||
turnLifecycle: {
|
||||
turnId: 'turn-2',
|
||||
state: 'interrupted',
|
||||
startedAt: 30,
|
||||
completedAt: 1_234
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('does not release or reacquire while terminal settlement retry is still failing', async () => {
|
||||
const session = {
|
||||
hasProviderChild: true,
|
||||
|
||||
+17
-9
@@ -1,4 +1,8 @@
|
||||
import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import {
|
||||
runningTurnLifecycleRevisions,
|
||||
type StructuredAgentSessionTurnVerdict
|
||||
} from './structured-agent-session-stale-turn-verdict'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalRenderItem
|
||||
@@ -47,6 +51,8 @@ export async function settleUnexpectedStructuredAgentSessionExit(
|
||||
return null
|
||||
}
|
||||
const unexpectedEvent = event as UnexpectedExitLifecycleEvent
|
||||
// Receipt of the exit is the one end time the host may record for a running turn.
|
||||
const observedAt = context.now()
|
||||
return context.serialize(unexpectedEvent.sessionId, async () => {
|
||||
const session = context.sessions.get(unexpectedEvent.sessionId)
|
||||
if (
|
||||
@@ -86,7 +92,8 @@ export async function settleUnexpectedStructuredAgentSessionExit(
|
||||
context,
|
||||
event: unexpectedEvent,
|
||||
session,
|
||||
stableSettlementId
|
||||
stableSettlementId,
|
||||
verdict: { state: 'interrupted', completedAt: observedAt }
|
||||
})
|
||||
if (!retried) {
|
||||
settlementFailed = true
|
||||
@@ -168,12 +175,14 @@ export async function retryUnexpectedExitSettlement(input: {
|
||||
event: UnexpectedExitLifecycleEvent
|
||||
session: Pick<StructuredAgentSessionHostSession, 'journal' | 'fence'>
|
||||
stableSettlementId: string
|
||||
verdict: StructuredAgentSessionTurnVerdict
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const mutations = unexpectedExitFallbackMutations(
|
||||
input.event,
|
||||
input.session,
|
||||
input.stableSettlementId
|
||||
input.stableSettlementId,
|
||||
input.verdict
|
||||
)
|
||||
for (const chunk of partitionJournalLifecycleMutations(input.stableSettlementId, mutations)) {
|
||||
await input.session.journal.appendLifecycleBatch({
|
||||
@@ -193,11 +202,12 @@ export async function retryUnexpectedExitSettlement(input: {
|
||||
function unexpectedExitFallbackMutations(
|
||||
event: UnexpectedExitLifecycleEvent,
|
||||
session: Pick<StructuredAgentSessionHostSession, 'journal'>,
|
||||
stableSettlementId: string
|
||||
stableSettlementId: string,
|
||||
verdict: StructuredAgentSessionTurnVerdict
|
||||
): JournalLifecycleMutationInput[] {
|
||||
const mutations: JournalLifecycleMutationInput[] = []
|
||||
const tombstones: JournalLifecycleMutationInput[] = []
|
||||
for (const item of session.journal.snapshot().items) {
|
||||
const { items } = session.journal.snapshot()
|
||||
for (const item of items) {
|
||||
const identity = parseAgentJournalItemKey(item.itemId)
|
||||
if (!identity) {
|
||||
continue
|
||||
@@ -206,16 +216,14 @@ function unexpectedExitFallbackMutations(
|
||||
if (terminal) {
|
||||
mutations.push({ kind: 'item', identity, body: terminal })
|
||||
}
|
||||
if (item.body.kind === 'status' && item.body.turnLifecycle?.state === 'running') {
|
||||
tombstones.push({ kind: 'tombstone', identity })
|
||||
}
|
||||
}
|
||||
mutations.push({
|
||||
kind: 'item',
|
||||
identity: { provider: 'orca', clientMessageId: stableSettlementId },
|
||||
body: { kind: 'status', text: boundJournalStatusText(`Provider exited: ${event.reason}`) }
|
||||
})
|
||||
mutations.push(...tombstones)
|
||||
// Lifecycle rows settle last, in place: the turn's endpoints outlive the child.
|
||||
mutations.push(...runningTurnLifecycleRevisions(items, verdict))
|
||||
return mutations
|
||||
}
|
||||
|
||||
|
||||
+79
-1
@@ -200,13 +200,25 @@ async function seedRunningTurn(provider: 'codex' | 'claude' = 'codex'): Promise<
|
||||
{
|
||||
kind: 'status',
|
||||
text: 'Agent is working...',
|
||||
turnLifecycle: { turnId: 'turn-1', state: 'running' }
|
||||
turnLifecycle: { turnId: 'turn-1', state: 'running', startedAt: NOW - 5_000 }
|
||||
},
|
||||
{ fence: 13 }
|
||||
)
|
||||
await journal.close()
|
||||
}
|
||||
|
||||
function turnLifecycle(turnId: string) {
|
||||
const item = restoredJournal()
|
||||
.snapshot()
|
||||
.items.find(
|
||||
(candidate) =>
|
||||
candidate.body.kind === 'status' && candidate.body.turnLifecycle?.turnId === turnId
|
||||
)
|
||||
return item?.body.kind === 'status'
|
||||
? { ...item.body.turnLifecycle, recovered: item.recovered }
|
||||
: null
|
||||
}
|
||||
|
||||
function restoredJournal(): AgentSessionJournal {
|
||||
const restored = (
|
||||
host as unknown as { sessions: Map<string, { journal: AgentSessionJournal }> }
|
||||
@@ -289,6 +301,13 @@ describe('already-wedged profiles become usable on load', () => {
|
||||
|
||||
expect(acquire).toHaveBeenCalledOnce()
|
||||
expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null)
|
||||
// A pid probe proved the owner gone; nobody saw it exit, so the turn has no end.
|
||||
expect(turnLifecycle('turn-1')).toEqual({
|
||||
turnId: 'turn-1',
|
||||
state: 'unverifiable',
|
||||
startedAt: NOW - 5_000,
|
||||
recovered: true
|
||||
})
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
claimStatus: 'live',
|
||||
handoffStage: null,
|
||||
@@ -314,6 +333,14 @@ describe('already-wedged profiles become usable on load', () => {
|
||||
|
||||
expect(acquire).toHaveBeenCalledOnce()
|
||||
expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null)
|
||||
// The exit was observed, so its receipt is the turn's end.
|
||||
expect(turnLifecycle('turn-1')).toEqual({
|
||||
turnId: 'turn-1',
|
||||
state: 'interrupted',
|
||||
startedAt: NOW - 5_000,
|
||||
completedAt: NOW - 1_000,
|
||||
recovered: true
|
||||
})
|
||||
expect(store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
claimStatus: 'live',
|
||||
handoffStage: null,
|
||||
@@ -322,6 +349,57 @@ describe('already-wedged profiles become usable on load', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('marks a running turn left behind by a released lease unverifiable on a cold acquire', async () => {
|
||||
// No settlement latch: the record was released cleanly, but the journal still says a turn is
|
||||
// running. The child that wrote it is gone and nothing observed its exit.
|
||||
await seedStore(wedgedRecord({ claimStatus: 'released', handoffStage: null }))
|
||||
await seedRunningTurn()
|
||||
openHost()
|
||||
|
||||
expect(await host.attach(CALLER, hostTestAttachParams(13))).toMatchObject({ ok: true })
|
||||
|
||||
expect(acquire).toHaveBeenCalledOnce()
|
||||
expect(turnLifecycle('turn-1')).toEqual({
|
||||
turnId: 'turn-1',
|
||||
state: 'unverifiable',
|
||||
startedAt: NOW - 5_000,
|
||||
recovered: true
|
||||
})
|
||||
expect(
|
||||
restoredJournal()
|
||||
.snapshot()
|
||||
.items.some(
|
||||
(item) => item.body.kind === 'status' && item.body.text.startsWith('Provider exited')
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("leaves the live generation's running turn alone on a re-attach", async () => {
|
||||
await seedStore(wedgedRecord({ claimStatus: 'released', handoffStage: null }))
|
||||
// The child this host spawns stays provably alive across the second attach.
|
||||
openHost({
|
||||
probeOwner: async () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] })
|
||||
})
|
||||
const params = hostTestAttachParams(13)
|
||||
expect(await host.attach(CALLER, params)).toMatchObject({ ok: true })
|
||||
const fence = store.getRecord(SESSION)!.lease.runtimeFence
|
||||
await restoredJournal().appendItem(
|
||||
{ provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 0 },
|
||||
{
|
||||
kind: 'status',
|
||||
text: 'Agent is working...',
|
||||
turnLifecycle: { turnId: 'turn-2', state: 'running', startedAt: NOW }
|
||||
},
|
||||
{ fence }
|
||||
)
|
||||
|
||||
// A reconnecting client replays its attach; the same operation admits the live owner.
|
||||
expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true })
|
||||
|
||||
expect(acquire).toHaveBeenCalledOnce()
|
||||
expect(turnLifecycle('turn-2')).toEqual({ turnId: 'turn-2', state: 'running', startedAt: NOW })
|
||||
})
|
||||
|
||||
it('re-adjudicates a conflicted manual-recovery record whose owner is provably gone', async () => {
|
||||
// A crash can leave a conflicted current-schema row in manual recovery; positive death proof
|
||||
// must make it acquirable again without discarding the provider handle.
|
||||
|
||||
@@ -34,6 +34,22 @@ describe('rewind recovery of newer durable records', () => {
|
||||
text: JSON.stringify(status)
|
||||
})
|
||||
})
|
||||
it.each(['interrupted', 'unverifiable'] as const)(
|
||||
'keeps a %s turn and its recorded endpoints',
|
||||
(state) => {
|
||||
const status = {
|
||||
kind: 'status' as const,
|
||||
text: 'Working',
|
||||
turnLifecycle: {
|
||||
turnId: 'turn',
|
||||
state,
|
||||
startedAt: 10,
|
||||
...(state === 'interrupted' ? { completedAt: 20 } : {})
|
||||
}
|
||||
}
|
||||
expect(restoreRewindJournalBody(status)).toEqual(status)
|
||||
}
|
||||
)
|
||||
it('does not reject a saved recovery prefix over a newer refusal reason', () => {
|
||||
expect(
|
||||
AgentSessionRewindRecordSchema.safeParse({
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { isAdmissibleAgentJournalItemBody } from '../../../shared/agent-session-journal-schemas'
|
||||
import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
AGENT_JOURNAL_TURN_LIFECYCLE_STATES,
|
||||
type AgentJournalItemBody
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind'
|
||||
import { NATIVE_CHAT_ROLES } from '../../../shared/native-chat-types'
|
||||
|
||||
@@ -49,8 +52,7 @@ export function restoreRewindJournalBody(body: StoredBody): AgentJournalItemBody
|
||||
} else if (
|
||||
body.kind === 'status' &&
|
||||
body.turnLifecycle &&
|
||||
body.turnLifecycle.state !== 'running' &&
|
||||
body.turnLifecycle.state !== 'completed'
|
||||
!(AGENT_JOURNAL_TURN_LIFECYCLE_STATES as readonly string[]).includes(body.turnLifecycle.state)
|
||||
) {
|
||||
normalized = fallback()
|
||||
}
|
||||
|
||||
@@ -33,10 +33,10 @@ export function structuredSessionPointerCallerKey(sessionId: string): string {
|
||||
/**
|
||||
* The idle gate for a structured session, read off its FULL reduced timeline.
|
||||
*
|
||||
* Never a bounded page. Settlement tombstones the running turn's lifecycle item rather than
|
||||
* rewriting it to `completed`, so on any tail window an idle session and a busy one whose
|
||||
* lifecycle item scrolled off look identical — and idle-with-history is the normal steady state of
|
||||
* a working agent. Shared so the pointer lane and group addressing cannot disagree about it.
|
||||
* Never a bounded page. A settled turn's lifecycle item is revised in place, so on any tail window
|
||||
* an idle session and a busy one whose lifecycle item scrolled off look identical — and
|
||||
* idle-with-history is the normal steady state of a working agent. Shared so the pointer lane and
|
||||
* group addressing cannot disagree about it.
|
||||
*/
|
||||
export function readStructuredSessionGateFacts(
|
||||
sessionId: string
|
||||
|
||||
@@ -661,8 +661,10 @@ describe('a structured codex session over agentSession.*', () => {
|
||||
expect(older.page.hasOlder).toBe(false)
|
||||
// Every step of the conversation, in order, from the durable journal alone —
|
||||
// no page overlaps another, and nothing the live stream showed is missing.
|
||||
// The turn's lifecycle row outlives the turn: it is revised, never tombstoned.
|
||||
expect([...older.page.items, ...tail.page.items].map((item) => item.body?.kind)).toEqual([
|
||||
'message',
|
||||
'status',
|
||||
'message',
|
||||
'tool-call',
|
||||
'approval',
|
||||
@@ -671,6 +673,7 @@ describe('a structured codex session over agentSession.*', () => {
|
||||
])
|
||||
expect([...older.page.items, ...tail.page.items].map(textOf)).toEqual([
|
||||
'list files',
|
||||
'',
|
||||
'Two files.',
|
||||
'',
|
||||
'',
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { NativeChatTurnActivity } from './native-chat-turn-activity'
|
||||
import { NativeChatTurnActivityLine } from './NativeChatTurnActivityLine'
|
||||
|
||||
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
|
||||
import type { NativeChatSettledTurn } from '../../../../shared/native-chat-turn-status'
|
||||
import {
|
||||
nativeChatTurnDiffs,
|
||||
type NativeChatDiffReveal,
|
||||
@@ -45,6 +46,7 @@ export function NativeChatMessageList({
|
||||
onLinkClick,
|
||||
allowFileUriLinks = false,
|
||||
workingStartedAt,
|
||||
settledTurns,
|
||||
failedDeliveryMessageIds,
|
||||
showTurnStatus = true,
|
||||
turnActivity,
|
||||
@@ -58,6 +60,8 @@ export function NativeChatMessageList({
|
||||
/** Chat-only text multiplier (1 = default), driven by the zoom shortcuts. */
|
||||
fontScale: number
|
||||
workingStartedAt?: number | null
|
||||
/** Host-recorded turn durations keyed by user message id (structured lane). */
|
||||
settledTurns?: ReadonlyMap<string, NativeChatSettledTurn>
|
||||
onLinkClick?: CommentMarkdownLinkClickHandler
|
||||
allowFileUriLinks?: boolean
|
||||
failedDeliveryMessageIds?: ReadonlySet<string>
|
||||
@@ -149,7 +153,8 @@ export function NativeChatMessageList({
|
||||
messages,
|
||||
latestUserIndex,
|
||||
isWorking: showTurnStatus && isWorking,
|
||||
workingStartedAt: showTurnStatus ? workingStartedAt : null
|
||||
workingStartedAt: showTurnStatus ? workingStartedAt : null,
|
||||
settledTurns: showTurnStatus ? settledTurns : null
|
||||
})
|
||||
|
||||
const prependAnchorRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import { NativeChatMessageList } from './NativeChatMessageList'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const session: NativeChatLiveSession = {
|
||||
messages: [
|
||||
{
|
||||
id: 'user-settled',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'Settled on the host' }],
|
||||
timestamp: 1,
|
||||
source: 'transcript'
|
||||
},
|
||||
{
|
||||
id: 'assistant-settled',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'Done.' }],
|
||||
timestamp: 2,
|
||||
source: 'transcript'
|
||||
}
|
||||
],
|
||||
status: 'ready',
|
||||
sessionId: 'session-1',
|
||||
agent: 'codex',
|
||||
hasMore: false,
|
||||
loadingEarlier: false,
|
||||
loadEarlier: vi.fn(),
|
||||
readPhase: 'ready'
|
||||
}
|
||||
|
||||
const settledTurns = new Map([['user-settled', { startedAt: 1, workedSeconds: 197 }]])
|
||||
|
||||
describe('NativeChatMessageList host-settled turn timing', () => {
|
||||
it('renders a host-settled duration without ever clocking the turn locally', () => {
|
||||
// A local clock nowhere near the host's: the value must still be the host's.
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<NativeChatMessageList
|
||||
session={session}
|
||||
isWorking={false}
|
||||
workingStartedAt={null}
|
||||
settledTurns={settledTurns}
|
||||
expandSignal={false}
|
||||
fontScale={1}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Worked for 3m 17s')).toBeInTheDocument()
|
||||
now.mockReturnValue(1_700_000_099_000)
|
||||
rerender(
|
||||
<NativeChatMessageList
|
||||
session={{ ...session }}
|
||||
isWorking={false}
|
||||
workingStartedAt={null}
|
||||
settledTurns={settledTurns}
|
||||
expandSignal={false}
|
||||
fontScale={1}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Worked for 3m 17s')).toBeInTheDocument()
|
||||
} finally {
|
||||
now.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -196,7 +196,8 @@ export function NativeChatStructuredSession(
|
||||
isWorking={controller.isWorking}
|
||||
expandSignal={false}
|
||||
fontScale={fontScale.scale}
|
||||
workingStartedAt={null}
|
||||
workingStartedAt={controller.workingStartedAt}
|
||||
settledTurns={controller.settledTurns}
|
||||
showTurnStatus
|
||||
turnActivity={controller.turnActivity}
|
||||
onLinkClick={onLinkClick}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
nativeChatTurnHasResponse,
|
||||
reduceNativeChatTurnTiming,
|
||||
selectNativeChatTurnStatuses,
|
||||
type NativeChatSettledTurn,
|
||||
type NativeChatTurnStatus,
|
||||
type NativeChatTurnTimingByTurn
|
||||
} from '../../../../shared/native-chat-turn-status'
|
||||
@@ -14,12 +15,15 @@ export function useNativeChatTurnStatus({
|
||||
messages,
|
||||
latestUserIndex,
|
||||
isWorking,
|
||||
workingStartedAt
|
||||
workingStartedAt,
|
||||
settledTurns
|
||||
}: {
|
||||
messages: readonly NativeChatMessage[]
|
||||
latestUserIndex: number
|
||||
isWorking: boolean
|
||||
workingStartedAt?: number | null
|
||||
/** Host-recorded durations; they outrank whatever this client observed. */
|
||||
settledTurns?: ReadonlyMap<string, NativeChatSettledTurn> | null
|
||||
}): {
|
||||
active: NativeChatTurnStatus | null
|
||||
completedByTurn: Readonly<Record<string, NativeChatTurnStatus>>
|
||||
@@ -48,6 +52,7 @@ export function useNativeChatTurnStatus({
|
||||
activeTurnKey,
|
||||
isWorking,
|
||||
workingStartedAt,
|
||||
hasCurrentTurnResponse
|
||||
hasCurrentTurnResponse,
|
||||
settledByTurn: settledTurns ?? undefined
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
let fence = 3
|
||||
let sessionCommands: { name: string; kind: 'command' | 'skill' }[] | undefined
|
||||
let items: AgentJournalRenderItem[] = []
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.call
|
||||
@@ -24,7 +25,7 @@ vi.mock('./use-structured-agent-session-read', () => ({
|
||||
state: {
|
||||
fence,
|
||||
commands: sessionCommands,
|
||||
items: [],
|
||||
items,
|
||||
submissions: [],
|
||||
status: 'ready',
|
||||
error: null,
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
resolveStructuredLaunchSeedOptions
|
||||
} from '../../../../shared/native-chat-session-option-defaults'
|
||||
import type { PersistedNativeChatSessionOptions } from '../../../../shared/native-chat-session-options'
|
||||
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
|
||||
import { useStructuredAgentSession } from './use-structured-agent-session'
|
||||
|
||||
/** Replay every host mutation in order, exactly as the runtime does. */
|
||||
@@ -480,6 +482,84 @@ describe('useStructuredAgentSession options', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('turn timing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
items = []
|
||||
mocks.call.mockImplementation((_target, method) =>
|
||||
method === 'agentSession.options' ? Promise.resolve(OPTIONS) : Promise.resolve(null)
|
||||
)
|
||||
})
|
||||
|
||||
it('exposes host-settled durations and a skew-free live anchor', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.setSystemTime(50_000)
|
||||
items = [
|
||||
{
|
||||
itemId: 'u1',
|
||||
revision: 0,
|
||||
sequence: 1,
|
||||
observedAt: 9_000_000,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'one' }] }
|
||||
},
|
||||
{
|
||||
itemId: 'l1',
|
||||
revision: 1,
|
||||
sequence: 2,
|
||||
observedAt: 9_000_100,
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Done',
|
||||
turnLifecycle: {
|
||||
turnId: 't1',
|
||||
state: 'completed',
|
||||
startedAt: 9_000_000,
|
||||
completedAt: 9_004_000
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
itemId: 'u2',
|
||||
revision: 0,
|
||||
sequence: 3,
|
||||
observedAt: 9_010_000,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'two' }] }
|
||||
},
|
||||
{
|
||||
itemId: 'l2',
|
||||
revision: 1,
|
||||
sequence: 4,
|
||||
observedAt: 9_010_300,
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Working',
|
||||
turnLifecycle: { turnId: 't2', state: 'running', startedAt: 9_010_000 }
|
||||
}
|
||||
}
|
||||
]
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useStructuredAgentSession({
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
agent: 'codex',
|
||||
isVisible: true
|
||||
})
|
||||
)
|
||||
expect(result.current.isWorking).toBe(true)
|
||||
expect(result.current.workingStartedAt).toBe(50_000 - 300)
|
||||
expect([...result.current.settledTurns]).toEqual([
|
||||
['u1', { startedAt: 9_000_000, workedSeconds: 4 }]
|
||||
])
|
||||
vi.setSystemTime(80_000)
|
||||
rerender()
|
||||
expect(result.current.workingStartedAt).toBe(50_000 - 300)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('session command catalog stream', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import { useStructuredAgentSessionMessages } from './use-structured-agent-session-messages'
|
||||
import { selectStructuredAgentTurnActivity } from './native-chat-turn-activity'
|
||||
import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-settings-write'
|
||||
import { useStructuredAgentTurnTiming } from './use-structured-agent-turn-timing'
|
||||
|
||||
export type { StructuredPromptItem } from './structured-agent-session-message-projection'
|
||||
|
||||
@@ -151,6 +152,7 @@ export function useStructuredAgentSession(args: {
|
||||
)
|
||||
const isMonitoringBackgroundTasks =
|
||||
turnId === null && state.backgroundTasks?.state === 'monitoring'
|
||||
const turnTiming = useStructuredAgentTurnTiming(state.items, turnId)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !optionCatalog) {
|
||||
@@ -272,6 +274,8 @@ export function useStructuredAgentSession(args: {
|
||||
!commandPending.current && outboxController.send(...input),
|
||||
retry: outboxController.retry,
|
||||
isWorking: turnId !== null,
|
||||
workingStartedAt: turnTiming.workingStartedAt,
|
||||
settledTurns: turnTiming.settledTurns,
|
||||
turnActivity,
|
||||
isMonitoringBackgroundTasks,
|
||||
backgroundTasks: state.backgroundTasks?.tasks ?? [],
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
|
||||
import { useStructuredAgentTurnTiming } from './use-structured-agent-turn-timing'
|
||||
|
||||
// Host clock sits an hour ahead of the client's so any leak of a host timestamp
|
||||
// into the local anchor shows up as a huge offset.
|
||||
const HOST_START = 3_600_000_000
|
||||
const CLIENT_NOW = 12_345_000
|
||||
|
||||
function user(itemId: string, sequence: number): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId,
|
||||
revision: 0,
|
||||
sequence,
|
||||
observedAt: HOST_START + sequence,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: itemId }] }
|
||||
}
|
||||
}
|
||||
|
||||
function lifecycle(
|
||||
turnId: string,
|
||||
sequence: number,
|
||||
turnLifecycle: Omit<
|
||||
NonNullable<Extract<AgentJournalRenderItem['body'], { kind: 'status' }>['turnLifecycle']>,
|
||||
'turnId'
|
||||
>,
|
||||
observedAt: number
|
||||
): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: `lifecycle-${turnId}`,
|
||||
revision: 1,
|
||||
sequence,
|
||||
observedAt,
|
||||
body: { kind: 'status', text: 'Working', turnLifecycle: { turnId, ...turnLifecycle } }
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useStructuredAgentTurnTiming', () => {
|
||||
it('hands settled host durations through keyed by user message', () => {
|
||||
const items = [
|
||||
user('u1', 1),
|
||||
lifecycle(
|
||||
't1',
|
||||
2,
|
||||
{ state: 'completed', startedAt: HOST_START, completedAt: HOST_START + 197_900 },
|
||||
HOST_START + 5
|
||||
)
|
||||
]
|
||||
const { result } = renderHook(() => useStructuredAgentTurnTiming(items, null))
|
||||
expect(result.current.workingStartedAt).toBeNull()
|
||||
expect([...result.current.settledTurns]).toEqual([
|
||||
['u1', { startedAt: HOST_START, workedSeconds: 197 }]
|
||||
])
|
||||
})
|
||||
|
||||
it('anchors the live counter on the local clock, once per turn, free of host skew', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(CLIENT_NOW)
|
||||
// The host appended the row 2.5s after it saw the turn start.
|
||||
const running = [
|
||||
user('u1', 1),
|
||||
lifecycle('t1', 2, { state: 'running', startedAt: HOST_START }, HOST_START + 2_500)
|
||||
]
|
||||
const { result, rerender } = renderHook(
|
||||
({ items, turnId }: { items: AgentJournalRenderItem[]; turnId: string | null }) =>
|
||||
useStructuredAgentTurnTiming(items, turnId),
|
||||
{ initialProps: { items: running, turnId: 't1' as string | null } }
|
||||
)
|
||||
expect(result.current.workingStartedAt).toBe(CLIENT_NOW - 2_500)
|
||||
|
||||
vi.setSystemTime(CLIENT_NOW + 30_000)
|
||||
rerender({ items: [...running], turnId: 't1' })
|
||||
expect(result.current.workingStartedAt).toBe(CLIENT_NOW - 2_500)
|
||||
|
||||
rerender({ items: running, turnId: null })
|
||||
expect(result.current.workingStartedAt).toBeNull()
|
||||
|
||||
vi.setSystemTime(CLIENT_NOW + 60_000)
|
||||
const next = [
|
||||
...running,
|
||||
user('u2', 3),
|
||||
lifecycle('t2', 4, { state: 'running', startedAt: HOST_START + 50_000 }, HOST_START + 50_100)
|
||||
]
|
||||
rerender({ items: next, turnId: 't2' })
|
||||
expect(result.current.workingStartedAt).toBe(CLIENT_NOW + 60_000 - 100)
|
||||
})
|
||||
|
||||
it('leaves the anchor null when an older host records no start', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(CLIENT_NOW)
|
||||
const items = [user('u1', 1), lifecycle('t1', 2, { state: 'running' }, HOST_START)]
|
||||
const { result } = renderHook(() => useStructuredAgentTurnTiming(items, 't1'))
|
||||
expect(result.current.workingStartedAt).toBeNull()
|
||||
expect(result.current.settledTurns.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
|
||||
import type { NativeChatSettledTurn } from '../../../../shared/native-chat-turn-status'
|
||||
import {
|
||||
selectStructuredAgentRunningTurnTiming,
|
||||
selectStructuredAgentSettledTurns,
|
||||
structuredAgentTurnLocalStartedAt
|
||||
} from '../../../../shared/structured-agent-session-turn-timing'
|
||||
|
||||
type TurnAnchor = { turnId: string; startedAt: number | null }
|
||||
|
||||
/** The live turn's local-clock anchor. Null when its row carries no host start
|
||||
* (older hosts), so local observation applies. */
|
||||
function anchorRunningTurn(items: readonly AgentJournalRenderItem[], turnId: string): TurnAnchor {
|
||||
const timing = selectStructuredAgentRunningTurnTiming(items, turnId)
|
||||
return {
|
||||
turnId,
|
||||
startedAt: timing ? structuredAgentTurnLocalStartedAt(timing, Date.now()) : null
|
||||
}
|
||||
}
|
||||
|
||||
/** Host-recorded turn timing for the structured lane: settled durations straight
|
||||
* off the journal, and a skew-free start for the live counter stamped once per
|
||||
* turn so re-renders never move it. */
|
||||
export function useStructuredAgentTurnTiming(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
turnId: string | null
|
||||
): { settledTurns: ReadonlyMap<string, NativeChatSettledTurn>; workingStartedAt: number | null } {
|
||||
const settledTurns = useMemo(() => selectStructuredAgentSettledTurns(items), [items])
|
||||
const [anchor, setAnchor] = useState<TurnAnchor | null>(null)
|
||||
// Stamp during render (React's derive-from-props pattern) so the first paint of
|
||||
// a new turn already counts from the right instant.
|
||||
if (turnId === null) {
|
||||
if (anchor !== null) {
|
||||
setAnchor(null)
|
||||
}
|
||||
return { settledTurns, workingStartedAt: null }
|
||||
}
|
||||
if (anchor?.turnId !== turnId) {
|
||||
const next = anchorRunningTurn(items, turnId)
|
||||
setAnchor(next)
|
||||
return { settledTurns, workingStartedAt: next.startedAt }
|
||||
}
|
||||
return { settledTurns, workingStartedAt: anchor.startedAt }
|
||||
}
|
||||
@@ -67,6 +67,16 @@ const CANONICAL_BODIES: AgentJournalItemBody[] = [
|
||||
text: 'turn',
|
||||
turnLifecycle: { turnId: 'turn-1', state: 'running' },
|
||||
providerFrame: { provider: 'codex', kind: 'raw', payload: PAYLOAD }
|
||||
},
|
||||
{
|
||||
kind: 'status',
|
||||
text: 'turn',
|
||||
turnLifecycle: { turnId: 'turn-2', state: 'completed', startedAt: 1_000, completedAt: 188_000 }
|
||||
},
|
||||
{
|
||||
kind: 'status',
|
||||
text: 'turn',
|
||||
turnLifecycle: { turnId: 'turn-3', state: 'unverifiable', startedAt: 1_000 }
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -164,7 +164,14 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [
|
||||
text: z.string(),
|
||||
presentation: z.string().optional(),
|
||||
tone: z.string().optional(),
|
||||
turnLifecycle: z.object({ turnId: z.string(), state: z.string().min(1) }).optional(),
|
||||
turnLifecycle: z
|
||||
.object({
|
||||
turnId: z.string(),
|
||||
state: z.string().min(1),
|
||||
startedAt: z.number().finite().positive().optional(),
|
||||
completedAt: z.number().finite().positive().optional()
|
||||
})
|
||||
.optional(),
|
||||
providerFrame: ProviderFrame.optional()
|
||||
})
|
||||
])
|
||||
|
||||
@@ -145,15 +145,33 @@ export type AgentJournalQuestionItem = {
|
||||
resolution: AgentJournalResolution
|
||||
}
|
||||
|
||||
export const AGENT_JOURNAL_TURN_LIFECYCLE_STATES = [
|
||||
'running',
|
||||
'completed',
|
||||
'interrupted',
|
||||
'unverifiable'
|
||||
] as const
|
||||
export type AgentJournalTurnLifecycleState = (typeof AGENT_JOURNAL_TURN_LIFECYCLE_STATES)[number]
|
||||
|
||||
export type AgentJournalTurnLifecycle = {
|
||||
turnId: string
|
||||
state: AgentJournalTurnLifecycleState
|
||||
startedAt?: number
|
||||
completedAt?: number
|
||||
}
|
||||
|
||||
export type AgentJournalStatusItem = {
|
||||
kind: 'status'
|
||||
text: string
|
||||
/** Optional display hints; unknown values retain the ordinary text fallback. */
|
||||
presentation?: string
|
||||
tone?: string
|
||||
/** Durable root-turn lifecycle used by clients to expose cancellation only
|
||||
* while the provider can still accept it. */
|
||||
turnLifecycle?: { turnId: string; state: 'running' | 'completed' }
|
||||
/** Durable root-turn lifecycle. `running` exposes cancellation while the
|
||||
* provider can still accept it; the item is revised to a terminal state, never
|
||||
* tombstoned, so the turn's endpoints survive. Timestamps are the execution
|
||||
* host's clock at provider-event receipt. `unverifiable` carries no end: the
|
||||
* host lost the child without observing its exit. */
|
||||
turnLifecycle?: AgentJournalTurnLifecycle
|
||||
/** Additive fallback for provider traffic this host cannot model yet. Older
|
||||
* clients still render `text`; newer clients expose the bounded frame. */
|
||||
providerFrame?: {
|
||||
|
||||
@@ -165,19 +165,27 @@ export function reduceNativeChatTurnTiming(
|
||||
}
|
||||
}
|
||||
|
||||
/** Split the timing map into the active turn's status and the settled ones. */
|
||||
/** A turn duration the execution host recorded, which outranks anything this
|
||||
* platform observed locally. */
|
||||
export type NativeChatSettledTurn = { startedAt: number; workedSeconds: number }
|
||||
|
||||
/** Split the timing map into the active turn's status and the settled ones.
|
||||
* Host-recorded durations override the locally observed ones per turn; local
|
||||
* observation remains the floor for hosts that record nothing. */
|
||||
export function selectNativeChatTurnStatuses(
|
||||
timingByTurn: NativeChatTurnTimingByTurn,
|
||||
{
|
||||
activeTurnKey,
|
||||
isWorking,
|
||||
workingStartedAt,
|
||||
hasCurrentTurnResponse
|
||||
hasCurrentTurnResponse,
|
||||
settledByTurn
|
||||
}: {
|
||||
activeTurnKey: string
|
||||
isWorking: boolean
|
||||
workingStartedAt?: number | null
|
||||
hasCurrentTurnResponse: boolean
|
||||
settledByTurn?: ReadonlyMap<string, NativeChatSettledTurn>
|
||||
}
|
||||
): { active: NativeChatTurnStatus | null; completedByTurn: Record<string, NativeChatTurnStatus> } {
|
||||
const completedByTurn = Object.fromEntries(
|
||||
@@ -188,6 +196,13 @@ export function selectNativeChatTurnStatuses(
|
||||
{ startedAt: timing.startedAt, thinking: false, workedSeconds: timing.workedSeconds }
|
||||
])
|
||||
) as Record<string, NativeChatTurnStatus>
|
||||
for (const [turnKey, settled] of settledByTurn ?? []) {
|
||||
completedByTurn[turnKey] = {
|
||||
startedAt: settled.startedAt,
|
||||
thinking: false,
|
||||
workedSeconds: settled.workedSeconds
|
||||
}
|
||||
}
|
||||
return {
|
||||
active: isWorking
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from './agent-session-journal-types'
|
||||
import { selectNativeChatTurnStatuses } from './native-chat-turn-status'
|
||||
import {
|
||||
completedStructuredAgentTurnSeconds,
|
||||
selectStructuredAgentRunningTurnTiming,
|
||||
selectStructuredAgentSettledTurns,
|
||||
selectStructuredAgentTurnTimings,
|
||||
structuredAgentTurnLocalStartedAt
|
||||
} from './structured-agent-session-turn-timing'
|
||||
|
||||
let sequence = 0
|
||||
function user(itemId: string): AgentJournalRenderItem {
|
||||
sequence += 1
|
||||
return {
|
||||
itemId,
|
||||
revision: 0,
|
||||
sequence,
|
||||
observedAt: 1_000 + sequence,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: itemId }] }
|
||||
}
|
||||
}
|
||||
function lifecycle(
|
||||
turnId: string,
|
||||
lifecycle: Partial<
|
||||
NonNullable<AgentJournalRenderItem['body'] & { kind: 'status' }>['turnLifecycle']
|
||||
>,
|
||||
observedAt = 1_000 + sequence + 1
|
||||
): AgentJournalRenderItem {
|
||||
sequence += 1
|
||||
return {
|
||||
itemId: `legacy:codex:s:turn-lifecycle%3A${turnId}`,
|
||||
revision: 1,
|
||||
sequence,
|
||||
observedAt,
|
||||
body: {
|
||||
kind: 'status',
|
||||
text: 'Codex is working…',
|
||||
turnLifecycle: { turnId, state: 'running', ...lifecycle }
|
||||
}
|
||||
}
|
||||
}
|
||||
function assistant(): AgentJournalRenderItem {
|
||||
sequence += 1
|
||||
return {
|
||||
itemId: `a${sequence}`,
|
||||
revision: 0,
|
||||
sequence,
|
||||
observedAt: 1_000 + sequence,
|
||||
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'ok' }] }
|
||||
}
|
||||
}
|
||||
|
||||
describe('selectStructuredAgentTurnTimings', () => {
|
||||
it('keys each timed lifecycle row by the user message that opened the turn', () => {
|
||||
const items = [
|
||||
user('u1'),
|
||||
lifecycle('t1', { state: 'completed', startedAt: 10_000, completedAt: 197_500 }),
|
||||
assistant(),
|
||||
user('u2'),
|
||||
lifecycle('t2', { state: 'running', startedAt: 300_000 })
|
||||
]
|
||||
const timings = selectStructuredAgentTurnTimings(items)
|
||||
expect(timings.get('u1')).toMatchObject({
|
||||
state: 'completed',
|
||||
startedAt: 10_000,
|
||||
completedAt: 197_500
|
||||
})
|
||||
expect(timings.get('u2')).toMatchObject({ state: 'running', startedAt: 300_000 })
|
||||
expect(timings.get('u2')?.completedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('skips lifecycle rows that carry no start (older hosts, conversation commands)', () => {
|
||||
const items = [user('u1'), lifecycle('compact:1', {})]
|
||||
expect(selectStructuredAgentTurnTimings(items).size).toBe(0)
|
||||
})
|
||||
|
||||
it('gives a prompt folded into a running turn no timing of its own', () => {
|
||||
const items = [
|
||||
user('u1'),
|
||||
lifecycle('t1', { state: 'completed', startedAt: 5_000, completedAt: 9_000 }),
|
||||
user('u2-steer'),
|
||||
assistant()
|
||||
]
|
||||
const timings = selectStructuredAgentTurnTimings(items)
|
||||
expect([...timings.keys()]).toEqual(['u1'])
|
||||
})
|
||||
|
||||
it('drops an end that precedes its start', () => {
|
||||
const items = [
|
||||
user('u1'),
|
||||
lifecycle('t1', { state: 'completed', startedAt: 9_000, completedAt: 5_000 })
|
||||
]
|
||||
expect(selectStructuredAgentTurnTimings(items).get('u1')?.completedAt).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('completedStructuredAgentTurnSeconds', () => {
|
||||
it('floors whole seconds for completed and interrupted turns', () => {
|
||||
expect(
|
||||
completedStructuredAgentTurnSeconds({
|
||||
state: 'completed',
|
||||
startedAt: 1_000,
|
||||
completedAt: 188_900,
|
||||
observedAt: 1_000
|
||||
})
|
||||
).toBe(187)
|
||||
expect(
|
||||
completedStructuredAgentTurnSeconds({
|
||||
state: 'interrupted',
|
||||
startedAt: 1_000,
|
||||
completedAt: 4_999,
|
||||
observedAt: 1_000
|
||||
})
|
||||
).toBe(3)
|
||||
})
|
||||
|
||||
it('claims nothing for running or unverifiable turns', () => {
|
||||
expect(
|
||||
completedStructuredAgentTurnSeconds({ state: 'running', startedAt: 1_000, observedAt: 1_000 })
|
||||
).toBeNull()
|
||||
expect(
|
||||
completedStructuredAgentTurnSeconds({
|
||||
state: 'unverifiable',
|
||||
startedAt: 1_000,
|
||||
observedAt: 1_000
|
||||
})
|
||||
).toBeNull()
|
||||
expect(completedStructuredAgentTurnSeconds(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('structuredAgentTurnLocalStartedAt', () => {
|
||||
it('moves the local first sighting back by the host-side append lag only', () => {
|
||||
const timing = { state: 'running' as const, startedAt: 50_000, observedAt: 52_500 }
|
||||
// Client clock is 1h ahead of the host: the anchor must not inherit that skew.
|
||||
expect(structuredAgentTurnLocalStartedAt(timing, 3_600_000 + 60_000)).toBe(3_600_000 + 57_500)
|
||||
})
|
||||
|
||||
it('never moves the anchor forward when the row predates its own start', () => {
|
||||
expect(
|
||||
structuredAgentTurnLocalStartedAt(
|
||||
{ state: 'running', startedAt: 50_000, observedAt: 40_000 },
|
||||
100
|
||||
)
|
||||
).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('host-settled turns override local observation', () => {
|
||||
it('wins per turn and leaves locally observed turns from an older host intact', () => {
|
||||
const settled = selectStructuredAgentSettledTurns([
|
||||
user('u1'),
|
||||
lifecycle('t1', { state: 'completed', startedAt: 10_000, completedAt: 197_000 })
|
||||
])
|
||||
const statuses = selectNativeChatTurnStatuses(
|
||||
{ u0: { startedAt: 500, workedSeconds: 4 }, u1: { startedAt: 900, workedSeconds: 2 } },
|
||||
{
|
||||
activeTurnKey: 'u1',
|
||||
isWorking: false,
|
||||
hasCurrentTurnResponse: true,
|
||||
settledByTurn: settled
|
||||
}
|
||||
)
|
||||
expect(statuses.completedByTurn.u1?.workedSeconds).toBe(187)
|
||||
expect(statuses.completedByTurn.u0?.workedSeconds).toBe(4)
|
||||
expect(statuses.active?.workedSeconds).toBe(187)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectStructuredAgentRunningTurnTiming', () => {
|
||||
it('finds the live turn by id and returns null for an older host row without a start', () => {
|
||||
const items = [user('u1'), lifecycle('t1', { state: 'running', startedAt: 7_000 }, 7_250)]
|
||||
expect(selectStructuredAgentRunningTurnTiming(items, 't1')).toEqual({
|
||||
state: 'running',
|
||||
startedAt: 7_000,
|
||||
observedAt: 7_250
|
||||
})
|
||||
expect(selectStructuredAgentRunningTurnTiming(items, 'other')).toBeNull()
|
||||
expect(
|
||||
selectStructuredAgentRunningTurnTiming([user('u2'), lifecycle('t2', {})], 't2')
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
// Turn timing read straight off durable lifecycle items. The execution host
|
||||
// stamps both endpoints on its own clock, so a completed value is the same on
|
||||
// every client and needs no local clock. Shared by desktop and mobile.
|
||||
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalTurnLifecycleState
|
||||
} from './agent-session-journal-types'
|
||||
import type { NativeChatSettledTurn } from './native-chat-turn-status'
|
||||
|
||||
export type StructuredAgentTurnTiming = {
|
||||
state: AgentJournalTurnLifecycleState
|
||||
/** Host clock at provider turn-start receipt. */
|
||||
startedAt: number
|
||||
/** Host clock at the terminal provider event; absent while running or unverifiable. */
|
||||
completedAt?: number
|
||||
/** Host clock when the lifecycle row was appended; with `startedAt` it gives
|
||||
* the host-side lag a client must subtract to anchor a live counter. */
|
||||
observedAt: number
|
||||
}
|
||||
|
||||
function readTiming(item: AgentJournalRenderItem): StructuredAgentTurnTiming | null {
|
||||
const body = item.body
|
||||
if (body.kind !== 'status' || !body.turnLifecycle) {
|
||||
return null
|
||||
}
|
||||
const { state, startedAt, completedAt } = body.turnLifecycle
|
||||
if (startedAt === undefined || !Number.isFinite(startedAt) || startedAt <= 0) {
|
||||
return null
|
||||
}
|
||||
const end =
|
||||
completedAt !== undefined && Number.isFinite(completedAt) && completedAt >= startedAt
|
||||
? completedAt
|
||||
: undefined
|
||||
return {
|
||||
state,
|
||||
startedAt,
|
||||
...(end !== undefined ? { completedAt: end } : {}),
|
||||
observedAt: item.observedAt
|
||||
}
|
||||
}
|
||||
|
||||
/** Timing keyed by the user message that opened each turn. A lifecycle row
|
||||
* belongs to the nearest user message before it in journal order — the
|
||||
* submission row is written ahead of dispatch, so it always precedes the
|
||||
* provider's turn-start, and a prompt Codex folds into an already-running turn
|
||||
* correctly claims no timing of its own. Lifecycle rows without `startedAt`
|
||||
* (older hosts, conversation commands) are skipped. */
|
||||
export function selectStructuredAgentTurnTimings(
|
||||
items: readonly AgentJournalRenderItem[]
|
||||
): ReadonlyMap<string, StructuredAgentTurnTiming> {
|
||||
const timings = new Map<string, StructuredAgentTurnTiming>()
|
||||
let userItemId: string | null = null
|
||||
for (const item of items) {
|
||||
if (item.body.kind === 'message' && item.body.role === 'user') {
|
||||
userItemId = item.itemId
|
||||
continue
|
||||
}
|
||||
const timing = readTiming(item)
|
||||
if (timing && userItemId !== null) {
|
||||
timings.set(userItemId, timing)
|
||||
}
|
||||
}
|
||||
return timings
|
||||
}
|
||||
|
||||
/** The live turn's lifecycle timing, or null when its row carries no host start
|
||||
* (an older host), in which case a surface falls back to local observation. */
|
||||
export function selectStructuredAgentRunningTurnTiming(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
turnId: string
|
||||
): StructuredAgentTurnTiming | null {
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index]
|
||||
if (item?.body.kind === 'status' && item.body.turnLifecycle?.turnId === turnId) {
|
||||
return readTiming(item)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Whole seconds a settled turn ran, or null when the host never observed its end. */
|
||||
export function completedStructuredAgentTurnSeconds(
|
||||
timing: StructuredAgentTurnTiming | undefined
|
||||
): number | null {
|
||||
return timing &&
|
||||
(timing.state === 'completed' || timing.state === 'interrupted') &&
|
||||
timing.completedAt !== undefined
|
||||
? Math.floor((timing.completedAt - timing.startedAt) / 1000)
|
||||
: null
|
||||
}
|
||||
|
||||
/** A local-clock anchor for the live counter that carries no host/client skew:
|
||||
* the client's first sighting of the running row, moved back by the host-side
|
||||
* lag between turn-start receipt and the row's append. Both terms are single-clock. */
|
||||
export function structuredAgentTurnLocalStartedAt(
|
||||
timing: StructuredAgentTurnTiming,
|
||||
firstSeenAt: number
|
||||
): number {
|
||||
return firstSeenAt - Math.max(0, timing.observedAt - timing.startedAt)
|
||||
}
|
||||
|
||||
/** The settled turns a chat surface hands to the shared turn-status selector. */
|
||||
export function selectStructuredAgentSettledTurns(
|
||||
items: readonly AgentJournalRenderItem[]
|
||||
): ReadonlyMap<string, NativeChatSettledTurn> {
|
||||
const settled = new Map<string, NativeChatSettledTurn>()
|
||||
for (const [userItemId, timing] of selectStructuredAgentTurnTimings(items)) {
|
||||
const workedSeconds = completedStructuredAgentTurnSeconds(timing)
|
||||
if (workedSeconds !== null) {
|
||||
settled.set(userItemId, { startedAt: timing.startedAt, workedSeconds })
|
||||
}
|
||||
}
|
||||
return settled
|
||||
}
|
||||
Reference in New Issue
Block a user