diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 00598e6a967..9640608114b 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -76,8 +76,7 @@ export function MobileNativeChatOverlay({ turnIndicator={controller.nativeChatTurnIndicator} workingStartedAt={controller.nativeChatWorkingStartedAt} settledTurns={controller.nativeChatSettledTurns} - activeTurnOpenedBy={controller.nativeChatActiveTurnOpenedBy} - turnKeysByItemId={controller.nativeChatTurnKeysByItemId} + turnJournal={controller.nativeChatTurnJournal} streaming={streaming} onStop={controller.handleNativeChatStop} ask={controller.nativeChatAsk} diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index d0a6c39cba6..6247c5f6c7c 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -21,8 +21,10 @@ import type { NativeChatLiveTurnIndicator, NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' +import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' import { colors } from '../theme/mobile-theme' import { styles } from './mobile-native-chat-view-styles' +import { mobileNativeChatListFooter } from './mobile-native-chat-list-footer' import { buildMobileNativeChatTransientData, mobileNativeChatEmptyState, @@ -66,10 +68,8 @@ type Props = { /** Structured lane: host-recorded turn timing feeding the per-turn status rows. */ workingStartedAt?: number | null settledTurns?: NativeChatSettledTurns | null - /** Structured lane: the key the host says anchors the running turn's bar. */ - activeTurnOpenedBy?: string | null - /** Structured lane: host-attributed turn ownership per journal item id. */ - turnKeysByItemId?: ReadonlyMap | null + /** Structured lane: the journal that places each row in its turn. */ + turnJournal?: NativeChatTurnJournal | null /** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */ /** Interrupt a provider turn. */ onStop?: () => void @@ -155,8 +155,7 @@ export function MobileNativeChatView({ turnIndicator = null, workingStartedAt, settledTurns, - activeTurnOpenedBy = null, - turnKeysByItemId = null, + turnJournal = null, onStop, streaming, hasMore, @@ -276,8 +275,7 @@ export function MobileNativeChatView({ isWorking: agentWorking === true, workingStartedAt, settledTurns, - activeTurnOpenedBy, - turnKeysByItemId, + turnJournal, thinking: turnIndicator?.thinking === true, activityText: turnIndicator?.activityText ?? null, scopeKey: sendSurfaceId @@ -301,6 +299,14 @@ export function MobileNativeChatView({ [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, subagentLabels, turns] ) + const liveStatus = + structuredActivityUi && agentWorking && !hasPendingStructuredInteraction && turns.active ? ( + + ) : null + const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) const showLoading = status === 'loading' && messages.length === 0 @@ -317,7 +323,7 @@ export function MobileNativeChatView({ item.id} renderItem={renderItem} contentContainerStyle={styles.listContent} @@ -347,17 +353,11 @@ export function MobileNativeChatView({ ) : null } - ListFooterComponent={ - structuredActivityUi && - agentWorking && - !hasPendingStructuredInteraction && - turns.active ? ( - - ) : null - } + ListFooterComponent={mobileNativeChatListFooter( + liveStatus, + turns.waitingRows, + renderItem + )} ListEmptyComponent={ emptyState ? ( diff --git a/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts b/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts new file mode 100644 index 00000000000..c0b7fbb5fa2 --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts @@ -0,0 +1,169 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { MobileNativeChatView } from './MobileNativeChatView' + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + FlatList: React.forwardRef((props, ref) => { + React.useImperativeHandle(ref, () => ({ scrollToEnd: vi.fn(), scrollToOffset: vi.fn() }), []) + return React.createElement('FlatList', props) + }), + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' + } +}) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) +vi.mock('react-native-gesture-handler', () => { + const chain = { runOnJS: () => chain, onStart: () => chain, onUpdate: () => chain } + return { + Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain }, + GestureDetector: 'GestureDetector', + GestureHandlerRootView: 'GestureHandlerRootView' + } +}) +vi.mock('lucide-react-native', () => ({ + ArrowDown: 'ArrowDown', + ChevronsDownUp: 'ChevronsDownUp', + ChevronsUpDown: 'ChevronsUpDown', + Square: 'Square' +})) +vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatTurnStatus', () => ({ MobileNativeChatTurnActivity: 'LiveStatus' })) +vi.mock('./MobileNativeChatComposer', () => ({ MobileNativeChatComposer: 'Composer' })) +vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) +vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) +vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('./MobileAgentWorkingIndicator', () => ({ + MobileAgentWorkingIndicator: 'WorkingIndicator' +})) + +let renderer: ReactTestRenderer | null = null +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null +}) + +function user(id: string, text: string, queued = false): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: 1, + source: 'hook', + ...(queued ? { queued: true as const } : {}) + } +} + +function row(itemId: string, sequence: number, body: AgentJournalRenderItem['body']) { + return { + itemId, + revision: 0, + sequence, + observedAt: sequence, + turnScope: { kind: 'thread' as const }, + body + } +} + +function renderView( + folded: NativeChatMessage[], + items: AgentJournalRenderItem[] +): { listed: string[]; footer: string[] } { + act(() => { + renderer = create( + createElement(MobileNativeChatView, { + messages: [], + folded, + status: 'ready', + streaming: null, + onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: () => 0, + getComposerEditGeneration: () => 0, + pending: [], + composerText: '', + onComposerTextChange: vi.fn(), + structuredActivityUi: true, + agentWorking: true, + turnJournal: { items, submissions: [] } + }) + ) + }) + const list = renderer!.root.find((node) => String(node.type) === 'FlatList') + let footer: ReactTestRenderer | null = null + act(() => { + footer = create(list.props.ListFooterComponent) + }) + const drawn = footer!.root + .findAll((node) => String(node.type) === 'LiveStatus' || String(node.type) === 'ChatMessage') + .map((node) => (String(node.type) === 'LiveStatus' ? 'status' : node.props.message.id)) + act(() => footer!.unmount()) + return { + listed: list.props.data.map((message: NativeChatMessage) => message.id), + footer: drawn + } +} + +it('draws a message waiting behind a running /compact after the live status, not in the list', () => { + const drawn = renderView( + [user('compact', '/compact'), user('held', 'Say DONE', true)], + [ + row('compact', 1, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + row('command-turn', 2, { + kind: 'turn', + turnId: 'compact:1', + state: 'running', + userItemId: 'compact', + startedAt: 1 + }), + row('held', 3, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Say DONE' }] + }) + ] + ) + + expect(drawn).toEqual({ listed: ['compact'], footer: ['status', 'held'] }) +}) + +it('keeps a steer on its way into an ordinary running turn in the list, above the live status', () => { + const drawn = renderView( + [user('ask', 'List three fruits'), user('steer', 'Make it four', true)], + [ + row('ask', 1, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'List three fruits' }] + }), + row('turn', 2, { + kind: 'turn', + turnId: 'turn-1', + state: 'running', + userItemId: 'ask', + startedAt: 1 + }), + row('steer', 3, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'Make it four' }] + }) + ] + ) + + expect(drawn).toEqual({ listed: ['ask', 'steer'], footer: ['status'] }) +}) diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 30bf33d7d51..8e4cffe158f 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -10,6 +10,7 @@ import type { NativeChatLiveTurnIndicator, NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' +import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' 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' @@ -37,10 +38,8 @@ export type MobileNativeChatController = { /** Structured lane: host-recorded turn timing for the per-turn status rows. */ nativeChatWorkingStartedAt: number | null nativeChatSettledTurns: NativeChatSettledTurns | null - /** Structured lane: the user message the host says opened the running turn. */ - nativeChatActiveTurnOpenedBy: string | null - /** Structured lane: the turn that owns each journal item's row. */ - nativeChatTurnKeysByItemId: ReadonlyMap | null + /** Structured lane: the journal that places each transcript row in its turn. */ + nativeChatTurnJournal: NativeChatTurnJournal | null nativeChatCanStop: boolean nativeChatStreamingText?: string /** Agent mid-turn, regardless of whether chat is the visible view. */ diff --git a/mobile/src/session/mobile-native-chat-list-footer.tsx b/mobile/src/session/mobile-native-chat-list-footer.tsx new file mode 100644 index 00000000000..df81930e5fb --- /dev/null +++ b/mobile/src/session/mobile-native-chat-list-footer.tsx @@ -0,0 +1,23 @@ +import { Fragment, type ReactElement } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' + +type MobileNativeChatListRow = { item: NativeChatMessage; index: number } + +/** The live turn's status, then the messages waiting behind that turn, drawn below it. */ +export function mobileNativeChatListFooter( + liveStatus: ReactElement | null, + waitingRows: readonly MobileNativeChatListRow[], + renderRow: (row: MobileNativeChatListRow) => ReactElement +): ReactElement | null { + if (waitingRows.length === 0) { + return liveStatus + } + return ( + <> + {liveStatus} + {waitingRows.map((row) => ( + {renderRow(row)} + ))} + + ) +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index c14c3e1251c..0d236770be3 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -288,10 +288,7 @@ export function useMobileNativeChatController(args: { nativeChatTurnIndicator: activeChatStructured ? structuredNativeChat.turnIndicator : null, nativeChatWorkingStartedAt: activeChatStructured ? structuredNativeChat.workingStartedAt : null, nativeChatSettledTurns: activeChatStructured ? structuredNativeChat.settledTurns : null, - nativeChatActiveTurnOpenedBy: activeChatStructured - ? structuredNativeChat.activeTurnOpenedBy - : null, - nativeChatTurnKeysByItemId: activeChatStructured ? structuredNativeChat.turnKeysByItemId : null, + nativeChatTurnJournal: activeChatStructured ? structuredNativeChat.turnJournal : null, nativeChatCanStop: activeChatStructured ? structuredNativeChat.turnId !== null : nativeChatAgentWorking, diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx index ed0b22dfc1f..f5cc0ff8c02 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx @@ -1,7 +1,13 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../src/shared/agent-session-journal-types' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' @@ -20,18 +26,16 @@ function Harness({ enabled, isWorking = true, settledTurns, + turnJournal, workingStartedAt, - activeTurnOpenedBy, - turnKeysByItemId, scopeKey = 'host\0worktree\0tab-a' }: { messages: readonly NativeChatMessage[] enabled: boolean isWorking?: boolean settledTurns?: NativeChatSettledTurns + turnJournal?: NativeChatTurnJournal workingStartedAt?: number | null - activeTurnOpenedBy?: string | null - turnKeysByItemId?: ReadonlyMap | null scopeKey?: string }): React.JSX.Element { const disclosure = useMobileNativeChatTurnDisclosure({ @@ -39,9 +43,8 @@ function Harness({ enabled, isWorking, settledTurns, + turnJournal, workingStartedAt, - activeTurnOpenedBy, - turnKeysByItemId, scopeKey }) return createElement('result', { disclosure }) @@ -211,199 +214,432 @@ describe('useMobileNativeChatTurnDisclosure', () => { } }) - it('keeps the live bar under the prompt that opened the running turn, not a mid-turn send', () => { - vi.useFakeTimers() - try { - vi.setSystemTime(10_000) - const tool: NativeChatMessage = { - id: 'tool-a', - role: 'assistant', - blocks: [ - { type: 'tool-call', name: 'Bash', input: { command: 'sleep 15' }, state: 'running' } - ], - timestamp: null, - source: 'transcript' + describe('keeps the live bar under the prompt that opened the running turn, not a mid-turn send', () => { + const tool: NativeChatMessage = { + id: 'tool-a', + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'Bash', input: { command: 'sleep 15' }, state: 'running' } + ], + timestamp: null, + source: 'transcript' + } + const messages = [userMessage('A'), tool, userMessage('B')] + const user = ( + id: string, + sequence: number, + turnScope?: AgentJournalTurnScope + ): AgentJournalRenderItem => ({ + itemId: id, + revision: 0, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, + ...(turnScope ? { turnScope } : {}) + }) + const turn = ( + turnId: string, + sequence: number, + state: 'running' | 'interrupted', + userItemId: string, + turnScope?: AgentJournalTurnScope + ): AgentJournalRenderItem => ({ + itemId: turnId, + revision: 0, + sequence, + observedAt: sequence, + body: { kind: 'turn', turnId, state, userItemId }, + ...(turnScope ? { turnScope } : {}) + }) + const toolItem = ( + sequence: number, + turnScope?: AgentJournalTurnScope + ): AgentJournalRenderItem => ({ + itemId: 'tool-a', + revision: 0, + sequence, + observedAt: sequence, + body: { + kind: 'tool-call', + name: 'Bash', + input: { command: 'sleep 15' }, + state: 'running' + }, + ...(turnScope ? { turnScope } : {}) + }) + const rows = () => { + const disclosure = renderer!.root.findByType('result').props.disclosure + return messages.map((message, index) => disclosure.resolveRow(index, message)) + } + const render = (props: Parameters[0]) => + act(() => { + if (renderer) { + renderer.update(createElement(Harness, props)) + } else { + renderer = create(createElement(Harness, props)) + } + }) + + it("on a host that states each row's turn, the steered message and the work are one live turn", () => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const t1 = { kind: 'turn', turnItemId: 't1' } as const + const thread = { kind: 'thread' } as const + // B was handed over while A's turn runs, so the host scopes it to that turn. + const whileA = [ + user('A', 1, thread), + turn('t1', 2, 'running', 'A', thread), + toolItem(3, t1), + user('B', 4, t1) + ] + render({ + messages, + enabled: true, + workingStartedAt: 5_000, + turnJournal: { items: whileA, submissions: [] } + }) + let [rowA, rowTool, rowB] = rows() + expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) + expect(rowB.turnStatus).toBeNull() + expect([rowA, rowTool, rowB].map((row) => row.activeTurnIsWorking)).toEqual([ + true, + true, + true + ]) + + // B's own turn opens: A takes the host's settled duration, B counts from A's end. + const whileB = [ + user('A', 1, thread), + turn('t1', 2, 'interrupted', 'A', thread), + toolItem(3, t1), + user('B', 4, t1), + turn('t2', 5, 'running', 'B', thread) + ] + render({ + messages, + enabled: true, + workingStartedAt: 22_000, + settledTurns: new Map([['A', { startedAt: 5_000, workedSeconds: 17 }]]), + turnJournal: { items: whileB, submissions: [] } + }) + ;[rowA, rowTool, rowB] = rows() + expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 17 }) + expect(rowB.turnStatus).toEqual({ startedAt: 22_000, thinking: false, workedSeconds: null }) + expect(rowB.activeTurnIsWorking).toBe(true) + expect(rowTool.activeTurnIsWorking).toBe(false) + } finally { + vi.useRealTimers() } - const messages = [userMessage('A'), tool, userMessage('B')] - const rows = () => { + }) + + it('on a host that states no turn, the bar and liveness follow the running record by journal order', () => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const whileA = [user('A', 1), turn('t1', 2, 'running', 'A'), toolItem(3), user('B', 4)] + render({ + messages, + enabled: true, + workingStartedAt: 5_000, + turnJournal: { items: whileA, submissions: [] } + }) + let [rowA, rowTool, rowB] = rows() + expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) + expect(rowB.turnStatus).toBeNull() + // Liveness follows the owning turn: A's tool row stays live while B waits. + expect(rowTool.activeTurnIsWorking).toBe(true) + expect(rowB.activeTurnIsWorking).toBe(false) + + const whileB = [ + user('A', 1), + turn('t1', 2, 'interrupted', 'A'), + toolItem(3), + user('B', 4), + turn('t2', 5, 'running', 'B') + ] + render({ + messages, + enabled: true, + workingStartedAt: 22_000, + settledTurns: new Map([['A', { startedAt: 5_000, workedSeconds: 17 }]]), + turnJournal: { items: whileB, submissions: [] } + }) + ;[rowA, , rowB] = rows() + expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 17 }) + expect(rowB.turnStatus).toEqual({ startedAt: 22_000, thinking: false, workedSeconds: null }) + } finally { + vi.useRealTimers() + } + }) + }) + + /** A journal of `entries` in order, each naming the turn it belongs to (null for none). With + * `statesScope` false the rows carry no scope, as an older host writes them. */ + function journalOf( + entries: readonly [itemId: string, body: AgentJournalItemBody, turnItemId: string | null][], + statesScope: boolean + ): NativeChatTurnJournal { + return { + items: entries.map(([itemId, body, turnItemId], index) => ({ + itemId, + revision: 0, + sequence: index + 1, + observedAt: index + 1, + body, + ...(statesScope + ? { + turnScope: turnItemId + ? { kind: 'turn' as const, turnItemId } + : { kind: 'thread' as const } + } + : {}) + })), + submissions: [] + } + } + const said = (role: 'user' | 'assistant'): AgentJournalItemBody => ({ + kind: 'message', + role, + blocks: [] + }) + const record = ( + turnId: string, + state: 'running' | 'completed', + userItemId: string + ): AgentJournalItemBody => ({ kind: 'turn', turnId, state, userItemId }) + const hosts = [ + ['states each row’s turn', true], + ['states no scope', false] + ] as const + + it.each(hosts)( + "keeps a running turn's rows live across a mid-turn send the host folded in, on a host that %s", + (_host, statesScope) => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const tool = (id: string): NativeChatMessage => ({ + id, + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'Bash', input: { command: 'sleep 15' }, state: 'running' } + ], + timestamp: null, + source: 'transcript' + }) + // The #23621 shape: B lands mid-turn and the tool rows after it are still A's. + const messages = [userMessage('A'), tool('t1'), userMessage('B'), tool('t2')] + const turnJournal = journalOf( + [ + ['A', said('user'), null], + ['tA', record('tA', 'running', 'A'), null], + ['t1', said('assistant'), 'tA'], + ['B', said('user'), 'tA'], + ['t2', said('assistant'), 'tA'] + ], + statesScope + ) + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + workingStartedAt: 5_000, + turnJournal + }) + ) + }) const disclosure = renderer!.root.findByType('result').props.disclosure - return messages.map((message, index) => disclosure.resolveRow(index, message)) + const [rowA, rowT1, rowB, rowT2] = messages.map((message, index) => + disclosure.resolveRow(index, message) + ) + expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) + // The steered bubble shares A's turn but never carries a bar of its own. + expect(rowB.turnStatus).toBeNull() + expect(rowT1.activeTurnIsWorking).toBe(true) + expect(rowT2.activeTurnIsWorking).toBe(true) + } finally { + vi.useRealTimers() } - // B was sent while A's turn runs; the host still names A as the running turn's opener. - act(() => { - renderer = create( - createElement(Harness, { - messages, - enabled: true, - workingStartedAt: 5_000, - activeTurnOpenedBy: 'A' - }) - ) - }) - let [rowA, rowTool, rowB] = rows() - expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) - expect(rowB.turnStatus).toBeNull() - // Liveness follows the owning turn: A's tool row stays live while B waits. - expect(rowTool.activeTurnIsWorking).toBe(true) - expect(rowB.activeTurnIsWorking).toBe(false) - - // B's own turn opens: A takes the host's settled duration, B counts from A's end. - act(() => { - renderer?.update( - createElement(Harness, { - messages, - enabled: true, - workingStartedAt: 22_000, - settledTurns: new Map([['A', { startedAt: 5_000, workedSeconds: 17 }]]), - activeTurnOpenedBy: 'B' - }) - ) - }) - ;[rowA, , rowB] = rows() - expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 17 }) - expect(rowB.turnStatus).toEqual({ startedAt: 22_000, thinking: false, workedSeconds: null }) - } finally { - vi.useRealTimers() } - }) + ) - it("keeps a running turn's rows live across a mid-turn send the host folded in", () => { - vi.useFakeTimers() - try { - vi.setSystemTime(10_000) - const tool = (id: string): NativeChatMessage => ({ - id, - role: 'assistant', - blocks: [ - { type: 'tool-call', name: 'Bash', input: { command: 'sleep 15' }, state: 'running' } - ], - timestamp: null, - source: 'transcript' - }) - // The #23621 shape: B lands mid-turn and the tool rows after it are still A's. - const messages = [userMessage('A'), tool('t1'), userMessage('B'), tool('t2')] - const owned = new Map([ - ['A', 'A'], - ['t1', 'A'], - ['B', 'A'], - ['t2', 'A'] - ]) - act(() => { - renderer = create( - createElement(Harness, { - messages, - enabled: true, - workingStartedAt: 5_000, - activeTurnOpenedBy: 'A', - turnKeysByItemId: owned - }) + it.each(hosts)( + "anchors a provider-opened turn's bar above its first row, on a host that %s", + (_host, statesScope) => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const woke: NativeChatMessage = { + id: 'woke', + role: 'assistant', + blocks: [{ type: 'text', text: 'Woke up.' }], + timestamp: null, + source: 'transcript' + } + const messages = [userMessage('u1'), woke] + const turnJournal = journalOf( + [ + ['u1', said('user'), null], + ['wake', record('wake', 'completed', 'claude:wake'), null], + ['woke', said('assistant'), 'wake'] + ], + statesScope ) - }) - const disclosure = renderer!.root.findByType('result').props.disclosure - const [rowA, rowT1, rowB, rowT2] = messages.map((message, index) => - disclosure.resolveRow(index, message) - ) - expect(rowA.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: null }) - // The steered bubble shares A's turn but never carries a bar of its own. - expect(rowB.turnStatus).toBeNull() - expect(rowT1.activeTurnIsWorking).toBe(true) - expect(rowT2.activeTurnIsWorking).toBe(true) - } finally { - vi.useRealTimers() - } - }) - - it("anchors a provider-opened turn's bar above its first row", () => { - vi.useFakeTimers() - try { - vi.setSystemTime(10_000) - const woke: NativeChatMessage = { - id: 'woke', - role: 'assistant', - blocks: [{ type: 'text', text: 'Woke up.' }], - timestamp: null, - source: 'transcript' + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + isWorking: false, + settledTurns: new Map([['wake', { startedAt: 5_000, workedSeconds: 9 }]]), + turnJournal + }) + ) + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const [rowU1, rowWoke] = messages.map((message, index) => + disclosure.resolveRow(index, message) + ) + expect(rowU1.turnStatus).toBeNull() + expect(rowWoke.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 9 }) + expect(rowWoke.turnStatusAbove).toBe(true) + } finally { + vi.useRealTimers() } - const messages = [userMessage('u1'), woke] - act(() => { - renderer = create( - createElement(Harness, { - messages, - enabled: true, - isWorking: false, - settledTurns: new Map([['wake', { startedAt: 5_000, workedSeconds: 9 }]]), - turnKeysByItemId: new Map([ - ['u1', 'u1'], - ['woke', 'wake'] - ]) - }) - ) - }) - const disclosure = renderer!.root.findByType('result').props.disclosure - const [rowU1, rowWoke] = messages.map((message, index) => - disclosure.resolveRow(index, message) - ) - expect(rowU1.turnStatus).toBeNull() - expect(rowWoke.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 9 }) - expect(rowWoke.turnStatusAbove).toBe(true) - } finally { - vi.useRealTimers() } - }) + ) - it("never hands a provider-opened turn's clock to a message sent during it", () => { - vi.useFakeTimers() - try { - vi.setSystemTime(10_000) - const row = (id: string): NativeChatMessage => ({ - id, - role: 'assistant', - blocks: [{ type: 'text', text: id }], - timestamp: null, - source: 'transcript' - }) - // A wake turn runs; B is sent during it and folded in, so B opened nothing. - const messages = [row('w1'), userMessage('B'), row('w2')] - const owned = new Map([ - ['w1', 'wake'], - ['B', 'wake'], - ['w2', 'wake'] - ]) - act(() => { - renderer = create( - createElement(Harness, { - messages, - enabled: true, - workingStartedAt: 5_000, - activeTurnOpenedBy: 'wake', - turnKeysByItemId: owned, - settledTurns: new Map([['wake', null]]) - }) + it.each(hosts)( + "never hands a provider-opened turn's clock to a message sent during it, on a host that %s", + (_host, statesScope) => { + vi.useFakeTimers() + try { + vi.setSystemTime(10_000) + const row = (id: string): NativeChatMessage => ({ + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: null, + source: 'transcript' + }) + // A wake turn runs; B is sent during it and folded in, so B opened nothing. + const messages = [row('w1'), userMessage('B'), row('w2')] + const journal = (state: 'running' | 'completed') => + journalOf( + [ + ['wake', record('wake', state, 'claude:wake'), null], + ['w1', said('assistant'), 'wake'], + ['B', said('user'), 'wake'], + ['w2', said('assistant'), 'wake'] + ], + statesScope + ) + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + workingStartedAt: 5_000, + turnJournal: journal('running'), + settledTurns: new Map([['wake', null]]) + }) + ) + }) + // The wake turn settles: the host no longer names a running turn. + vi.setSystemTime(20_000) + act(() => { + renderer!.update( + createElement(Harness, { + messages, + enabled: true, + isWorking: false, + workingStartedAt: null, + turnJournal: journal('completed'), + settledTurns: new Map([['wake', { startedAt: 5_000, workedSeconds: 15 }]]) + }) + ) + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const [rowW1, rowB] = messages.map((message, index) => + disclosure.resolveRow(index, message) ) - }) - // The wake turn settles: the host no longer names a running turn. - vi.setSystemTime(20_000) - act(() => { - renderer!.update( - createElement(Harness, { - messages, - enabled: true, - isWorking: false, - workingStartedAt: null, - activeTurnOpenedBy: null, - turnKeysByItemId: owned, - settledTurns: new Map([['wake', { startedAt: 5_000, workedSeconds: 15 }]]) - }) - ) - }) - const disclosure = renderer!.root.findByType('result').props.disclosure - const [rowW1, rowB] = messages.map((message, index) => disclosure.resolveRow(index, message)) - expect(rowW1.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 15 }) - expect(rowB.turnStatus).toBeNull() - } finally { - vi.useRealTimers() + expect(rowW1.turnStatus).toEqual({ startedAt: 5_000, thinking: false, workedSeconds: 15 }) + expect(rowB.turnStatus).toBeNull() + } finally { + vi.useRealTimers() + } } - }) + ) + + // Claude runs A; B is sent mid-turn and Claude answers it after A. The journal writes B when it + // is sent, so A's remaining tool run and its answer follow B there, and B's turn opens after them. + it.each(hosts)( + "draws A's remaining rows under A's bar, then B's bubble and turn, on a host that %s", + (_host, statesScope) => { + vi.useFakeTimers() + try { + vi.setSystemTime(30_000) + const message = (id: string, role: 'user' | 'assistant'): NativeChatMessage => ({ + id, + role, + blocks: [{ type: 'text', text: id }], + timestamp: null, + source: 'transcript' + }) + const messages = [ + message('A', 'user'), + message('a-tool-1', 'assistant'), + message('B', 'user'), + message('a-tool-2', 'assistant'), + message('FIRST DONE', 'assistant'), + message('b-answer', 'assistant') + ] + const turnJournal = journalOf( + [ + ['A', said('user'), null], + ['tA', record('tA', 'completed', 'A'), null], + ['a-tool-1', said('assistant'), 'tA'], + // Handed over while A runs, so the host scopes it to A's turn until its own opens. + ['B', said('user'), 'tA'], + ['a-tool-2', said('assistant'), 'tA'], + ['FIRST DONE', said('assistant'), 'tA'], + ['tB', record('tB', 'running', 'B'), null], + ['b-answer', said('assistant'), 'tB'] + ], + statesScope + ) + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + workingStartedAt: 29_000, + settledTurns: new Map([['A', { startedAt: 5_000, workedSeconds: 17 }]]), + turnJournal + }) + ) + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const list: NativeChatMessage[] = disclosure.listMessages + const drawn = list.map((entry, index) => { + const row = disclosure.resolveRow(index, entry) + return [entry.id, row.turnStatus?.workedSeconds, row.activeTurnIsWorking] + }) + expect(drawn).toEqual([ + ['A', 17, false], + ['a-tool-1', undefined, false], + ['a-tool-2', undefined, false], + ['FIRST DONE', undefined, false], + ['B', null, true], + ['b-answer', undefined, true] + ]) + } finally { + vi.useRealTimers() + } + } + ) it('keeps at most the latest 128 turns expanded', () => { vi.useFakeTimers() @@ -439,4 +675,47 @@ describe('useMobileNativeChatTurnDisclosure', () => { vi.useRealTimers() } }) + + it('keeps a turn the provider opened live while it runs, and the turn before it settled', () => { + let sequence = 0 + const entry = ( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope = { kind: 'thread' } + ): AgentJournalRenderItem => { + sequence += 1 + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } + } + const said = (itemId: string, turnItemId: string) => + entry( + itemId, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: itemId }] }, + { kind: 'turn', turnItemId } + ) + const items = [ + entry('u1', { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }), + entry('t1', { kind: 'turn', turnId: 't1', state: 'completed', userItemId: 'u1' }), + said('a1', 't1'), + entry('wake', { kind: 'turn', turnId: 'wake', state: 'running', userItemId: 'claude:wake' }), + said('wake-note', 'wake') + ] + const messages: NativeChatMessage[] = ['u1', 'a1', 'wake-note'].map((id, index) => ({ + ...userMessage(id), + role: index === 0 ? 'user' : 'assistant' + })) + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + settledTurns: new Map([['u1', { startedAt: 500, workedSeconds: 4 }]]), + turnJournal: { items, submissions: [] } + }) + ) + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const rows = messages.map((message, index) => disclosure.resolveRow(index, message)) + expect(rows.map((row) => row.activeTurnIsWorking)).toEqual([false, false, true]) + expect(rows[0].turnStatus?.workedSeconds).toBe(4) + }) }) diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts index cc61ba63d89..6e1d4df395e 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -1,10 +1,14 @@ import { useCallback, useMemo, useState } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { NATIVE_CHAT_UNANCHORED_TURN_KEY } from '../../../src/shared/native-chat-turn-status' import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' import { - nativeChatRowTurnKeys, - nativeChatSelfAnchoredTurnRows + nativeChatMessagesWaitingBehindLiveTurn, + nativeChatTurnMembership, + type NativeChatTurnJournal +} from '../../../src/shared/native-chat-turn-membership' +import { + nativeChatRowsInDrawOrder, + nativeChatTurnBarRows } from '../../../src/shared/native-chat-turn-grouping' import { useMobileNativeChatTurnStatus, @@ -12,8 +16,7 @@ import { } from './use-mobile-native-chat-turn-status' const EMPTY_TURN_IDS: ReadonlySet = new Set() -const EMPTY_TURN_KEYS: readonly undefined[] = [] -const EMPTY_TURN_ANCHORS: ReadonlyMap = new Map() +const NO_TURN_KEYS: readonly undefined[] = [] const MAX_EXPANDED_TURNS = 128 export type MobileNativeChatTurnRow = { @@ -35,8 +38,7 @@ export function useMobileNativeChatTurnDisclosure({ isWorking, workingStartedAt, settledTurns, - activeTurnOpenedBy = null, - turnKeysByItemId = null, + turnJournal = null, thinking = false, activityText = null, scopeKey @@ -47,11 +49,8 @@ export function useMobileNativeChatTurnDisclosure({ workingStartedAt?: number | null /** Host-recorded durations; they outrank whatever this client observed. */ settledTurns?: NativeChatSettledTurns | null - /** The key the host says anchors the running turn's bar; absent, the latest user message. */ - activeTurnOpenedBy?: string | null - /** Host-attributed turn ownership per journal item id; unnamed rows keep - * positional preceding-user grouping. */ - turnKeysByItemId?: ReadonlyMap | null + /** The journal that places each row in its turn; absent groups rows by position. */ + turnJournal?: NativeChatTurnJournal | null /** Whether the turn is reasoning right now, derived from its journal content. */ thinking?: boolean /** What the provider says the live turn is doing; outranks the other labels. */ @@ -64,15 +63,43 @@ export function useMobileNativeChatTurnDisclosure({ activeActivityText: string | null onToggleTurn: (turnKey: string) => void resolveRow: (index: number, message: NativeChatMessage) => MobileNativeChatTurnRow + /** The list's rows, in the order they draw, less those waiting behind the live turn. */ + listMessages: readonly NativeChatMessage[] + /** Rows waiting behind the live turn, drawn after its live status. */ + waitingRows: readonly { item: NativeChatMessage; index: number }[] } { + // Resolve each row's turn, which turn is live, and the order the rows draw in, once: from the + // turn record when the host states scopes, else by journal order. + const { rows, turnKeys, liveTurnKey } = useMemo(() => { + if (!enabled) { + return { rows: messages, turnKeys: NO_TURN_KEYS, liveTurnKey: undefined } + } + const membership = nativeChatTurnMembership(messages, turnJournal) + return { + rows: nativeChatRowsInDrawOrder(messages, membership.drawOrder), + turnKeys: nativeChatRowsInDrawOrder(membership.turnKeys, membership.drawOrder), + liveTurnKey: membership.liveTurnKey + } + }, [enabled, messages, turnJournal]) + // A message waiting behind the live turn draws after that turn's live status, not in the list. + const waiting = useMemo(() => { + const ids = enabled ? nativeChatMessagesWaitingBehindLiveTurn(rows, turnJournal?.items) : null + if (!ids?.size) { + return { listMessages: rows, waitingRows: [], indexById: null } + } + return { + listMessages: rows.filter((message) => !ids.has(message.id)), + waitingRows: rows.flatMap((item, index) => (ids.has(item.id) ? [{ item, index }] : [])), + indexById: new Map(rows.map((message, index) => [message.id, index])) + } + }, [enabled, rows, turnJournal]) const turnStatuses = useMobileNativeChatTurnStatus({ - messages, + turnKeys, + liveTurnKey, enabled, isWorking, workingStartedAt, settledTurns, - activeTurnOpenedBy, - turnKeysByItemId, thinking, scopeKey }) @@ -100,59 +127,43 @@ export function useMobileNativeChatTurnDisclosure({ }, [scopeKey] ) - // Resolve each row's owning turn once — a findLast per row is quadratic on a - // long transcript. - const turnKeys = useMemo( - () => (enabled ? nativeChatRowTurnKeys(messages, turnKeysByItemId) : EMPTY_TURN_KEYS), - [enabled, messages, turnKeysByItemId] - ) - // A turn with no user bubble (provider-opened) anchors its bar at its first row. - const selfAnchors = useMemo( - () => (enabled ? nativeChatSelfAnchoredTurnRows(messages, turnKeys) : EMPTY_TURN_ANCHORS), - [enabled, messages, turnKeys] - ) + const bars = useMemo(() => nativeChatTurnBarRows(rows, turnKeys), [rows, turnKeys]) const { active, activeTurnKey, completedByTurn } = turnStatuses const activeActivityText = enabled && isWorking ? (activityText ?? null) : null const resolveRow = useCallback( - (index: number, message: NativeChatMessage): MobileNativeChatTurnRow => { + (listIndex: number, message: NativeChatMessage): MobileNativeChatTurnRow => { + const index = waiting.indexById?.get(message.id) ?? listIndex const turnKey = turnKeys[index] - const anchorsTurnHere = turnKey !== undefined && selfAnchors.get(turnKey) === index - // The live turn's bar carries its running clock; it settles in place. Only - // the bubble that opened a turn carries its bar: a message the provider - // folded into a running turn shares the turn's key but not its bar. - const turnStatus = !enabled - ? null - : message.role === 'user' && message.id === activeTurnKey - ? active - : message.role === 'user' && turnKey === message.id - ? (completedByTurn[turnKey] ?? null) - : anchorsTurnHere - ? turnKey === activeTurnKey - ? (active ?? completedByTurn[turnKey] ?? null) - : (completedByTurn[turnKey] ?? null) - : null + const bar = turnKey === undefined ? undefined : bars.get(turnKey) + // A turn's bar draws at its first row; the live turn's carries its running clock and settles + // in place. A message folded into a turn (a steer) carries none. + const turnStatus = + enabled && turnKey !== undefined && bar?.index === index + ? turnKey === activeTurnKey + ? active + : (completedByTurn[turnKey] ?? null) + : null return { turnStatus, - ...(anchorsTurnHere && turnStatus !== null ? { turnStatusAbove: true } : {}), + ...(bar?.above === true && turnStatus !== null ? { turnStatusAbove: true } : {}), turnExpanded: turnKey ? expandedTurnIds.has(turnKey) : false, // Why: the key travels and the row calls one stable handler with it. A // closure per row would be a new identity every render of a streaming // transcript, defeating the row's memo; caching one per turn would mean // writing a ref during render, which react-freeze can discard. turnKey: turnKey && turnStatus?.workedSeconds != null ? turnKey : undefined, - // Liveness is the owning turn's, not the newest prompt's: a running turn's - // rows stay live while a newer message waits behind it. - activeTurnIsWorking: - enabled && - isWorking && - (turnKey === activeTurnKey || - (turnKey === undefined && activeTurnKey === NATIVE_CHAT_UNANCHORED_TURN_KEY)) + // Liveness is the live turn's rows, not the newest prompt's: a running turn's rows stay live + // while a newer message waits behind it. With no user boundary at all, the session's + // working state stays authoritative. + activeTurnIsWorking: enabled && isWorking && turnKey === liveTurnKey } }, [ turnKeys, - selfAnchors, + waiting, + bars, + liveTurnKey, enabled, activeTurnKey, active, @@ -167,6 +178,8 @@ export function useMobileNativeChatTurnDisclosure({ activeActivityText, /** Stable for a given chat scope, so it never disturbs a row's memo. */ onToggleTurn: toggleExpandedTurn, - resolveRow + resolveRow, + listMessages: waiting.listMessages, + waitingRows: waiting.waitingRows } } diff --git a/mobile/src/session/use-mobile-native-chat-turn-status.ts b/mobile/src/session/use-mobile-native-chat-turn-status.ts index 62f6ada8d5e..f7d440dd738 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-status.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-status.ts @@ -1,9 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { NATIVE_CHAT_UNANCHORED_TURN_KEY, reduceNativeChatTurnTiming, - selectNativeChatActiveTurnKey, selectNativeChatTurnStatuses, type NativeChatSettledTurns, type NativeChatTurnStatus, @@ -22,27 +20,24 @@ type ScopedTurnTiming = { /** Per-turn "Thinking / Working for N / Worked for N" timing, on the same shared * state machine the desktop renderer uses so the two surfaces stamp turns alike. */ export function useMobileNativeChatTurnStatus({ - messages, + turnKeys, + liveTurnKey, enabled, isWorking, workingStartedAt, settledTurns, - activeTurnOpenedBy = null, - turnKeysByItemId = null, thinking = false, scopeKey }: { - messages: readonly NativeChatMessage[] + /** Each row's turn, as `nativeChatTurnMembership` places it. */ + turnKeys: readonly (string | undefined)[] + /** The live turn, whose bar carries the running clock (`nativeChatTurnMembership`). */ + liveTurnKey: string | undefined enabled: boolean isWorking: boolean workingStartedAt?: number | null /** Host-recorded durations; they outrank whatever this client observed. */ settledTurns?: NativeChatSettledTurns | null - /** The user message the host says opened the running turn; absent, the latest one. */ - activeTurnOpenedBy?: string | null - /** Host-attributed turn ownership; a turn keyed to its own record is still in - * the transcript, so its clock is not an echo to hand to the next prompt. */ - turnKeysByItemId?: ReadonlyMap | null /** Whether the turn is reasoning right now, derived from its journal content. */ thinking?: boolean /** Host/worktree/tab identity. Timings never carry across chat surfaces. */ @@ -52,9 +47,7 @@ export function useMobileNativeChatTurnStatus({ completedByTurn: Readonly> activeTurnKey: string } { - const activeTurnKey = enabled - ? selectNativeChatActiveTurnKey(messages, activeTurnOpenedBy) - : NATIVE_CHAT_UNANCHORED_TURN_KEY + const activeTurnKey = (enabled ? liveTurnKey : undefined) ?? NATIVE_CHAT_UNANCHORED_TURN_KEY const [scopedTiming, setScopedTiming] = useState(() => ({ scopeKey, timingByTurn: {} @@ -73,12 +66,7 @@ export function useMobileNativeChatTurnStatus({ if (!enabled) { return } - const validTurnKeys = new Set( - messages.filter((message) => message.role === 'user').map((message) => message.id) - ) - for (const turnKey of turnKeysByItemId?.values() ?? []) { - validTurnKeys.add(turnKey) - } + const validTurnKeys = new Set(turnKeys.filter((turnKey) => turnKey !== undefined)) const previousActiveTurnKey = previousActiveTurn.current?.scopeKey === scopeKey ? previousActiveTurn.current.turnKey @@ -99,7 +87,7 @@ export function useMobileNativeChatTurnStatus({ ? current : { scopeKey, timingByTurn: nextTiming } }) - }, [activeTurnKey, enabled, isWorking, messages, scopeKey, turnKeysByItemId, workingStartedAt]) + }, [activeTurnKey, enabled, isWorking, scopeKey, turnKeys, workingStartedAt]) // Why: the selection rebuilds its status objects on every call, and a streaming // turn re-renders ~20x/s. Without this, every settled turn's row gets fresh diff --git a/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx b/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx index 944b1d9be13..bc14028c872 100644 --- a/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-turn-timing.test.tsx @@ -86,7 +86,8 @@ describe('useMobileStructuredAgentTurnTiming', () => { vi.useFakeTimers() vi.setSystemTime(CLIENT_NOW) const items = [ - user('u1', 1), + // The acknowledged send: its entry's key is the submission's, not the provider's. + user('orca:first', 1), lifecycle( 't1', 2, diff --git a/mobile/src/session/use-mobile-structured-agent-turn-timing.ts b/mobile/src/session/use-mobile-structured-agent-turn-timing.ts index 9181856cc0d..34f0df7edff 100644 --- a/mobile/src/session/use-mobile-structured-agent-turn-timing.ts +++ b/mobile/src/session/use-mobile-structured-agent-turn-timing.ts @@ -4,6 +4,7 @@ import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' +import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' import type { StructuredAgentHostClock } from '../../../src/shared/structured-agent-session-reducer' import { selectStructuredAgentTurnBars } from '../../../src/shared/structured-agent-session-turn-timing' import { @@ -12,10 +13,8 @@ import { } from '../../../src/shared/structured-agent-turn-clock-anchor' /** Host-recorded turn timing for the structured lane: settled durations straight - * off the journal, the transcript key that owns the running turn's bar, each - * row's owning turn, and a - * skew-free start for the live counter whose host-to-local conversion is latched - * once per turn. */ + * off the journal, and a skew-free start for the live counter whose host-to-local + * conversion is latched once per turn. */ export function useMobileStructuredAgentTurnTiming( { items, @@ -29,14 +28,15 @@ export function useMobileStructuredAgentTurnTiming( turnId: string | null ): { settledTurns: NativeChatSettledTurns + /** What places each transcript row in its turn; the same read desktop makes. */ + turnJournal: NativeChatTurnJournal workingStartedAt: number | null - activeTurnOpenedBy: string | null - turnKeysByItemId: ReadonlyMap } { - const { settledTurns, runningTiming, activeTurnOpenedBy, turnKeysByItemId } = useMemo( + const { settledTurns, runningTiming } = useMemo( () => selectStructuredAgentTurnBars(items, submissions, turnId), [items, submissions, turnId] ) + const turnJournal = useMemo(() => ({ items, submissions }), [items, submissions]) const [latch, setLatch] = useState(null) // Stamp during render (React's derive-from-props pattern) so the first paint of // a new turn already counts from the right instant. @@ -50,10 +50,5 @@ export function useMobileStructuredAgentTurnTiming( if (step.latch !== latch) { setLatch(step.latch) } - return { - settledTurns, - workingStartedAt: step.workingStartedAt, - activeTurnOpenedBy, - turnKeysByItemId - } + return { settledTurns, turnJournal, workingStartedAt: step.workingStartedAt } } diff --git a/src/main/claude/claude-background-task-row-journal.test.ts b/src/main/claude/claude-background-task-row-journal.test.ts index ecd23480605..736583d0324 100644 --- a/src/main/claude/claude-background-task-row-journal.test.ts +++ b/src/main/claude/claude-background-task-row-journal.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { StructuredAgentSessionEventSink, @@ -94,12 +95,16 @@ describe('Claude background task row journal', () => { } const resolver = new ClaudeBackgroundTaskIdentityResolver() - expect(writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task)).toEqual({ + expect( + writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task, () => AGENT_JOURNAL_THREAD_SCOPE) + ).toEqual({ accepted: false, reason: 'backpressure' }) expect(task.lastSerialized).toBeNull() - expect(writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task)).toEqual({ + expect( + writeClaudeBackgroundTaskRow(sink, resolver, 'task-1', task, () => AGENT_JOURNAL_THREAD_SCOPE) + ).toEqual({ accepted: true }) expect(task.lastSerialized).not.toBeNull() @@ -130,7 +135,13 @@ describe('Claude background task row journal', () => { } expect( - writeClaudeBackgroundTaskRow(sink, new ClaudeBackgroundTaskIdentityResolver(), 'task-1', task) + writeClaudeBackgroundTaskRow( + sink, + new ClaudeBackgroundTaskIdentityResolver(), + 'task-1', + task, + () => AGENT_JOURNAL_THREAD_SCOPE + ) ).toEqual({ accepted: true }) expect(calls).toEqual([ { @@ -153,7 +164,8 @@ describe('Claude background task row journal', () => { publish: vi.fn(), tryAppendResolvedItemAndPublish: appendAndPublish }, - isForwardedParentTool: () => true + isForwardedParentTool: () => true, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) rows.observe(START_BASH) @@ -175,7 +187,8 @@ describe('Claude background task row journal', () => { publish: vi.fn(), tryAppendResolvedItemAndPublish: appendAndPublish }, - isForwardedParentTool: () => true + isForwardedParentTool: () => true, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) rows.observe({ @@ -203,7 +216,8 @@ describe('Claude background task row journal', () => { publish: vi.fn(), tryAppendResolvedItemAndPublish: appendAndPublish }, - isForwardedParentTool: () => true + isForwardedParentTool: () => true, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) expect(rows.observe(START_BASH)).toBe(true) @@ -227,7 +241,8 @@ describe('Claude background task row journal', () => { tryAppendResolvedItemAndPublish: appendAndPublish }, isForwardedParentTool: () => true, - onPersistenceFailure + onPersistenceFailure, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) rows.observe(START_BASH) @@ -249,7 +264,8 @@ describe('Claude background task row journal', () => { })) }, isForwardedParentTool: () => true, - onPersistenceFailure + onPersistenceFailure, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) for (let index = 0; index < 512; index += 1) { @@ -287,7 +303,13 @@ describe('Claude background task row journal', () => { } expect( - writeClaudeBackgroundTaskRow(sink, new ClaudeBackgroundTaskIdentityResolver(), 'task-1', task) + writeClaudeBackgroundTaskRow( + sink, + new ClaudeBackgroundTaskIdentityResolver(), + 'task-1', + task, + () => AGENT_JOURNAL_THREAD_SCOPE + ) ).toEqual({ accepted: false, reason }) expect(task.lastSerialized).toBeNull() }) diff --git a/src/main/claude/claude-background-task-row-journal.ts b/src/main/claude/claude-background-task-row-journal.ts index a4779898204..956bc770f74 100644 --- a/src/main/claude/claude-background-task-row-journal.ts +++ b/src/main/claude/claude-background-task-row-journal.ts @@ -1,6 +1,7 @@ import type { AgentJournalItemBody, - AgentJournalItemIdentity + AgentJournalItemIdentity, + AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { backgroundTaskFallbackText } from '../../shared/native-chat-background-task-row' import { @@ -141,6 +142,8 @@ export function writeClaudeBackgroundTaskRow( identities: ClaudeBackgroundTaskIdentityResolver, id: string, row: ClaudeBackgroundTaskRow, + /** The turn the row belongs to, read once `beforeAppend` has opened it. */ + turnScope: () => AgentJournalTurnScope, /** Runs before admission to preserve turn-before-row ordering; duplicate * delivery skips it, and a retry reuses the turn the first attempt opened. */ beforeAppend?: () => void, @@ -157,7 +160,11 @@ export function writeClaudeBackgroundTaskRow( // recreated. Keep unresolved writes from distinct provider runs queued side // by using the provider's parent tool identity as the coalescing discriminator. const coalescingKey = JSON.stringify(['claude-background-task', id, row.toolUseId ?? null]) - const appendOptions = { coalescingKey, ...(lifecycle ? { lifecycle: true } : {}) } + const appendOptions = { + coalescingKey, + turnScope: turnScope(), + ...(lifecycle ? { lifecycle: true } : {}) + } const publishOptions = lifecycle ? { lifecycle: true } : {} const resolveIdentity = (journal: StructuredAgentSessionLifecycleJournal) => identities.resolve(journal, id, row.toolUseId) diff --git a/src/main/claude/claude-background-task-row-test-support.ts b/src/main/claude/claude-background-task-row-test-support.ts index 450f4ebe292..af03378331b 100644 --- a/src/main/claude/claude-background-task-row-test-support.ts +++ b/src/main/claude/claude-background-task-row-test-support.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' // Shared fixtures and the sink harness the background-task row suites drive. // // Moved out of claude-background-task-rows.test.ts verbatim when that suite @@ -85,7 +86,8 @@ export function harness( sink, isForwardedParentTool: (toolUseId) => forwardedTools.has(toolUseId), openOutputTurn: () => turnOpens.push(1), - now: () => (clock += 10) + now: () => (clock += 10), + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) const keys = (): string[] => items.map((item) => diff --git a/src/main/claude/claude-background-task-row-writer.ts b/src/main/claude/claude-background-task-row-writer.ts index bb973a1ae70..0415503b9c2 100644 --- a/src/main/claude/claude-background-task-row-writer.ts +++ b/src/main/claude/claude-background-task-row-writer.ts @@ -1,3 +1,4 @@ +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission @@ -19,6 +20,7 @@ export class ClaudeBackgroundTaskRowWriter { constructor( private readonly sink: StructuredAgentSessionEventSink, + private readonly turnScope: () => AgentJournalTurnScope, private readonly onPersistenceFailure?: (error: Error) => void ) {} @@ -33,6 +35,7 @@ export class ClaudeBackgroundTaskRowWriter { this.identities, id, row, + this.turnScope, beforeAppend, lifecycle ) @@ -61,6 +64,7 @@ export class ClaudeBackgroundTaskRowWriter { this.identities, pending.id, pending.row, + this.turnScope, undefined, pending.lifecycle ) diff --git a/src/main/claude/claude-background-task-rows.ts b/src/main/claude/claude-background-task-rows.ts index 64829d0493a..d6f50f3cfc0 100644 --- a/src/main/claude/claude-background-task-rows.ts +++ b/src/main/claude/claude-background-task-rows.ts @@ -1,6 +1,7 @@ // One durable row per Claude background `task_id`, revised in place from the // lifecycle frames so a failed command prints once with the provider sentence. +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { isSettledBackgroundTaskState } from '../../shared/native-chat-background-task-row' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { record, taskAliasId } from './claude-background-task-frames' @@ -46,6 +47,8 @@ export type ClaudeBackgroundTaskRowsDeps = { * output, so writing one must reopen a turn the provider resumed itself — * otherwise the session renders the row while reporting idle. */ openOutputTurn?: (frame: Record, observedAt: number) => void + /** The turn a row written now belongs to: the open one, else the conversation. */ + turnScope: () => AgentJournalTurnScope onPersistenceFailure?: (error: Error) => void now?: () => number } @@ -60,7 +63,11 @@ export class ClaudeBackgroundTaskRows { constructor(private readonly deps: ClaudeBackgroundTaskRowsDeps) { this.now = deps.now ?? (() => Date.now()) - this.writer = new ClaudeBackgroundTaskRowWriter(deps.sink, deps.onPersistenceFailure) + this.writer = new ClaudeBackgroundTaskRowWriter( + deps.sink, + deps.turnScope, + deps.onPersistenceFailure + ) this.overflowTerminalRows = new ClaudeOverflowTerminalRows( this.ledgers, this.now, diff --git a/src/main/claude/claude-captured-compact-frames.test-fixture.ts b/src/main/claude/claude-captured-compact-frames.test-fixture.ts new file mode 100644 index 00000000000..059ed560d6c --- /dev/null +++ b/src/main/claude/claude-captured-compact-frames.test-fixture.ts @@ -0,0 +1,219 @@ +// Claude Code 2.1.283 (SDK 0.3.251) stream-json captures of `/compact`, driven through `query()` with +// streaming input and cut to the protocol fields Orca reads. The session id and the summary text +// are replaced, and every frame carried `session_id: CAPTURED_COMPACT_SESSION_ID`; frame order, +// uuids and the relative clock (`at`, ms) are the captured ones. +// +// Verified from these frames: a finished and a stopped `/compact` both end in a `success` result +// with no `terminal_reason`, naming the command's input in `user_message_uuid`. Only the finished +// one carries a `compact_boundary`; the stopped one answers with a synthetic "Compaction canceled." +// instead. `command_lifecycle` frames bracket every input. + +export type CapturedCompactEvent = + | { at: number; sent: { text: string; uuid: string } } + | { at: number; interrupt: true } + | { at: number; frame: Record } + +export const CAPTURED_COMPACT_SESSION_ID = '00000000-0000-4000-8000-000000000001' + +const lifecycle = (commandUuid: string, state: string) => ({ + type: 'command_lifecycle', + command_uuid: commandUuid, + state +}) + +/** A `/compact` left to finish, from its send to its result. */ +export const CAPTURED_COMPACT_SUCCEEDS: CapturedCompactEvent[] = [ + { at: 1750, sent: { text: '/compact', uuid: '563c9bb2-241a-4946-a35b-0f9088fd05de' } }, + { at: 1751, frame: lifecycle('968b34fc-76cc-41bc-adfc-34f8840637a0', 'completed') }, + { at: 1753, frame: lifecycle('563c9bb2-241a-4946-a35b-0f9088fd05de', 'queued') }, + { at: 1753, frame: lifecycle('563c9bb2-241a-4946-a35b-0f9088fd05de', 'started') }, + { + at: 1757, + frame: { + type: 'system', + subtype: 'status', + status: 'compacting', + uuid: 'de45cbf4-0f29-4ad7-a2a4-f9d9d8847f88' + } + }, + { + at: 6602, + frame: { + type: 'system', + subtype: 'status', + status: null, + compact_result: 'success', + uuid: 'c9cd62b1-02d9-48fc-bb07-1ccb4889c0af' + } + }, + { + at: 6608, + frame: { + type: 'system', + subtype: 'init', + uuid: '9cebf016-94d0-43d5-a23b-acbd606efeae', + model: 'claude-opus-5-5[1m]' + } + }, + { + at: 6609, + frame: { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { + trigger: 'manual', + pre_tokens: 13576, + post_tokens: 1011, + cumulative_dropped_tokens: 12565, + duration_ms: 4711 + }, + uuid: '1ed30bd1-8113-4c3d-b4ef-8020a2f80bfe' + } + }, + { + at: 6609, + frame: { + type: 'user', + parent_tool_use_id: null, + isReplay: false, + isSynthetic: true, + uuid: '4fa68de9-8e28-40d1-a332-da8dba6edd14', + message: { + role: 'user', + content: + 'This session is being continued from a previous conversation that ran out of context. [summary scrubbed]' + } + } + }, + { + at: 6609, + frame: { + type: 'user', + parent_tool_use_id: null, + isReplay: true, + uuid: '302b225c-05fd-45a5-ac2d-0435f9b50845', + message: { role: 'user', content: 'Compacted' } + } + }, + { + at: 6609, + frame: { + type: 'result', + subtype: 'success', + is_error: false, + result: '', + user_message_uuid: '563c9bb2-241a-4946-a35b-0f9088fd05de', + uuid: 'd4c86a55-621b-40ae-9f26-612c842d3ca2' + } + } +] + +/** A `/compact` interrupted 1.5 s in, then the next send and its own answer. */ +export const CAPTURED_COMPACT_STOPPED_THEN_SEND: CapturedCompactEvent[] = [ + { at: 9663, sent: { text: '/compact', uuid: 'a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d' } }, + { at: 9664, frame: lifecycle('0be27e6d-49b7-4ee8-a784-744b3fb3b55c', 'completed') }, + { at: 9664, frame: lifecycle('a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d', 'queued') }, + { at: 9665, frame: lifecycle('a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d', 'started') }, + { + at: 9666, + frame: { + type: 'system', + subtype: 'status', + status: 'compacting', + uuid: 'c85f1131-b4a5-424e-af6a-85e8bd98b2fc' + } + }, + { at: 11166, interrupt: true }, + { + at: 11168, + frame: { + type: 'system', + subtype: 'status', + status: null, + compact_result: 'failed', + compact_error: 'API Error: Request was aborted.', + uuid: '7fede6b2-afdf-4455-8c69-096ae3fa6f55' + } + }, + { + at: 11170, + frame: { + type: 'system', + subtype: 'init', + uuid: '067d1daa-f0da-4ca0-a9de-bedaa72c606a', + model: 'claude-opus-5-5[1m]' + } + }, + { + at: 11170, + frame: { + type: 'assistant', + parent_tool_use_id: null, + uuid: '0d252793-d95f-4c80-b4a1-7309614b7eeb', + message: { + id: '9c14e2f0-6480-4760-92f8-aa73e461bc47', + model: '', + role: 'assistant', + content: [{ type: 'text', text: 'Compaction canceled.' }] + } + } + }, + { + at: 11170, + frame: { + type: 'result', + subtype: 'success', + is_error: false, + result: '', + user_message_uuid: 'a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d', + uuid: '523a8c1d-5054-422e-b81e-f5f350d992fd' + } + }, + { + at: 11170, + sent: { + text: 'Reply with the single word AFTERSTOP.', + uuid: '4178c081-0d9e-414f-a861-ea156efa1e7e' + } + }, + { at: 11170, frame: lifecycle('a4e04ce1-37ad-4d40-aaf8-e03a1bbbbc7d', 'cancelled') }, + { at: 11171, frame: lifecycle('4178c081-0d9e-414f-a861-ea156efa1e7e', 'queued') }, + { at: 11171, frame: lifecycle('4178c081-0d9e-414f-a861-ea156efa1e7e', 'started') }, + { + at: 11200, + frame: { + type: 'system', + subtype: 'init', + uuid: '1eedc4e5-d48c-4682-b593-144466641d13', + model: 'claude-opus-5-5[1m]' + } + }, + { + at: 12778, + frame: { + type: 'assistant', + user_message_uuid: '4178c081-0d9e-414f-a861-ea156efa1e7e', + parent_tool_use_id: null, + uuid: '39969651-5abd-4dba-ae80-ab1299c07a47', + message: { + id: 'msg_011CfUXeXFfvAtj1PLLjB1H4', + model: 'claude-opus-5-5', + role: 'assistant', + content: [{ type: 'text', text: 'AFTERSTOP' }] + } + } + }, + { + at: 12844, + frame: { + type: 'result', + subtype: 'success', + is_error: false, + terminal_reason: 'completed', + result: 'AFTERSTOP', + user_message_uuid: '4178c081-0d9e-414f-a861-ea156efa1e7e', + uuid: 'd9b4faaa-f9be-4d7b-9f81-a5106bd003f3' + } + }, + { at: 12844, frame: lifecycle('4178c081-0d9e-414f-a861-ea156efa1e7e', 'completed') } +] diff --git a/src/main/claude/claude-command-turn.test.ts b/src/main/claude/claude-command-turn.test.ts new file mode 100644 index 00000000000..d777e8a423d --- /dev/null +++ b/src/main/claude/claude-command-turn.test.ts @@ -0,0 +1,367 @@ +// A `/compact` as the Claude translator's open turn, driven by real captured frames: which of them +// end the command, how, and what the timeline draws. + +import { describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionRevisionJournal +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + CAPTURED_COMPACT_SESSION_ID, + CAPTURED_COMPACT_STOPPED_THEN_SEND, + CAPTURED_COMPACT_SUCCEEDS, + type CapturedCompactEvent +} from './claude-captured-compact-frames.test-fixture' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +const COMMAND_IDENTITY: AgentJournalItemIdentity = { + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' +} +const COMMAND_KEY = agentJournalItemKey(COMMAND_IDENTITY) +const IN_COMMAND: AgentJournalTurnScope = { kind: 'turn', turnItemId: COMMAND_KEY } +const RUNNING = { + kind: 'turn' as const, + turnId: 'compact:cmd-1', + state: 'running' as const, + userItemId: 'orca:submission:cmd-1', + requestedAt: 900, + startedAt: 1_000 +} + +type Row = { key: string; body: AgentJournalItemBody; turnScope?: AgentJournalTurnScope } + +/** A sink over a journal that holds the host's running command turn, revising rows in place. */ +function harness() { + const rows = new Map([[COMMAND_KEY, { key: COMMAND_KEY, body: RUNNING }]]) + const writes: Row[] = [] + const write = ( + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + turnScope?: AgentJournalTurnScope + ) => { + const row = { key: agentJournalItemKey(identity), body, ...(turnScope ? { turnScope } : {}) } + rows.set(row.key, row) + writes.push(row) + } + const journal: StructuredAgentSessionRevisionJournal = { + epoch: 'epoch-1', + itemBody: (itemId) => rows.get(itemId)?.body ?? null, + visitItems: (visit) => { + let sequence = 0 + for (const row of rows.values()) { + visit(row.key, sequence++, row.body) + } + } + } + const revise: NonNullable = ( + _bytes, + resolve, + options + ) => { + const resolved = resolve(journal) + if (resolved) { + write(resolved.identity, resolved.body, options.turnScope) + } + return { accepted: true } + } + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body, options) => write(identity, body, options.turnScope), + appendTombstone: vi.fn(), + publish: vi.fn(), + tryReviseResolvedItem: revise, + tryReviseResolvedItemAndPublish: revise + } + const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' }) + const frame = (message: Record, extra: Record = {}) => + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { session_id: CAPTURED_COMPACT_SESSION_ID, ...message }, + observedAt: 5_000, + ...extra + }) + const begin = (sentUuid: string) => + translator.beginCommand({ + clientMessageId: 'cmd-1', + turnId: RUNNING.turnId, + identity: COMMAND_IDENTITY, + resultIdentity: { provider: 'orca', clientMessageId: 'command-result:cmd-1' }, + running: RUNNING, + providerSessionId: CAPTURED_COMPACT_SESSION_ID, + sentUuid + }) + /** Replays a capture as the adapter delivers it: `/compact` goes through `beginCommand`, and a + * Stop marks the interrupt the way the cancel path does. */ + const replay = (events: readonly CapturedCompactEvent[]) => { + for (const event of events) { + if ('sent' in event) { + if (event.sent.text === '/compact') { + begin(event.sent.uuid) + } + } else if ('interrupt' in event) { + translator.commandInterruptRequested(RUNNING.turnId) + } else { + frame(event.frame) + } + } + } + const commandTurn = () => readAgentJournalTurn(rows.get(COMMAND_KEY)?.body) + const drawn = () => + [...rows.values()].filter((row) => row.key !== COMMAND_KEY && row.body.kind !== 'turn') + return { translator, rows, writes, frame, begin, replay, commandTurn, drawn } +} + +describe('a captured /compact as the open Claude turn', () => { + it('ends a finished compaction as a success, drawing only the compaction separator', () => { + const { replay, commandTurn, drawn, translator } = harness() + replay(CAPTURED_COMPACT_SUCCEEDS) + + expect(commandTurn()).toMatchObject({ + turnId: RUNNING.turnId, + state: 'completed', + outcome: 'success', + userItemId: RUNNING.userItemId, + requestedAt: RUNNING.requestedAt, + startedAt: RUNNING.startedAt + }) + // Not the continuation summary, not the command's echo, not a lifecycle opcode row. + expect(drawn()).toEqual([ + { + key: agentJournalItemKey({ provider: 'orca', clientMessageId: 'command-result:cmd-1' }), + body: { kind: 'status', text: 'Context compacted', presentation: 'compaction' }, + turnScope: IN_COMMAND + } + ]) + expect(translator.commandTurnId).toBeNull() + }) + + it("ends a stopped compaction as the user's cancellation, then answers the next send in its own turn", () => { + const { replay, commandTurn, drawn, rows } = harness() + replay(CAPTURED_COMPACT_STOPPED_THEN_SEND) + + // Claude's result for the stopped command is `success` like a finished one's. + expect(commandTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + const answer = drawn().find((row) => row.body.kind === 'message') + expect(answer?.body).toMatchObject({ role: 'assistant' }) + expect(answer?.turnScope).not.toEqual(IN_COMMAND) + const answered = readAgentJournalTurn( + answer?.turnScope?.kind === 'turn' ? rows.get(answer.turnScope.turnItemId)?.body : undefined + ) + expect(answered).toMatchObject({ state: 'completed', outcome: 'success' }) + // "Compaction canceled." is the command's own output, and a stop earns no failure row. + expect(drawn().filter((row) => row.body.kind === 'status')).toEqual([]) + }) + + it('reads a compaction with no boundary and no stop as a failure, with Claude’s reason', () => { + const { replay, commandTurn, drawn } = harness() + // The stopped capture without Orca's stop: Claude reported the compaction failed. + replay(CAPTURED_COMPACT_STOPPED_THEN_SEND.filter((event) => !('interrupt' in event))) + + expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect( + drawn().filter( + (row) => row.turnScope?.kind === 'turn' && row.turnScope.turnItemId === COMMAND_KEY + ) + ).toEqual([ + expect.objectContaining({ + body: { + kind: 'status', + text: 'Compaction failed: API Error: Request was aborted.', + failure: { + kind: 'compactionFailed', + detail: { text: 'API Error: Request was aborted.', audience: 'person' } + }, + tone: 'error' + } + }) + ]) + }) + + it('reads a stop that lands after the summary but before the boundary as a cancellation', () => { + const { replay, commandTurn, drawn } = harness() + // Only the boundary says the conversation was replaced; the status that precedes it does not. + replay( + CAPTURED_COMPACT_SUCCEEDS.flatMap((event): CapturedCompactEvent[] => + 'frame' in event && event.frame.subtype === 'compact_boundary' + ? [] + : 'frame' in event && event.frame.compact_result === 'success' + ? [event, { at: event.at, interrupt: true }] + : [event] + ) + ) + + expect(commandTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(drawn().filter((row) => row.body.kind === 'status')).toEqual([]) + }) + + it('leaves the command running at a result that names another input', () => { + const { begin, frame, commandTurn, translator } = harness() + begin('compact-input') + frame({ type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) + frame({ type: 'result', subtype: 'success', is_error: false, user_message_uuid: 'earlier' }) + expect(commandTurn()?.state).toBe('running') + expect(translator.commandTurnId).toBe(RUNNING.turnId) + + frame({ + type: 'result', + subtype: 'success', + is_error: false, + user_message_uuid: 'compact-input' + }) + expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'success' }) + }) +}) + +describe('the command turn at each point the ordinary result path threads through', () => { + it('suppresses a provider reopen after a command that failed, as after any failed turn', () => { + const failed = harness() + failed.begin('compact-input') + failed.frame({ type: 'result', subtype: 'error_during_execution', is_error: true }) + failed.frame({ + type: 'assistant', + uuid: 'stray', + parent_tool_use_id: null, + message: { id: 'msg-stray', role: 'assistant', content: [{ type: 'text', text: 'hi' }] } + }) + expect(failed.translator.currentTurnId).toBeNull() + + const finished = harness() + finished.begin('compact-input') + finished.frame({ type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) + finished.frame({ type: 'result', subtype: 'success', is_error: false }) + finished.frame({ + type: 'assistant', + uuid: 'resumed', + parent_tool_use_id: null, + message: { id: 'msg-resumed', role: 'assistant', content: [{ type: 'text', text: 'hi' }] } + }) + expect(finished.translator.currentTurnId).not.toBeNull() + }) + + it("settles a child still working in the command's turn when the command ends", () => { + const { begin, frame, rows } = harness() + begin('compact-input') + frame({ + type: 'system', + subtype: 'task_started', + task_id: 'task-1', + task_type: 'local_agent', + description: 'Map the lane' + }) + frame({ type: 'result', subtype: 'success', is_error: false }) + const group = rows.get( + agentJournalItemKey({ + provider: 'orca', + clientMessageId: `claude-subagents:${CAPTURED_COMPACT_SESSION_ID}:${RUNNING.turnId}` + }) + ) + const agents = + group?.body.kind === 'message' + ? group.body.blocks.flatMap((block) => + block.type === 'subagent-group' ? block.agents : [] + ) + : [] + expect(agents).toEqual([expect.objectContaining({ state: 'unverifiable' })]) + }) + + it("records the context window the command's result reports on the command's turn", () => { + const { begin, frame, commandTurn } = harness() + begin('compact-input') + frame({ type: 'system', subtype: 'init', model: 'claude-opus-5-5[1m]', uuid: 'init' }) + frame({ type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) + frame({ + type: 'result', + subtype: 'success', + is_error: false, + modelUsage: { 'claude-opus-5-5[1m]': { contextWindow: 1_000_000 } } + }) + expect(commandTurn()).toMatchObject({ + state: 'completed', + contextUsage: { window: { tokens: 1_000_000 } } + }) + }) + + it("reports a compaction Claude refused in Claude's own words, and one with none plainly", () => { + const worded = harness() + worded.begin('compact-input') + worded.frame({ + type: 'system', + subtype: 'status', + compact_result: 'failed', + compact_error: 'Not enough messages to compact.' + }) + worded.frame({ type: 'result', subtype: 'success', is_error: false }) + expect(worded.commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(worded.drawn().map((row) => row.body)).toEqual([ + { + kind: 'status', + text: 'Compaction failed: Not enough messages to compact.', + failure: { + kind: 'compactionFailed', + detail: { text: 'Not enough messages to compact.', audience: 'person' } + }, + tone: 'error' + } + ]) + + const unworded = harness() + unworded.begin('compact-input') + unworded.frame({ type: 'system', subtype: 'status', compact_result: 'failed' }) + unworded.frame({ type: 'result', subtype: 'success', is_error: false }) + expect(unworded.drawn().map((row) => row.body)).toEqual([ + { + kind: 'status', + text: 'Compaction failed.', + failure: { kind: 'compactionFailed' }, + tone: 'error' + } + ]) + }) + + it('reports a result with no compaction and no failure as a compaction Claude never confirmed', () => { + const { begin, frame, commandTurn, drawn } = harness() + begin('compact-input') + frame({ type: 'result', subtype: 'success', is_error: false }) + expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(drawn().map((row) => row.body)).toEqual([ + { + kind: 'status', + text: 'Compaction completion is unconfirmed.', + failure: { kind: 'compactionUnconfirmed' }, + tone: 'error' + } + ]) + }) + + it('fails a command whose result ended in error', () => { + const { begin, frame, commandTurn } = harness() + begin('compact-input') + frame({ type: 'result', subtype: 'error_during_execution', is_error: true }) + expect(commandTurn()).toMatchObject({ state: 'completed', outcome: 'failure' }) + }) + + it("draws one error row, the provider's, for a command whose result is an error", () => { + const { begin, frame, drawn } = harness() + begin('compact-input') + frame({ + type: 'result', + subtype: 'success', + is_error: true, + result: 'API Error: 529 upstream overloaded' + }) + expect(drawn().filter((row) => row.body.kind === 'status')).toEqual([ + expect.objectContaining({ + body: expect.objectContaining({ text: 'API Error: 529 upstream overloaded' }), + turnScope: IN_COMMAND + }) + ]) + }) +}) diff --git a/src/main/claude/claude-command-turn.ts b/src/main/claude/claude-command-turn.ts new file mode 100644 index 00000000000..be846dc7858 --- /dev/null +++ b/src/main/claude/claude-command-turn.ts @@ -0,0 +1,153 @@ +// A conversation command Claude runs as the session's open turn. The turn is the host's record, and +// the command's own result ends it. Everything Claude writes for the command meanwhile — the +// summary it continues from, the command's echo, a "Compaction canceled." — is the command's +// output, never a reply, so none of it draws. + +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { providerDiagnostic, type ProviderDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' +import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { structuredCompactionOutcome } from '../native-chat/agent-session-wire/structured-conversation-command-outcome' +import { claudeText } from './claude-structured-item-translation' +import { claudeResultFailure } from './claude-structured-provider-fallback' +import type { ClaudeCurrentTurn, ClaudeTurnEnd } from './claude-turn-lifecycle-item' +import { isRootClaudeFrame } from './claude-turn-opening' +import type { ClaudeOpenTurn } from './claude-open-turn' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' + +export type ClaudeCommandTurn = { + identity: AgentJournalItemIdentity + resultIdentity: AgentJournalItemIdentity + /** The command input's uuid, which the result answering it names. */ + sentUuid: string + compacted: boolean + /** Orca asked Claude to stop the command. */ + interruptRequested: boolean + /** Claude reported the compaction failed, with its words for a person when it gave any. */ + failed: { detail?: ProviderDiagnostic } | null +} + +export type ClaudeCommandStart = StructuredAgentSessionCommandRun & { + providerSessionId: string + sentUuid: string +} + +export function claudeCommandCurrentTurn(start: ClaudeCommandStart): ClaudeCurrentTurn { + const { running } = start + return { + sessionId: start.providerSessionId, + turnId: start.turnId, + startedAt: running.startedAt ?? Date.now(), + ...(running.requestedAt === undefined ? {} : { requestedAt: running.requestedAt }), + userItemId: running.userItemId ?? agentJournalItemKey(start.identity), + command: { + identity: start.identity, + resultIdentity: start.resultIdentity, + sentUuid: start.sentUuid, + compacted: false, + interruptRequested: false, + failed: null + } + } +} + +/** Reads a running command's evidence off a frame. True for the command's own output, which + * draws nothing. */ +export function observeClaudeCommandFrame( + command: ClaudeCommandTurn | null, + message: Record +): boolean { + if (!command) { + return false + } + if (message.type === 'system') { + // Only the boundary says the history was replaced; `compact_result: 'success'` precedes it. + if (message.subtype === 'compact_boundary') { + command.compacted = true + } else if (message.compact_result === 'failed') { + const words = claudeText(message.compact_error) + const detail = words === null ? undefined : providerDiagnostic(words, 'person') + command.failed = detail ? { detail } : {} + } + return false + } + return ( + isRootClaudeFrame(message) && + (message.type === 'user' || message.type === 'assistant' || message.type === 'stream_event') + ) +} + +/** The command's end from a root result, and the one row that reports it; null when the result + * names another input. */ +export function claudeCommandEnd( + command: ClaudeCommandTurn, + message: Record, + completedAt: number +): { end: ClaudeTurnEnd; row: AgentJournalItemBody | null } | null { + const answers = claudeText(message.user_message_uuid) + if (answers !== null && answers !== command.sentUuid) { + return null + } + // A stopped `/compact` ends in the same success result as a finished one: only the provider's + // report of the compaction tells them apart. + const shown = claudeResultFailure(message) + const verdict = structuredCompactionOutcome({ + compacted: command.compacted, + interruptRequested: command.interruptRequested, + failed: command.failed ?? (shown ? {} : null) + }) + const durationMs = message.duration_ms + const end: ClaudeTurnEnd = { + state: verdict.outcome === 'cancellation' ? 'interrupted' : 'completed', + completedAt, + outcome: verdict.outcome, + ...(typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0 + ? { durationMs } + : {}) + } + if (verdict.outcome === 'success') { + return { end, row: { kind: 'status', text: 'Context compacted', presentation: 'compaction' } } + } + // An error result already draws its own row through the provider fallback. + return verdict.failure && !shown + ? { + end, + row: { + kind: 'status', + ...agentSessionFailureWords(verdict.failure, { + surface: 'row', + agentName: TUI_AGENT_DISPLAY_NAMES.claude + }), + tone: 'error' + } + } + : { end, row: null } +} + +/** A root result, when a command is the open turn: the command's end, with its one result row + * already written; `another-input` for a result that answers something else; null when no + * command is open. */ +export function claudeCommandResultEnd( + turn: Pick, + sink: Pick, + message: Record, + completedAt: number +): ClaudeTurnEnd | 'another-input' | null { + const { command } = turn + if (!command) { + return null + } + const ended = claudeCommandEnd(command, message, completedAt) + if (!ended) { + return 'another-input' + } + if (ended.row) { + sink.appendItem(command.resultIdentity, ended.row, { turnScope: turn.turnScope }) + } + return ended.end +} diff --git a/src/main/claude/claude-context-usage.ts b/src/main/claude/claude-context-usage.ts index 010620cf09d..43e1ef5c31e 100644 --- a/src/main/claude/claude-context-usage.ts +++ b/src/main/claude/claude-context-usage.ts @@ -10,7 +10,7 @@ import { type AgentSessionTokenUsage } from '../../shared/agent-session-context-usage' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' /** A report later than this describes a context the user has likely moved past. */ export const CLAUDE_CONTEXT_USAGE_TIMEOUT_MS = 5_000 diff --git a/src/main/claude/claude-journal-translator-contract.ts b/src/main/claude/claude-journal-translator-contract.ts index 1ccf24e1393..45feeb84364 100644 --- a/src/main/claude/claude-journal-translator-contract.ts +++ b/src/main/claude/claude-journal-translator-contract.ts @@ -6,6 +6,7 @@ import type { ClaudeChildToolQueries } from './claude-child-tool-queries' import type { ClaudeContextReportPart, ClaudeContextReportTarget } from './claude-context-facts' import type { ClaudeJournalPrompts } from './claude-structured-journal-prompts' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import type { ClaudeCommandStart } from './claude-command-turn' export type ClaudeJournalTranslator = { handle: (event: ClaudeStructuredSessionEvent) => void @@ -13,6 +14,14 @@ export type ClaudeJournalTranslator = { /** The open turn's provider id — the same id its journal row carries, and the one * a client's Stop names. Sole owner: no reader keeps a copy to disagree with. */ readonly currentTurnId: string | null + /** The open turn's id while it is a conversation command's. */ + readonly commandTurnId: string | null + /** Makes the host's command turn the open one until the command's result ends it. */ + beginCommand: (start: ClaudeCommandStart) => void + /** The command was never sent. */ + forgetCommand: (turnId: string) => void + /** Orca asked Claude to stop the command `turnId` names. */ + commandInterruptRequested: (turnId: string) => void /** True while a turn is open and the provider's current request cycle has * done root work since its init — the state in which the CLI folds an * arriving send into the turn. */ diff --git a/src/main/claude/claude-journal-turn-scope.test.ts b/src/main/claude/claude-journal-turn-scope.test.ts new file mode 100644 index 00000000000..fef833ced57 --- /dev/null +++ b/src/main/claude/claude-journal-turn-scope.test.ts @@ -0,0 +1,78 @@ +// Which turn a Claude row belongs to (B8): the root turn open when the row is written, whoever +// produced it, and the conversation once that turn has ended. + +import { expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionItemAppendOptions +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function recorder() { + const writes: { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + options: StructuredAgentSessionItemAppendOptions + }[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body, options) => writes.push({ identity, body, options }), + appendTombstone: vi.fn(), + publish: vi.fn() + } + const scopeOfProse = (text: string) => + writes.findLast( + (write) => + write.body.kind === 'message' && + write.body.blocks.some((block) => block.type === 'text' && block.text === text) + )?.options.turnScope + return { sink, writes, scopeOfProse } +} + +function frame(message: Record, startsTurn = false) { + return { + type: 'message' as const, + sessionId: 'orca-session', + ...(startsTurn ? { startsTurn: true as const } : {}), + message: { session_id: 'claude-session', ...message } + } +} + +function prose(uuid: string, text: string, parentToolUseId: string | null = null) { + return frame({ + type: 'assistant', + uuid, + parent_tool_use_id: parentToolUseId, + message: { role: 'assistant', content: [{ type: 'text', text }] } + }) +} + +it("scopes the session's and a subagent's rows to the open turn, and a late one to none", () => { + const { sink, writes, scopeOfProse } = recorder() + const translator = createClaudeJournalTranslator({ sink, fallbackIdPrefix: 'test' }) + translator.handle( + frame( + { + type: 'user', + uuid: 'user-1', + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'text', text: 'go' }] } + }, + true + ) + ) + translator.handle(prose('assistant-1', 'Working on it')) + translator.handle(prose('child-1', 'Child mid-turn', 'toolu_1')) + translator.handle(frame({ type: 'result', subtype: 'success', uuid: 'result-1', result: 'ok' })) + translator.handle(prose('child-2', 'Child after the turn', 'toolu_1')) + + const turnRecord = writes.find((write) => write.body.kind === 'turn') + expect(turnRecord?.options.turnScope).toEqual({ kind: 'thread' }) + const turnKey = expect.stringContaining('turn-lifecycle') + expect(scopeOfProse('Working on it')).toEqual({ kind: 'turn', turnItemId: turnKey }) + expect(scopeOfProse('Child mid-turn')).toEqual({ kind: 'turn', turnItemId: turnKey }) + expect(scopeOfProse('Child after the turn')).toEqual({ kind: 'thread' }) +}) diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index 8a9dc30cd5a..864624fe59e 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -5,15 +5,21 @@ // keeping a copy, so there is nothing to disagree with. import type { AgentSessionContextUsage } from '../../shared/agent-session-context-usage' -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { - claudeTurnLifecycleIdentity, + claudeCurrentTurnIdentity, claudeTurnLifecycleItem, type ClaudeCurrentTurn, type ClaudeTurnEnd } from './claude-turn-lifecycle-item' import { writeClaudeTurnRow } from './claude-turn-row-revision' +import type { ClaudeCommandTurn } from './claude-command-turn' import { createClaudeTurnOpener, type ClaudeTurnSource } from './claude-turn-opening' export type ClaudeOpenTurnDeps = { @@ -55,9 +61,21 @@ export class ClaudeOpenTurn { /** The open turn's row, where a fact about the running turn lands. */ get identity(): AgentJournalItemIdentity | null { - return this.current - ? claudeTurnLifecycleIdentity(this.current.sessionId, this.current.turnId) - : null + return this.current ? claudeCurrentTurnIdentity(this.current) : null + } + + /** The conversation command the open turn is, if it is one. */ + get command(): ClaudeCommandTurn | null { + return this.current?.command ?? null + } + + /** Which turn a row written now belongs to: the open one, or none. A subagent's rows too — + * its work is its parent turn's. */ + get turnScope(): AgentJournalTurnScope { + const identity = this.identity + return identity + ? { kind: 'turn', turnItemId: agentJournalItemKey(identity) } + : AGENT_JOURNAL_THREAD_SCOPE } get groupKey(): string | null { @@ -99,6 +117,32 @@ export class ClaudeOpenTurn { this.deps.sink.setActivity?.(null) } + /** A conversation command the host opened a turn for. Its row is the host's, already written, + * so only an end is published; the command's result is what ends it. */ + beginCommand(turn: ClaudeCurrentTurn): void { + this.deps.onOpen?.() + if (this.current) { + this.deps.settleChildren(this.groupKey) + this.publish(this.current, { state: 'interrupted', completedAt: turn.startedAt }) + } + this.current = turn + this.deps.sink.setActivity?.(null) + } + + /** Orca asked the provider to stop the command `turnId` names. */ + commandInterruptRequested(turnId: string): void { + if (this.current?.command && this.current.turnId === turnId) { + this.current.command.interruptRequested = true + } + } + + /** The command was never sent; its turn is the host's to settle. */ + forgetCommand(turnId: string): void { + if (this.current?.command && this.current.turnId === turnId) { + this.current = null + } + } + /** The provider produced, so a turn is running. Idempotent: every frame of one * reply stays inside the turn its first frame opened. A subagent's output is * its parent turn's work and never a turn of its own. */ diff --git a/src/main/claude/claude-prompt-journaling.ts b/src/main/claude/claude-prompt-journaling.ts deleted file mode 100644 index 6d3bfe148cf..00000000000 --- a/src/main/claude/claude-prompt-journaling.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Journaling an approval or question prompt, and remembering the rows it wrote -// so a cancellation can tombstone exactly those. - -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' -import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import { - claudeApprovalItem, - claudePromptIdentity, - claudeQuestionItems -} from './claude-structured-prompt-items' - -export type ClaudePromptJournalDeps = { - sink: StructuredAgentSessionEventSink - bindPromptItemId?: (journalItemId: string, promptKey: string) => void - /** Prompt key → the rows it wrote, owned by the translator so a cancel can sweep them. */ - promptItems: Map -} - -export function journalClaudePrompt( - deps: ClaudePromptJournalDeps, - event: Extract -): void { - const identities: AgentJournalItemIdentity[] = [] - if (event.prompt.kind === 'question') { - for (const question of claudeQuestionItems({ - sessionId: event.sessionId, - prompt: event.prompt - })) { - identities.push(question.identity) - deps.sink.appendItem(question.identity, question.body) - deps.bindPromptItemId?.(agentJournalItemKey(question.identity), event.prompt.promptKey) - } - } else { - const identity = claudePromptIdentity({ - sessionId: event.sessionId, - promptKey: event.prompt.promptKey - }) - identities.push(identity) - deps.sink.appendItem(identity, claudeApprovalItem(event.prompt)) - deps.bindPromptItemId?.(agentJournalItemKey(identity), event.prompt.promptKey) - } - deps.promptItems.set(event.prompt.promptKey, identities) - deps.sink.publish() -} diff --git a/src/main/claude/claude-provisional-row-corrections.test.ts b/src/main/claude/claude-provisional-row-corrections.test.ts index d18a71a17fe..cb317c5ca86 100644 --- a/src/main/claude/claude-provisional-row-corrections.test.ts +++ b/src/main/claude/claude-provisional-row-corrections.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import type { AgentJournalItemBody, @@ -38,6 +39,7 @@ function ledger(initial: Record = {}) { : { kind: 'linked', linkage: { agentId: ref, providerParentRef: ref, producerKind: 'agent' } } } const corrections = new ClaudeProvisionalRowCorrections({ + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE, linkageFor: (ref) => verdicts.get(ref) ?? { kind: 'pending' }, settledLinkageFor: (ref) => { const verdict = settledFor(ref) @@ -71,9 +73,11 @@ function ledger(initial: Record = {}) { } describe('ClaudeProvisionalRowCorrections', () => { - it("stamps the session's own rows with nothing and owes them nothing", () => { + it("stamps the session's own rows with no producer and owes them nothing", () => { const { corrections, rewrites } = ledger() - expect(corrections.stampFor(null)(identityOf('toolu_2'), RUNNING)).toEqual({}) + expect(corrections.stampFor(null)(identityOf('toolu_2'), RUNNING)).toEqual({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) expect(corrections.pending).toBe(0) corrections.retry() expect(rewrites).toEqual([]) diff --git a/src/main/claude/claude-provisional-row-corrections.ts b/src/main/claude/claude-provisional-row-corrections.ts index a4b7d039902..92064e23f02 100644 --- a/src/main/claude/claude-provisional-row-corrections.ts +++ b/src/main/claude/claude-provisional-row-corrections.ts @@ -21,9 +21,11 @@ import { agentJournalLinkageFields } from '../../shared/agent-session-journal-pr import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { AgentJournalItemBody, - AgentJournalItemIdentity + AgentJournalItemIdentity, + AgentJournalProducerLinkage, + AgentJournalTurnScope } from '../../shared/agent-session-journal-types' -import type { StructuredAgentSessionAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { StructuredAgentSessionItemAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudeSubagentLinkageSource } from './claude-subagent-linkage' /** @@ -36,12 +38,7 @@ import type { ClaudeSubagentLinkageSource } from './claude-subagent-linkage' export type ClaudeRowStamp = ( identity: AgentJournalItemIdentity, body: AgentJournalItemBody -) => StructuredAgentSessionAppendOptions - -/** The session's own agent wrote this row: nothing is stamped, nothing is owed. - * Only for a site with no ledger to consult; a ledger-backed root write goes - * through `stampFor(null)`, which also supersedes anything owed to the row. */ -export const rootClaudeRowStamp: ClaudeRowStamp = () => ({}) +) => StructuredAgentSessionItemAppendOptions /** Corrections outstanding at once, across every producer. */ const MAX_OUTSTANDING_CORRECTIONS = 256 @@ -57,17 +54,19 @@ type OutstandingCorrection = { * a tool call and its result do — and a correction carrying the older body * would revert the row it is only meant to re-attribute. */ body: AgentJournalItemBody - stamped: StructuredAgentSessionAppendOptions + stamped: StructuredAgentSessionItemAppendOptions } export type ClaudeProvisionalRowCorrectionsDeps = ClaudeSubagentLinkageSource & { + /** The turn a row written now belongs to: the open root turn, whoever produced the row. */ + turnScope: () => AgentJournalTurnScope /** Re-appends a row under its own identity, which revises it in place. * Returns whether the write was ADMITTED: a sink under backpressure refuses, * and a correction dropped on a refusal is an obligation nothing re-derives. */ rewrite: ( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ) => boolean publish: () => void } @@ -92,12 +91,12 @@ export class ClaudeProvisionalRowCorrections { if (parentToolUseId === null) { return (identity) => { this.supersede(identity) - return {} + return { turnScope: this.deps.turnScope() } } } return (identity, body) => { const provisional = this.deps.linkageFor(parentToolUseId).kind === 'pending' - const options = this.stamp(parentToolUseId) + const options = { ...this.stamp(parentToolUseId), turnScope: this.deps.turnScope() } if (provisional) { this.remember(parentToolUseId, identity, body, options) } else { @@ -160,7 +159,7 @@ export class ClaudeProvisionalRowCorrections { this.outstanding.delete(agentJournalItemKey(identity)) } - private stamp(parentToolUseId: string): StructuredAgentSessionAppendOptions { + private stamp(parentToolUseId: string): ReturnType { return agentJournalLinkageFields(this.deps.settledLinkageFor(parentToolUseId).linkage) } @@ -171,14 +170,16 @@ export class ClaudeProvisionalRowCorrections { if (sameLinkage(options, correction.stamped)) { return 'unchanged' } - return this.deps.rewrite(correction.identity, correction.body, options) ? 'wrote' : 'refused' + // A re-stamp revises the row, so its scope stays the one it was created with. + const restamped = { ...options, turnScope: correction.stamped.turnScope } + return this.deps.rewrite(correction.identity, correction.body, restamped) ? 'wrote' : 'refused' } private remember( ref: string, identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - stamped: StructuredAgentSessionAppendOptions + stamped: StructuredAgentSessionItemAppendOptions ): void { if (this.givenUp.has(ref)) { return @@ -233,8 +234,8 @@ export class ClaudeProvisionalRowCorrections { } function sameLinkage( - left: StructuredAgentSessionAppendOptions, - right: StructuredAgentSessionAppendOptions + left: AgentJournalProducerLinkage, + right: AgentJournalProducerLinkage ): boolean { return ( left.agentId === right.agentId && diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts new file mode 100644 index 00000000000..14fd2e94eb6 --- /dev/null +++ b/src/main/claude/claude-result-journaling.ts @@ -0,0 +1,88 @@ +// Journaling ONE Claude `result` frame: the end of the root turn it settles, and the diagnostic +// row a failed or unrecognised result leaves. +// +// Split out of the translator when that file reached its line budget; the translator still owns +// the open turn and every collaborator this writes through. + +import type { ClaudeContextFacts } from './claude-context-facts' +import { claudeCommandResultEnd } from './claude-command-turn' +import type { ClaudeMessageJournalContext } from './claude-message-journaling' +import type { ClaudeJournalPrompts } from './claude-structured-journal-prompts' +import { + claudeProviderFrameKind, + claudeResultFailure, + isSettledClaudeResultKind +} from './claude-structured-provider-fallback' +import { claudeTurnEndForResult } from './claude-turn-lifecycle-item' +import { claudeFrameParentRef, isRootClaudeFrame } from './claude-turn-opening' + +export type ClaudeResultJournalContext = Pick< + ClaudeMessageJournalContext, + | 'sink' + | 'streamedBlocks' + | 'streamedText' + | 'subagents' + | 'providerFallback' + | 'corrections' + | 'turn' +> & { + prompts: ClaudeJournalPrompts + context: ClaudeContextFacts +} + +export function journalClaudeResult( + { + sink, + streamedBlocks, + streamedText, + subagents, + providerFallback, + corrections, + turn, + prompts, + context + }: ClaudeResultJournalContext, + message: Record, + observedAt: number +): void { + // Every turn this translator opens is root by construction, so a nested + // result settles the child that produced it and never the turn. The + // diagnostic below still runs: a child's failure is reportable even when + // it ends no turn. + const settlesTurn = isRootClaudeFrame(message) + const commandEnd = settlesTurn ? claudeCommandResultEnd(turn, sink, message, observedAt) : null + if (commandEnd === 'another-input') { + return + } + // Read before the settle below closes it: the result reports that turn's end. + const endedTurnScope = turn.turnScope + if (settlesTurn) { + prompts.retryPendingCancellations() + turn.suppressReopenOnFailure(message.is_error === true) + // The turn is over however it ended, so a foreground child still + // reported as working will never be settled by an event. + subagents.settleTurn(turn.groupKey) + context.settle(message, commandEnd ?? claudeTurnEndForResult(message, observedAt)) + // The turn is over. A block still awaiting its final keeps the text the + // flush above journaled, but its live state goes: an interrupted turn + // would otherwise retain that text for the life of the session. + streamedBlocks.clear() + streamedText.settle() + } + const kind = claudeProviderFrameKind(message) + const failure = claudeResultFailure(message) + if (failure || !isSettledClaudeResultKind(kind)) { + providerFallback.append( + kind, + message, + failure?.text, + undefined, + undefined, + // A result that settles no turn is a CHILD's result: this + // translator only ever opens root turns. + settlesTurn + ? () => ({ turnScope: endedTurnScope }) + : corrections.stampFor(claudeFrameParentRef(message)) + ) + } +} diff --git a/src/main/claude/claude-structured-command-dispatch.ts b/src/main/claude/claude-structured-command-dispatch.ts new file mode 100644 index 00000000000..3d03c18178c --- /dev/null +++ b/src/main/claude/claude-structured-command-dispatch.ts @@ -0,0 +1,38 @@ +import { randomUUID } from 'node:crypto' +import type { + AgentSessionCommandAdmission, + StructuredAgentSessionCommandRun +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { dispatchClaudeTurn } from './claude-structured-dispatch' +import type { ClaudeSession } from './claude-structured-session-state' + +/** Sent like any message, so the slash-command waiter settles it on its result. The translator + * makes the command's turn the open one before the send, and that same result ends it. */ +export async function dispatchClaudeCommand( + session: ClaudeSession, + command: StructuredAgentSessionCommandRun +): Promise { + const sentUuid = randomUUID() + session.translator?.beginCommand({ + ...command, + providerSessionId: session.providerSessionId, + sentUuid + }) + try { + const admission = await dispatchClaudeTurn(session, { + clientMessageId: command.clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] }, + ...(command.running.requestedAt === undefined + ? {} + : { requestedAt: command.running.requestedAt }), + sentUuid + }) + if (admission.state === 'rejected') { + session.translator?.forgetCommand(command.turnId) + } + return admission + } catch (error) { + session.translator?.forgetCommand(command.turnId) + throw error + } +} diff --git a/src/main/claude/claude-structured-compaction.test.ts b/src/main/claude/claude-structured-compaction.test.ts deleted file mode 100644 index 70aabc5ae50..00000000000 --- a/src/main/claude/claude-structured-compaction.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' -import { claudeUnwrittenUserMessageError } from './claude-agent-sdk-user-message-queue' -import { compactClaudeSession, isClaudeCompactionContent } from './claude-structured-compaction' -import { sessionFor } from './claude-structured-dispatch-test-support' - -afterEach(() => { - vi.useRealTimers() -}) - -describe('Claude compaction transcript content', () => { - it('keeps generated summaries and command echoes out of the transcript only during explicit compaction', async () => { - const tracker = new StructuredSessionCompaction() - const event = { - type: 'message' as const, - sessionId: 'orca-session', - message: { - type: 'user', - session_id: 'provider', - uuid: 'summary', - message: { role: 'user', content: 'generated compaction summary' } - } - } - expect(isClaudeCompactionContent(tracker, event)).toBe(false) - const completion = tracker.run('orca-session', 'provider', async () => undefined) - expect(isClaudeCompactionContent(tracker, event)).toBe(true) - expect(isClaudeCompactionContent(tracker, { ...event, sessionId: 'other' })).toBe(false) - expect(isClaudeCompactionContent(tracker, { ...event, message: { type: 'result' } })).toBe( - false - ) - tracker.ended('orca-session') - await completion - expect(isClaudeCompactionContent(tracker, event)).toBe(false) - }) - - it('fails a provably unwritten command without waiting for the completion deadline', async () => { - vi.useFakeTimers() - const session = sessionFor( - vi.fn().mockRejectedValue(claudeUnwrittenUserMessageError(new Error('input closed'))) - ) - const pending = compactClaudeSession(session, new StructuredSessionCompaction(60_000), { - sessionId: 'orca-session', - fence: 1, - turnId: 'compact-1' - }) - - await vi.advanceTimersByTimeAsync(1) - - await expect(pending).resolves.toEqual({ outcome: 'failed' }) - }) - - it('keeps waiting when the command write outcome is ambiguous', async () => { - vi.useFakeTimers() - const session = sessionFor(vi.fn().mockRejectedValue(new Error('input pump stopped'))) - const pending = compactClaudeSession(session, new StructuredSessionCompaction(10), { - sessionId: 'orca-session', - fence: 1, - turnId: 'compact-1' - }) - const rejection = expect(pending).rejects.toThrow('Compaction completion is unconfirmed.') - - await vi.advanceTimersByTimeAsync(10) - - await rejection - }) -}) diff --git a/src/main/claude/claude-structured-compaction.ts b/src/main/claude/claude-structured-compaction.ts deleted file mode 100644 index 5b1f67e2e11..00000000000 --- a/src/main/claude/claude-structured-compaction.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { ClaudeSession, ClaudeStructuredSessionEvent } from './claude-structured-session-state' -import type { - StructuredSessionCompaction, - StructuredSessionCompactionResult -} from '../native-chat/agent-session-wire/structured-session-compaction' -import { dispatchClaudeTurn } from './claude-structured-dispatch' -import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -/** Compaction needs no ack deadline of its own: `compactions.run` keeps its own - * 180s completion window and settles on Claude's terminal `result` frame, so - * the dispatch here only has to report a refusal to send. */ -export function compactClaudeSession( - session: ClaudeSession, - compactions: StructuredSessionCompaction, - input: Parameters>[0] -): Promise { - return compactions.run( - input.sessionId, - session.providerSessionId, - async () => { - const result = await dispatchClaudeTurn(session, { - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] } - }) - if (result.state === 'rejected') { - return { outcome: 'failed' as const } - } - return undefined - }, - input.onLateResult, - input.turnId - ) -} - -export function observeClaudeCompaction( - compactions: StructuredSessionCompaction, - event: ClaudeStructuredSessionEvent, - translator: ClaudeSession['translator'] | undefined -): void { - if (!isClaudeCompactionContent(compactions, event)) { - translator?.handle(event) - } - if (event.type === 'message') { - compactions.claude(event.sessionId, event.message) - } - if (event.type === 'ended') { - compactions.ended(event.sessionId) - } -} - -export function isClaudeCompactionContent( - compactions: StructuredSessionCompaction, - event: ClaudeStructuredSessionEvent -): boolean { - return ( - event.type === 'message' && - compactions.hasPending(event.sessionId) && - ['user', 'assistant', 'stream_event'].includes(String(event.message.type)) - ) -} diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index 7f197788b68..4ba08d3b642 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -249,6 +249,10 @@ describe('answerClaudePrompt', () => { resolve: resolvePrompt }, currentTurnId: null, + commandTurnId: null, + beginCommand: vi.fn(), + forgetCommand: vi.fn(), + commandInterruptRequested: vi.fn(), flush: vi.fn(), contextActivity: 0, markContextActivity: vi.fn(), diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 612f6391ced..c208cceccdb 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -79,7 +79,13 @@ function claudeWriteFailureRejection(error: unknown): AgentJournalDispatchReject export async function dispatchClaudeTurn( session: ClaudeSession, - input: { clientMessageId?: string; body: AgentJournalMessageItem; requestedAt?: number }, + input: { + clientMessageId?: string + body: AgentJournalMessageItem + requestedAt?: number + /** The frame's uuid, for a caller that correlates the provider's answer to it. */ + sentUuid?: string + }, beforeDispatch?: () => Promise ): Promise { let content: unknown[] @@ -98,7 +104,7 @@ export async function dispatchClaudeTurn( // Read the sent content, not the journal blocks: only the mapped trailing prompt decides // whether Claude runs a command, so the two cannot disagree about which frame settles this. const acceptsResult = claudeDispatchInvokesSlashCommand(content) - const sentUuid = randomUUID() + const sentUuid = input.sentUuid ?? randomUUID() const arm = () => { ++session.dispatchSequence // A context report asked for before this send may land after it and misstate the context. diff --git a/src/main/claude/claude-structured-journal-prompts.ts b/src/main/claude/claude-structured-journal-prompts.ts index 73dd35071ba..92301af106c 100644 --- a/src/main/claude/claude-structured-journal-prompts.ts +++ b/src/main/claude/claude-structured-journal-prompts.ts @@ -1,7 +1,8 @@ import type { AgentJournalApprovalItem, AgentJournalItemIdentity, - AgentJournalQuestionItem + AgentJournalQuestionItem, + AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { cancelledJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' @@ -22,6 +23,7 @@ const ADMITTED = { accepted: true } as const type ClaudeJournalPrompt = { identity: AgentJournalItemIdentity body: AgentJournalApprovalItem | AgentJournalQuestionItem + turnScope: AgentJournalTurnScope } type ClaudeJournalPromptEntry = { @@ -54,6 +56,8 @@ export class ClaudeJournalPrompts { constructor( private readonly deps: { sink: StructuredAgentSessionEventSink + /** The turn that raised the prompt: the open one, else the conversation. */ + turnScope: () => AgentJournalTurnScope bindPromptItemId?: (journalItemId: string, promptKey: string) => void questionItems?: (input: { sessionId: string @@ -75,13 +79,14 @@ export class ClaudeJournalPrompts { */ handle(event: Extract): void { const items: ClaudeJournalPrompt[] = [] + const turnScope = this.deps.turnScope() if (event.prompt.kind === 'question') { for (const question of (this.deps.questionItems ?? claudeQuestionItems)({ sessionId: event.sessionId, prompt: event.prompt })) { - items.push(question) - this.deps.sink.appendItem(question.identity, question.body) + items.push({ ...question, turnScope }) + this.deps.sink.appendItem(question.identity, question.body, { turnScope }) this.deps.bindPromptItemId?.(agentJournalItemKey(question.identity), event.prompt.promptKey) } } else { @@ -90,8 +95,8 @@ export class ClaudeJournalPrompts { promptKey: event.prompt.promptKey }) const body = claudeApprovalItem(event.prompt) - items.push({ identity, body }) - this.deps.sink.appendItem(identity, body) + items.push({ identity, body, turnScope }) + this.deps.sink.appendItem(identity, body, { turnScope }) this.deps.bindPromptItemId?.(agentJournalItemKey(identity), event.prompt.promptKey) } this.deletePrompt(event.prompt.promptKey) @@ -104,10 +109,11 @@ export class ClaudeJournalPrompts { if (items.length === 0) { return ADMITTED } - const mutations = items.map(({ identity, body }) => ({ + const mutations = items.map(({ identity, body, turnScope }) => ({ kind: 'item' as const, identity, - body: cancelledPromptBody(body) + body: cancelledPromptBody(body), + turnScope })) let admission: StructuredAgentSessionSinkAdmission if (this.deps.sink.tryAppendLifecycleBatch) { @@ -129,9 +135,10 @@ export class ClaudeJournalPrompts { return ADMITTED } const body = cancelledPromptBody(item.body) + const options = { lifecycle: true, turnScope: item.turnScope } admission = this.deps.sink.tryAppendItem - ? this.deps.sink.tryAppendItem(item.identity, body, { lifecycle: true }) - : (this.deps.sink.appendItem(item.identity, body, { lifecycle: true }), ADMITTED) + ? this.deps.sink.tryAppendItem(item.identity, body, options) + : (this.deps.sink.appendItem(item.identity, body, options), ADMITTED) } else { return { accepted: false, reason: 'failed' } } diff --git a/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts index 170f520e9ec..7b224e8c51a 100644 --- a/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts +++ b/src/main/claude/claude-structured-journal-translation-background-tasks.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { @@ -201,7 +202,8 @@ describe('claude journal translation — background task rows', () => { deferred.bind(target) deferred.sink.appendItem( { provider: 'orca', clientMessageId: 'blocked-prefill' }, - { kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] } + { kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await appendEntered.promise const notification = systemFrame({ diff --git a/src/main/claude/claude-structured-journal-translation-subagents.test.ts b/src/main/claude/claude-structured-journal-translation-subagents.test.ts index d122d0af7a2..89df86122b8 100644 --- a/src/main/claude/claude-structured-journal-translation-subagents.test.ts +++ b/src/main/claude/claude-structured-journal-translation-subagents.test.ts @@ -529,7 +529,9 @@ describe('claude journal translation — which agent produced a row', () => { (entry) => orcaClientMessageId(entry.identity) === 'claude-tool:claude-session:toolu_bash' ) expect(row?.body).toMatchObject({ kind: 'tool-call', state: 'completed' }) - expect(row?.options).toEqual({}) + expect(row?.options).toEqual({ + turnScope: { kind: 'turn', turnItemId: expect.any(String) } + }) }) /** One streamed text block, as the SDK sends it: a message start, then deltas. diff --git a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts index ad76cd2792b..ee0f4970f4d 100644 --- a/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts +++ b/src/main/claude/claude-structured-journal-translation-turn-timing.test.ts @@ -161,7 +161,7 @@ describe('Claude structured turn timing', () => { state: 'running', startedAt: 1_000, userItemId: USER_1_KEY, - options: { observedAt: 1_000 } + options: { observedAt: 1_000, turnScope: { kind: 'thread' } } } ]) }) @@ -194,7 +194,7 @@ describe('Claude structured turn timing', () => { startedAt: 1_000, completedAt: 4_500, userItemId: USER_1_KEY, - options: {} + options: { turnScope: { kind: 'thread' } } }) expect(state.items.at(-1)?.identity).toEqual(state.items[0]?.identity) }) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 082b0024061..8c8ff1470f4 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -9,9 +9,7 @@ import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { claudeProviderFrameActivity } from '../native-chat/agent-session-wire/provider-frame-activity' import { claudeProviderFrameKind, - claudeResultFailure, - createClaudeProviderFrameFallback, - isSettledClaudeResultKind + createClaudeProviderFrameFallback } from './claude-structured-provider-fallback' import { taskFrameSentence } from './claude-background-task-frames' import { ClaudeBackgroundTaskRows } from './claude-background-task-rows' @@ -26,13 +24,14 @@ import { claudeStreamTurnSource, isRootClaudeFrame } from './claude-turn-opening' -import { claudeTurnEndForResult } from './claude-turn-lifecycle-item' import { ClaudeOpenTurn } from './claude-open-turn' +import { claudeCommandCurrentTurn, observeClaudeCommandFrame } from './claude-command-turn' import { ClaudeContextFacts } from './claude-context-facts' import { claudeSessionStateEndsTurn } from './claude-session-state-turn-over' import { ClaudeJournalPrompts } from './claude-structured-journal-prompts' import { claudeChildToolQueries } from './claude-child-tool-queries' import { journalClaudeMessage, type ClaudeMessageJournalContext } from './claude-message-journaling' +import { journalClaudeResult, type ClaudeResultJournalContext } from './claude-result-journaling' export type { ClaudeJournalTranslator } from './claude-journal-translator-contract' @@ -65,7 +64,9 @@ export function createClaudeJournalTranslator( deps: ClaudeJournalTranslatorDeps ): ClaudeJournalTranslator { const tools = new Map() - const prompts = new ClaudeJournalPrompts(deps) + // Every row joins the root turn open when it is written, whoever produced it. + const turnScope = () => turn.turnScope + const prompts = new ClaudeJournalPrompts({ ...deps, turnScope }) const streamedBlocks = createClaudeStreamedBlockRegistry() const turn = new ClaudeOpenTurn({ sink: deps.sink, @@ -73,14 +74,13 @@ export function createClaudeJournalTranslator( onOpen: () => context.markActivity() }) const context = new ClaudeContextFacts(turn, deps.sink) - const providerFallback = createClaudeProviderFrameFallback( - deps.sink, - deps.fallbackIdPrefix ?? 'acquisition' - ) + const fallbackId = deps.fallbackIdPrefix ?? 'acquisition' + const providerFallback = createClaudeProviderFrameFallback(deps.sink, fallbackId, turnScope) const toolOrigins = new ClaudeToolOriginRegistry() const subagents = new ClaudeSubagentRoster({ sink: deps.sink, currentGroupKey: () => turn.groupKey, + currentTurnScope: turnScope, isForwardedParentTool: (toolUseId) => toolOrigins.has(toolUseId), childOwnerRefOf: (toolUseId) => toolOrigins.childOwnerRef(toolUseId), // A settled group can receive no further announcement, so a correction @@ -90,6 +90,7 @@ export function createClaudeJournalTranslator( const childQueries = claudeChildToolQueries({ tools, toolOrigins, linkage: subagents.linkage }) const corrections = new ClaudeProvisionalRowCorrections({ ...subagents.linkage, + turnScope, rewrite: (identity, body, options) => { // The admission-returning path, so a correction the sink refuses under // backpressure stays owed instead of vanishing. Sinks without it accept @@ -110,6 +111,7 @@ export function createClaudeJournalTranslator( // turn, or the session shows the row while reading idle. openOutputTurn: (frame, observedAt) => turn.ensureOpen(frame, claudeStreamTurnSource(frame), observedAt), + turnScope, ...(deps.onBackgroundTaskJournalFailure ? { onPersistenceFailure: deps.onBackgroundTaskJournalFailure } : {}) @@ -119,7 +121,8 @@ export function createClaudeJournalTranslator( ...(deps.schedule ? { schedule: deps.schedule } : {}), producer: subagents.linkage, persist: (identity, text, options) => { - deps.sink.appendItem(identity, claudeStreamingMessageBody(text), options) + const body = claudeStreamingMessageBody(text) + deps.sink.appendItem(identity, body, { ...options, turnScope: turnScope() }) deps.sink.publish() } }) @@ -161,6 +164,8 @@ export function createClaudeJournalTranslator( turn } + const resultContext: ClaudeResultJournalContext = { ...messageContext, prompts, context } + const handleMessage = ( message: Record, startsTurn: boolean, @@ -200,6 +205,9 @@ export function createClaudeJournalTranslator( } } } + if (event.type === 'message' && observeClaudeCommandFrame(turn.command, event.message)) { + return + } if (event.type === 'message' && handleStream(event.message, event.observedAt ?? Date.now())) { return } @@ -218,41 +226,7 @@ export function createClaudeJournalTranslator( prompts.retryPendingCancellations() prompts.cancel(event.promptKey) } else if (event.type === 'message' && event.message.type === 'result') { - // Every turn this translator opens is root by construction, so a nested - // result settles the child that produced it and never the turn. The - // diagnostic below still runs: a child's failure is reportable even when - // it ends no turn. - const settlesTurn = isRootClaudeFrame(event.message) - if (settlesTurn) { - prompts.retryPendingCancellations() - turn.suppressReopenOnFailure(event.message.is_error === true) - // The turn is over however it ended, so a foreground child still - // reported as working will never be settled by an event. - subagents.settleTurn(turn.groupKey) - context.settle( - event.message, - claudeTurnEndForResult(event.message, event.observedAt ?? Date.now()) - ) - // The turn is over. A block still awaiting its final keeps the text the - // flush above journaled, but its live state goes: an interrupted turn - // would otherwise retain that text for the life of the session. - streamedBlocks.clear() - streamedText.settle() - } - const kind = claudeProviderFrameKind(event.message) - const failure = claudeResultFailure(event.message) - if (failure || !isSettledClaudeResultKind(kind)) { - providerFallback.append( - kind, - event.message, - failure?.text, - undefined, - undefined, - // A result that settles no turn is a CHILD's result: this - // translator only ever opens root turns. - settlesTurn ? undefined : corrections.stampFor(claudeFrameParentRef(event.message)) - ) - } + journalClaudeResult(resultContext, event.message, event.observedAt ?? Date.now()) } else if (event.type === 'message') { const backgroundTaskCovered = backgroundTasks.observe( event.message, @@ -296,6 +270,12 @@ export function createClaudeJournalTranslator( get currentTurnId() { return turn.id }, + get commandTurnId() { + return turn.command ? turn.id : null + }, + beginCommand: (start) => turn.beginCommand(claudeCommandCurrentTurn(start)), + forgetCommand: (turnId) => turn.forgetCommand(turnId), + commandInterruptRequested: (turnId) => turn.commandInterruptRequested(turnId), get openTurnInLiveProviderCycle() { return turn.openedInLiveProviderCycle }, diff --git a/src/main/claude/claude-structured-prompt-ownership.test.ts b/src/main/claude/claude-structured-prompt-ownership.test.ts index 53d6f63623d..31fd703088f 100644 --- a/src/main/claude/claude-structured-prompt-ownership.test.ts +++ b/src/main/claude/claude-structured-prompt-ownership.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' @@ -341,7 +342,10 @@ describe('Claude live prompt ownership', () => { }) it('drops resolved prompt bodies instead of retaining them for the session lifetime', () => { - const prompts = new ClaudeJournalPrompts({ sink: lifecycleRecorder().sink }) + const prompts = new ClaudeJournalPrompts({ + sink: lifecycleRecorder().sink, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE + }) for (let index = 0; index < 128; index += 1) { const promptKey = `resolved-${index}` @@ -673,7 +677,8 @@ describe('Claude live prompt ownership', () => { } ] : [] - } + }, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) const prompt: ClaudePendingPrompt = { requestId: 'grouped-request', @@ -714,7 +719,8 @@ describe('Claude live prompt ownership', () => { lifecycleAttempts += 1 return backpressured ? { accepted: false, reason: 'backpressure' } : { accepted: true } } - } + }, + turnScope: () => AGENT_JOURNAL_THREAD_SCOPE }) const registerCancellation = (index: number): void => { const promptKey = `permission-${index}` diff --git a/src/main/claude/claude-structured-prompt-ownership.ts b/src/main/claude/claude-structured-prompt-ownership.ts index 01af8ec9fc0..6d45e77bbbb 100644 --- a/src/main/claude/claude-structured-prompt-ownership.ts +++ b/src/main/claude/claude-structured-prompt-ownership.ts @@ -3,7 +3,6 @@ import { AgentSessionPromptUnavailableError, type StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { CLAUDE_DEFAULT_REQUEST_TIMEOUT_MS } from './claude-agent-sdk-control-requests' import { answerClaudePrompt, @@ -116,12 +115,11 @@ function cancelClaudeConversation( export async function cancelClaudeStructuredTurn(input: { request: CancelInput sessions: Map - compactions: StructuredSessionCompaction timeoutMs?: number admitPromptCancellation: (session: ClaudeSession, promptKey: string) => boolean onDispatchSettledLate?: ClaudeLateDispatchSettlement }): Promise<{ cancelled: boolean }> { - const { request, sessions, compactions, timeoutMs } = input + const { request, sessions, timeoutMs } = input const session = requireSession(sessions, request.sessionId) const acquisitionGeneration = session.acquisitionGeneration const prompt = request.prompt @@ -172,7 +170,8 @@ export async function cancelClaudeStructuredTurn(input: { const dispatchAdmissionAllowsCancellation = (): boolean => dispatchAdmissionIsCurrent() || (Boolean(prompt) && supportsClaudeQueuedInterruptCancellation(session)) - const compactionOwnsTurn = (): boolean => compactions.ownsTurn(request.sessionId, requestedTurnId) + const compactionOwnsTurn = (): boolean => + session.translator !== null && session.translator.commandTurnId === requestedTurnId const currentDispatchHasRetiredWaiter = (): boolean => session.retiredDispatchWaiters.some( (waiter) => waiter.dispatchSequence === session.dispatchSequence @@ -204,7 +203,14 @@ export async function cancelClaudeStructuredTurn(input: { const result = await cancelClaudeTurn( session, timeoutMs, - isCurrent, + () => { + const current = isCurrent() + // Read with the result that ends it: a stopped command reports no compaction. + if (current && compactionOwnsTurn()) { + session.translator?.commandInterruptRequested(requestedTurnId) + } + return current + }, input.onDispatchSettledLate ) if (result.cancelled && claim && cancellationObserved) { diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index e107633173c..cd5441d7ec1 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -1,3 +1,4 @@ +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { boundInlineText, @@ -15,7 +16,7 @@ import { type ClaudeMessageEnvelope } from './claude-structured-item-translation' import { claudeResultOutcome } from './claude-result-outcome' -import { rootClaudeRowStamp, type ClaudeRowStamp } from './claude-provisional-row-corrections' +import type { ClaudeRowStamp } from './claude-provisional-row-corrections' import { CLAUDE_API_RETRY_FRAME_KIND, claudeApiRetryRowBody, @@ -109,7 +110,9 @@ export function isModeledClaudeContent(value: unknown): boolean { export function createClaudeProviderFrameFallback( sink: StructuredAgentSessionEventSink, - acquisitionId: string + acquisitionId: string, + /** The frame's turn — the open one once `beforeAppend` ran — or the conversation. */ + turnScope: () => AgentJournalTurnScope ): { /** `displayText` leads the row when Claude knows the sentence the frame itself does not name. */ append: ( @@ -139,7 +142,7 @@ export function createClaudeProviderFrameFallback( clientMessageId: `provider-retry:claude:${acquisitionId}:${retryRun(retrying)}` } as const const body = claudeApiRetryRowBody(retrying) - sink.appendItem(identity, body, (stamp ?? rootClaudeRowStamp)(identity, body)) + sink.appendItem(identity, body, stamp?.(identity, body) ?? { turnScope: turnScope() }) sink.publish() return true } @@ -162,7 +165,7 @@ export function createClaudeProviderFrameFallback( clientMessageId: `provider-frame:claude:${acquisitionId}:${sequence}` } as const const body = bounded ? { ...translated.body, text: bounded } : translated.body - sink.appendItem(identity, body, (stamp ?? rootClaudeRowStamp)(identity, body)) + sink.appendItem(identity, body, stamp?.(identity, body) ?? { turnScope: turnScope() }) sink.publish() return true } diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 29b51e730ae..64d3c67c227 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -1,5 +1,5 @@ import type { SubmissionRejectionFact } from '../../shared/agent-session-failure' -import { compactClaudeSession, observeClaudeCompaction } from './claude-structured-compaction' +import { dispatchClaudeCommand } from './claude-structured-command-dispatch' import type { AgentSessionAcquisition, StructuredAgentSessionAcquireInput, @@ -7,7 +7,6 @@ import type { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' -import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' import { acquireClaudeSession } from './claude-structured-session-acquisition' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' @@ -55,7 +54,6 @@ function backgroundTaskState(session: ClaudeSession): AgentSessionBackgroundTask } export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAdapter { - private readonly compactions = new StructuredSessionCompaction() private readonly sessions = new Map() private readonly acquisitions = new ClaudeAcquisitionRegistry() private readonly exits = new Map() @@ -156,7 +154,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda if (event.type === 'message' && session?.commands.observe(event.message)) { session.events?.publish() } - observeClaudeCompaction(this.compactions, event, session?.translator) + session?.translator?.handle(event) this.deps.onEvent?.(event) if (backgroundTasksChanged) { this.deps.onBackgroundTasksChanged?.( @@ -193,13 +191,12 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda dispatchClaudeTurn(this.session(input.sessionId), input, input.beforeDispatch) compact: NonNullable = (input) => - compactClaudeSession(this.session(input.sessionId), this.compactions, input) + dispatchClaudeCommand(this.session(input.sessionId), input.command) cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = (request) => cancelClaudeStructuredTurn({ request, sessions: this.sessions, - compactions: this.compactions, admitPromptCancellation: (session, promptKey) => admitClaudePromptCancellation(session, promptKey), onDispatchSettledLate: (settlement) => diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index e27b2a41e93..f49b6a8d88f 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -12,7 +12,7 @@ import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { closeProcessRegistry } from '../../shared/child-process/close-process-registry' diff --git a/src/main/claude/claude-structured-session-journal-control.ts b/src/main/claude/claude-structured-session-journal-control.ts index 9a4d3611435..9e3ee0289cf 100644 --- a/src/main/claude/claude-structured-session-journal-control.ts +++ b/src/main/claude/claude-structured-session-journal-control.ts @@ -4,7 +4,7 @@ import { } from './claude-context-usage' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudeStreamJsonConnection } from './claude-stream-json-connection' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudeInitProof } from './claude-structured-session-startup' import type { ClaudeAcquisitionAttempt, diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index f20bc0c7907..c685c2cd2ae 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -1,7 +1,7 @@ import type { AgentSessionAcquisition } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { claudeProviderHandleLink } from './claude-structured-owner-identity' import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudeSession } from './claude-structured-session-state' import { ClaudeBackgroundTaskTracker } from './claude-background-task-tracker' import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' diff --git a/src/main/claude/claude-structured-session-reading-control.test.ts b/src/main/claude/claude-structured-session-reading-control.test.ts index 1084c2c8626..7677462303e 100644 --- a/src/main/claude/claude-structured-session-reading-control.test.ts +++ b/src/main/claude/claude-structured-session-reading-control.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { createDeferredStructuredAgentSessionEventSink, @@ -236,7 +237,8 @@ describe('Claude structured reading control', () => { const resumeReading = vi.spyOn(claude.connections[0], 'resumeReading') deferred.sink.appendItem( { provider: 'orca', clientMessageId: 'blocked-prefill' }, - { kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] } + { kind: 'message', role: 'system', blocks: [{ type: 'text', text: 'prefill' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await appendEntered.promise const notification = { diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 6848d9a1943..82332f72383 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -11,7 +11,7 @@ import type { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution' -import type { ClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeJournalTranslator } from './claude-journal-translator-contract' import type { ClaudePendingPrompt, ClaudePromptRegistry } from './claude-structured-prompt-replies' import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition' import { randomUUID } from 'node:crypto' diff --git a/src/main/claude/claude-subagent-group-row.ts b/src/main/claude/claude-subagent-group-row.ts index ab44350606e..8be89d15e19 100644 --- a/src/main/claude/claude-subagent-group-row.ts +++ b/src/main/claude/claude-subagent-group-row.ts @@ -46,7 +46,7 @@ export function writeClaudeSubagentGroupRow( group: RosterGroup ): void { const agents = [...group.entries.values()].map((tracked) => tracked.entry) - const options = { coalescingKey: `claude-subagents:${group.groupId}` } + const options = { coalescingKey: `claude-subagents:${group.groupId}`, turnScope: group.turnScope } if (agents.length === 0) { // The row's last child turned out not to be a subagent. An empty roster is // not a roster of nothing, so the row goes rather than reading "Ran 0". diff --git a/src/main/claude/claude-subagent-roster-state.ts b/src/main/claude/claude-subagent-roster-state.ts index f7fc3374661..dcaa72c3000 100644 --- a/src/main/claude/claude-subagent-roster-state.ts +++ b/src/main/claude/claude-subagent-roster-state.ts @@ -1,4 +1,7 @@ -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' import type { ClaudeSubagentTaskFrame } from './claude-subagent-task-frames' @@ -22,6 +25,8 @@ export type TrackedEntry = { export type RosterGroup = { groupId: string identity: AgentJournalItemIdentity + /** The spawning turn's scope: the row reports its children beside that turn's work. */ + turnScope: AgentJournalTurnScope /** Insertion order is the display order; the map holds the state. */ entries: Map /** Lifetime admissions bound retained labels even when entries are removed. */ diff --git a/src/main/claude/claude-subagent-roster.test.ts b/src/main/claude/claude-subagent-roster.test.ts index da1f16d0a0e..0d5747d2f4f 100644 --- a/src/main/claude/claude-subagent-roster.test.ts +++ b/src/main/claude/claude-subagent-roster.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody, @@ -43,6 +44,7 @@ function harness(groupKey: string | null = TURN_1) { const roster = new ClaudeSubagentRoster({ sink, currentGroupKey: () => key, + currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE, now: () => (clock += 1) }) const roles = (): NativeChatSubagentEntry[] => agentsOf(items.at(-1)?.body) @@ -464,7 +466,11 @@ describe('ClaudeSubagentRoster — through the real sink queue', () => { published += 1 } }) - const roster = new ClaudeSubagentRoster({ sink: deferred.sink, currentGroupKey: () => TURN_1 }) + const roster = new ClaudeSubagentRoster({ + sink: deferred.sink, + currentGroupKey: () => TURN_1, + currentTurnScope: () => AGENT_JOURNAL_THREAD_SCOPE + }) // The first append is in flight while the rest are submitted, so a publish // sharing the row's coalescing key would evict them. diff --git a/src/main/claude/claude-subagent-roster.ts b/src/main/claude/claude-subagent-roster.ts index d45a17e15e9..be6274ce054 100644 --- a/src/main/claude/claude-subagent-roster.ts +++ b/src/main/claude/claude-subagent-roster.ts @@ -11,6 +11,7 @@ // child on every resume. Outcomes latch within an invocation; a new spawn // alias can reopen it, and authoritative evidence can correct lost contact. +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { canReplaceSubagentState, isTerminalSubagentState @@ -46,6 +47,8 @@ export type ClaudeSubagentRosterDeps = { sink: StructuredAgentSessionEventSink /** The turn that owns children spawned right now; null outside any turn. */ currentGroupKey: () => string | null + /** That turn's scope, which the roster row it spawns belongs to. */ + currentTurnScope: () => AgentJournalTurnScope /** Whether a tool id was forwarded at the TOP level. A child parented to one * was spawned by a call the transcript shows, so its announcement is still * expected; a child parented to anything else names an id that only ever @@ -364,6 +367,7 @@ export class ClaudeSubagentRoster { const group: RosterGroup = { groupId, identity: claudeSubagentGroupIdentity(groupId), + turnScope: this.deps.currentTurnScope(), entries: new Map(), admittedEntries: 0, claimedLabels: new Set(), diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index 2a31b49afdb..be7150531fe 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -7,6 +7,7 @@ import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' import type { StructuredAgentSessionAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { claudeResultOutcome } from './claude-result-outcome' +import type { ClaudeCommandTurn } from './claude-command-turn' export type ClaudeCurrentTurn = { sessionId: string @@ -18,6 +19,13 @@ export type ClaudeCurrentTurn = { /** Provider key of the user echo, or the lifecycle row itself when provider * output opened a turn with no user row to receive its timing. */ userItemId: string + /** Present when the turn is the host's record of a conversation command. */ + command?: ClaudeCommandTurn +} + +/** The row a turn's lifecycle lives on: the host's record for a command, else the lane's own. */ +export function claudeCurrentTurnIdentity(turn: ClaudeCurrentTurn): AgentJournalItemIdentity { + return turn.command?.identity ?? claudeTurnLifecycleIdentity(turn.sessionId, turn.turnId) } export type ClaudeTurnEnd = { @@ -84,7 +92,7 @@ export function claudeTurnLifecycleItem( // already carried, because both are built from the same open turn. const requested = requestedAt === undefined ? {} : { requestedAt } return { - identity: claudeTurnLifecycleIdentity(sessionId, turnId), + identity: claudeCurrentTurnIdentity(turn), body: agentJournalTurnBody( end ? { diff --git a/src/main/claude/claude-turn-ownership.test.ts b/src/main/claude/claude-turn-ownership.test.ts index 81a84989aa4..c1915d9bf94 100644 --- a/src/main/claude/claude-turn-ownership.test.ts +++ b/src/main/claude/claude-turn-ownership.test.ts @@ -6,7 +6,6 @@ import type { AgentJournalItemBody } from '../../shared/agent-session-journal-ty import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { CLAUDE_DISPATCH_ADMISSION_TIMEOUT_MS, cancelClaudeStructuredTurn @@ -99,6 +98,10 @@ function sessionHoldingTurn(turnId: string | null): ReturnType true }) } @@ -281,6 +283,10 @@ describe('Claude turn ownership', () => { openTurnInLiveProviderCycle: false, journalPrompts: { cancel: vi.fn(), resolve: vi.fn() }, currentTurnId: 'turn-1', + commandTurnId: null, + beginCommand: vi.fn(), + forgetCommand: vi.fn(), + commandInterruptRequested: vi.fn(), flush: vi.fn(), contextActivity: 0, markContextActivity: vi.fn(), @@ -308,7 +314,6 @@ describe('Claude turn ownership', () => { const cancellation = cancelClaudeStructuredTurn({ request: { sessionId: 'session-1', turnId: 'turn-1', fence: 1 }, sessions: new Map([['session-1', session]]), - compactions: new StructuredSessionCompaction(), admitPromptCancellation: () => true }) await vi.advanceTimersByTimeAsync(100) diff --git a/src/main/claude/claude-turn-row-revision.ts b/src/main/claude/claude-turn-row-revision.ts index 1bee6bd0162..1b684ba89aa 100644 --- a/src/main/claude/claude-turn-row-revision.ts +++ b/src/main/claude/claude-turn-row-revision.ts @@ -14,9 +14,10 @@ import { agentJournalItemKey, parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' -import type { - AgentJournalItemIdentity, - AgentJournalTurnItem +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalTurnItem } from '../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { estimateStructuredAgentSessionItemBytes } from '../native-chat/agent-session-wire/structured-agent-session-event-sink-estimate' @@ -149,7 +150,7 @@ function findTurnRow( export type ClaudeTurnRowDelivery = { /** False only for a writer that publishes each write itself right after queueing it. */ publish: boolean - options?: StructuredAgentSessionRevisionOptions + options?: Omit } /** @@ -161,8 +162,10 @@ export function writeClaudeTurnRow( sink: StructuredAgentSessionEventSink, target: ClaudeTurnRowTarget, write: ClaudeTurnRowWrite, - { publish, options = {} }: ClaudeTurnRowDelivery + { publish, options: delivery = {} }: ClaudeTurnRowDelivery ): void { + // A turn record belongs to no turn. + const options = { ...delivery, turnScope: AGENT_JOURNAL_THREAD_SCOPE } const revise = publish ? sink.tryReviseResolvedItemAndPublish : sink.tryReviseResolvedItem if (!revise) { if (write.lifecycle && 'identity' in target) { diff --git a/src/main/codex/codex-command-turn-claim.test.ts b/src/main/codex/codex-command-turn-claim.test.ts new file mode 100644 index 00000000000..078060497f2 --- /dev/null +++ b/src/main/codex/codex-command-turn-claim.test.ts @@ -0,0 +1,375 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionRevisionJournal, + StructuredAgentSessionSinkAdmission +} 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 = 'session-1' +const THREAD = 'thread-1' +const PROVIDER_TURN = 'compact-turn' +const COMMAND_TURN_IDENTITY: AgentJournalItemIdentity = { + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' +} +const COMMAND_TURN_KEY = agentJournalItemKey(COMMAND_TURN_IDENTITY) +const RUNNING = { + kind: 'turn' as const, + turnId: 'compact:cmd-1', + state: 'running' as const, + userItemId: 'orca:submission:cmd-1', + requestedAt: 1, + startedAt: 1 +} +const COMMAND = { + clientMessageId: 'cmd-1', + turnId: RUNNING.turnId, + identity: COMMAND_TURN_IDENTITY, + resultIdentity: { provider: 'orca' as const, clientMessageId: 'command-result:cmd-1' }, + running: RUNNING +} +const ACCEPTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +type Written = { key: string; body: AgentJournalItemBody; turnScope?: AgentJournalTurnScope } + +/** Records every write with the scope it states; the journal holds the running command turn. */ +function recorder(refuseRevisions = 0) { + const writes: Written[] = [] + const record = ( + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + turnScope?: AgentJournalTurnScope + ) => writes.push({ key: agentJournalItemKey(identity), body, turnScope }) + const commandTurn: AgentJournalItemBody = RUNNING + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the claim resolver reads only `itemBody`. + const journal = { + itemBody: (itemId: string) => (itemId === COMMAND_TURN_KEY ? commandTurn : null) + } as unknown as StructuredAgentSessionRevisionJournal + let refusals = refuseRevisions + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body, options) => record(identity, body, options.turnScope), + appendTombstone: () => {}, + publish: () => {}, + tryPublish: () => ACCEPTED, + tryAppendItem: (identity, body, options) => { + record(identity, body, options.turnScope) + return ACCEPTED + }, + appendLifecycleBatch: (_settlementId, mutations) => { + for (const mutation of mutations) { + if (mutation.kind === 'item') { + record(mutation.identity, mutation.body, mutation.turnScope) + } + } + }, + tryReviseResolvedItemAndPublish: (_bytes, resolve, options) => { + if (refusals > 0) { + refusals -= 1 + return { accepted: false, reason: 'backpressure' } + } + const resolved = resolve(journal) + if (resolved) { + record(resolved.identity, resolved.body, options.turnScope) + } + return ACCEPTED + } + } + return { sink, writes } +} + +/** Codex names the thread inside every notification's params too. */ +function notification( + method: string, + params: Record +): CodexStructuredSessionEvent { + return { + type: 'notification', + sessionId: SESSION, + threadId: THREAD, + method, + params: { threadId: THREAD, ...params } + } +} + +function harness(refuseRevisions = 0) { + const tap = recorder(refuseRevisions) + const translator = createCodexJournalTranslator({ + sink: tap.sink, + sessionId: SESSION, + primaryThreadId: () => THREAD + }) + return { + ...tap, + translator, + emit: (event: CodexStructuredSessionEvent) => translator.handle(event) + } +} + +const codexTurnRecords = (writes: readonly Written[]) => + writes.filter((write) => write.key !== COMMAND_TURN_KEY && readAgentJournalTurn(write.body)) + +/** The command turn's newest body, as the journal would hold it. */ +const commandTurn = (writes: readonly Written[]) => + readAgentJournalTurn(writes.findLast((write) => write.key === COMMAND_TURN_KEY)?.body) + +const resultRows = (writes: readonly Written[]) => + writes.filter((write) => write.body.kind === 'status') + +describe('a Codex turn a conversation command claims', () => { + it('is the command turn: writes no root turn, scopes its content there, and ends it', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + // The claim is persisted on the command turn: nothing else re-derives it later. + expect(commandTurn(writes)).toMatchObject({ state: 'running', providerTurnId: PROVIDER_TURN }) + emit( + notification('item/completed', { + turnId: PROVIDER_TURN, + item: { type: 'agentMessage', id: 'summary', text: 'Summary of the conversation.' } + }) + ) + emit( + notification('item/completed', { + turnId: PROVIDER_TURN, + item: { type: 'contextCompaction', id: 'compaction' } + }) + ) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'completed' } })) + + expect(codexTurnRecords(writes)).toEqual([]) + const scope = { kind: 'turn', turnItemId: COMMAND_TURN_KEY } + const summary = writes.find( + (write) => write.body.kind === 'message' && write.body.role === 'assistant' + ) + expect(summary?.turnScope).toEqual(scope) + // Codex's own marker is the command's result row. + expect(resultRows(writes)).toEqual([ + expect.objectContaining({ + body: { kind: 'status', text: 'Context compacted', presentation: 'compaction' }, + turnScope: scope + }) + ]) + expect(commandTurn(writes)).toMatchObject({ + state: 'completed', + outcome: 'success', + providerTurnId: PROVIDER_TURN, + userItemId: RUNNING.userItemId, + requestedAt: RUNNING.requestedAt + }) + }) + + it('reads an interrupted turn as the command cancelled, with no result row', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'interrupted' } })) + + expect(commandTurn(writes)).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(resultRows(writes)).toEqual([]) + }) + + it('reads a turn that completed without compacting as a failure, with the reason', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit( + notification('turn/completed', { + turn: { id: PROVIDER_TURN, status: 'failed', error: { message: 'Unavailable' } } + }) + ) + + expect(commandTurn(writes)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(resultRows(writes).map((write) => write.body)).toEqual([ + { + kind: 'status', + text: 'Compaction failed: Unavailable.', + failure: { + kind: 'compactionFailed', + detail: { text: 'Unavailable', audience: 'person' } + }, + tone: 'error' + } + ]) + }) + + it('says only that the compaction failed when a turn that did not complete gave no words', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'failed' } })) + + expect(commandTurn(writes)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(resultRows(writes).map((write) => write.body)).toEqual([ + { + kind: 'status', + text: 'Compaction failed.', + failure: { kind: 'compactionFailed' }, + tone: 'error' + } + ]) + }) + + it('reports a turn that completed without Codex reporting a compaction as unconfirmed', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'completed' } })) + + expect(commandTurn(writes)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect(resultRows(writes).map((write) => write.body)).toEqual([ + { + kind: 'status', + text: 'Compaction completion is unconfirmed.', + failure: { kind: 'compactionUnconfirmed' }, + tone: 'error' + } + ]) + }) + + it('ends a compaction Codex failed with an error once, however late its completion', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit( + notification('error', { + turnId: PROVIDER_TURN, + willRetry: false, + error: { message: 'Unavailable' } + }) + ) + // The error is a row; only the completion ends the turn. + expect(commandTurn(writes)).toMatchObject({ state: 'running' }) + // Codex still completes the turn it failed, as status failed. + emit( + notification('turn/completed', { + turn: { id: PROVIDER_TURN, status: 'failed', error: { message: 'Unavailable' } } + }) + ) + + expect(codexTurnRecords(writes)).toEqual([]) + expect(commandTurn(writes)).toMatchObject({ state: 'completed', outcome: 'failure' }) + expect( + writes.filter( + (write) => write.key === COMMAND_TURN_KEY && readAgentJournalTurn(write.body)?.completedAt + ) + ).toHaveLength(1) + // Codex's own error row, in the command's turn; the completion adds none. + expect(resultRows(writes)).toEqual([ + expect.objectContaining({ + body: expect.objectContaining({ kind: 'status', text: 'Unavailable', tone: 'error' }), + turnScope: { kind: 'turn', turnItemId: COMMAND_TURN_KEY } + }) + ]) + }) + + it("ends a captured failed compaction on its completion, below one row of Codex's own", () => { + // A real app-server's compaction turn: two retried stream errors, the turn-ending error, then + // the failed completion. + const captured: { case: string; t: number; method: string; params: object }[] = readFileSync( + join(__dirname, '__fixtures__', 'codex-app-server-turn-endings.jsonl'), + 'utf8' + ) + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)) + .filter((frame) => frame.case === '0.158.0-compact-overloaded') + const compactionTurn = captured.findLastIndex((frame) => frame.method === 'turn/started') + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + for (const frame of captured.slice(compactionTurn)) { + emit({ + type: 'notification', + sessionId: SESSION, + threadId: THREAD, + method: frame.method, + params: { ...frame.params, threadId: THREAD }, + observedAt: frame.t + }) + } + + expect(codexTurnRecords(writes)).toEqual([]) + const completion = captured.at(-1)! + expect(completion.method).toBe('turn/completed') + expect(commandTurn(writes)).toMatchObject({ + state: 'completed', + outcome: 'failure', + completedAt: completion.t + }) + // Every row is one of Codex's frames, inside the command's turn; the completion adds none. + const scope = { kind: 'turn', turnItemId: COMMAND_TURN_KEY } + const rows = resultRows(writes) + expect(rows.map((write) => write.key)).not.toContain( + agentJournalItemKey(COMMAND.resultIdentity) + ) + expect(rows.map((write) => write.turnScope)).toEqual(rows.map(() => scope)) + expect( + rows.some( + (write) => + write.body.kind === 'status' && + write.body.tone === 'error' && + write.body.text.includes('Selected model is at capacity') + ) + ).toBe(true) + }) + + it('leaves the next turn to write its own record after a failed compaction', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + emit( + notification('error', { turnId: PROVIDER_TURN, willRetry: false, error: { message: 'x' } }) + ) + emit(notification('turn/completed', { turn: { id: PROVIDER_TURN, status: 'failed' } })) + emit(notification('turn/started', { turn: { id: 'ordinary' } })) + emit(notification('turn/completed', { turn: { id: 'ordinary', status: 'completed' } })) + + expect( + codexTurnRecords(writes).map((write) => readAgentJournalTurn(write.body)?.turnId) + ).toEqual(['ordinary', 'ordinary']) + }) + + it('names no provider turn for a Stop until Codex opens one, then the one it opened', () => { + const { translator, emit } = harness() + translator.beginCommand(COMMAND) + expect(translator.commandProviderTurnId(COMMAND.turnId)).toBeUndefined() + emit(notification('turn/started', { turn: { id: PROVIDER_TURN } })) + expect(translator.commandProviderTurnId(COMMAND.turnId)).toBe(PROVIDER_TURN) + expect(translator.commandProviderTurnId('ordinary')).toBe('ordinary') + }) + + it('claims the same provider turn when the refused start is retried', () => { + const { writes, translator, emit } = harness(1) + translator.beginCommand(COMMAND) + const started = notification('turn/started', { turn: { id: PROVIDER_TURN } }) + + expect(emit(started)).toEqual({ accepted: false, reason: 'backpressure' }) + expect(emit(started)).toEqual(ACCEPTED) + + expect(codexTurnRecords(writes)).toEqual([]) + expect(commandTurn(writes)).toMatchObject({ providerTurnId: PROVIDER_TURN }) + expect(translator.commandProviderTurnId(COMMAND.turnId)).toBe(PROVIDER_TURN) + }) + + it('leaves a primary turn no command claims to write its own record', () => { + const { writes, translator, emit } = harness() + translator.beginCommand(COMMAND) + translator.forgetCommand(COMMAND.turnId) + emit(notification('turn/started', { turn: { id: 'ordinary' } })) + emit(notification('turn/completed', { turn: { id: 'ordinary', status: 'completed' } })) + expect( + codexTurnRecords(writes).map((write) => readAgentJournalTurn(write.body)?.state) + ).toEqual(['running', 'completed']) + }) +}) diff --git a/src/main/codex/codex-command-turn-claim.ts b/src/main/codex/codex-command-turn-claim.ts new file mode 100644 index 00000000000..e1278a8b5c4 --- /dev/null +++ b/src/main/codex/codex-command-turn-claim.ts @@ -0,0 +1,32 @@ +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionSinkAdmission +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' + +const CLAIM_BYTES = 4_096 + +/** Records on the command's turn which provider turn carried it out. Persisted because nothing + * else re-derives it once the command settles, and a rewind has to place that turn's rows. */ +export function recordCodexCommandTurnClaim( + sink: StructuredAgentSessionEventSink, + commandTurnItemId: string, + providerTurnId: string +): StructuredAgentSessionSinkAdmission { + const identity = parseAgentJournalItemKey(commandTurnItemId) + if (!identity || !sink.tryReviseResolvedItemAndPublish) { + return { accepted: true } + } + return sink.tryReviseResolvedItemAndPublish( + CLAIM_BYTES, + (journal) => { + const body = journal.itemBody(commandTurnItemId) + // Settled already — by the host, or by a child's death — leaves nothing to annotate. + return body?.kind === 'turn' && body.state === 'running' + ? { identity, body: { ...body, providerTurnId } } + : null + }, + { lifecycle: true, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} diff --git a/src/main/codex/codex-journal-command-turn.ts b/src/main/codex/codex-journal-command-turn.ts new file mode 100644 index 00000000000..90e2f556b84 --- /dev/null +++ b/src/main/codex/codex-journal-command-turn.ts @@ -0,0 +1,165 @@ +// The conversation command a Codex child is running. Codex carries a command out as a turn of its +// own; that turn writes no record, its rows join the command's turn, and its end is the command's. +// Held by the child's translator, so it ends with the child and nothing has to release it. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' +import { providerDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' +import type { JournalLifecycleMutationInput } from '../native-chat/agent-session-journal/journal-row-builders' +import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { structuredCompactionOutcome } from '../native-chat/agent-session-wire/structured-conversation-command-outcome' +import { readCodexJournalRecord } from './codex-structured-journal-translation-values' +import { readCodexTurnId } from './codex-structured-thread-facts' + +type CarriedCommand = { + command: StructuredAgentSessionCommandRun + compacted: boolean + /** Codex's own error row already says why the command failed. */ + failureShown: boolean +} + +export class CodexJournalCommandTurn { + /** Sent, and not yet carried by a provider turn. */ + private awaiting: StructuredAgentSessionCommandRun | null = null + private readonly carried = new Map() + + /** Before Orca sends the command: the next primary turn to start carries it out. */ + begin(command: StructuredAgentSessionCommandRun): void { + this.awaiting = command + } + + /** The command was never taken. */ + forget(turnId: string): void { + if (this.awaiting?.turnId === turnId) { + this.awaiting = null + } + } + + /** The command the primary turn `providerTurnId` carries out. Idempotent per provider turn, so a + * refused frame's retry gets the same answer. */ + claim(providerTurnId: string): StructuredAgentSessionCommandRun | null { + const carried = this.carried.get(providerTurnId) + if (carried || !this.awaiting) { + return carried?.command ?? null + } + const command = this.awaiting + this.awaiting = null + this.carried.set(providerTurnId, { command, compacted: false, failureShown: false }) + return command + } + + isCarrying(providerTurnId: string): boolean { + return this.carried.has(providerTurnId) + } + + scopeFor(providerTurnId: string): AgentJournalTurnScope | null { + const carried = this.carried.get(providerTurnId) + return carried + ? { kind: 'turn', turnItemId: agentJournalItemKey(carried.command.identity) } + : null + } + + /** The provider turn a Stop on `turnId` interrupts: none while the command has not started one. */ + providerTurnId(turnId: string): string | undefined { + if (this.awaiting?.turnId === turnId) { + return undefined + } + for (const [providerTurnId, { command }] of this.carried) { + if (command.turnId === turnId) { + return providerTurnId + } + } + return turnId + } + + /** Codex reported the compaction, in the turn carrying the command. */ + compacted(providerTurnId: string): void { + const carried = this.carried.get(providerTurnId) + if (carried) { + carried.compacted = true + } + } + + /** The command's end, for the batch that settles the provider turn carrying it. */ + end( + providerTurnId: string, + ended: { + status: string | null + error: string | null + completedAt: number + } + ): JournalLifecycleMutationInput[] { + const carried = this.carried.get(providerTurnId) + if (!carried) { + return [] + } + const { command } = carried + const detail = ended.error === null ? undefined : providerDiagnostic(ended.error, 'person') + const verdict = structuredCompactionOutcome({ + compacted: carried.compacted, + // Codex reports the user's stop as the turn's own status. + interruptRequested: ended.status === 'interrupted', + // A turn that did not complete failed, not merely went unconfirmed. + failed: ended.status !== 'completed' || detail ? (detail ? { detail } : {}) : null + }) + const turnScope = { kind: 'turn' as const, turnItemId: agentJournalItemKey(command.identity) } + return [ + // A success already drew Codex's own compaction marker inside the command's turn. + ...(verdict.failure && !carried.failureShown + ? [ + { + kind: 'item' as const, + identity: command.resultIdentity, + body: { + kind: 'status' as const, + ...agentSessionFailureWords(verdict.failure, { + surface: 'row', + agentName: TUI_AGENT_DISPLAY_NAMES.codex + }), + tone: 'error' as const + }, + turnScope + } + ] + : []), + { + kind: 'item', + identity: command.identity, + body: agentJournalTurnBody({ + ...command.running, + providerTurnId, + state: verdict.outcome === 'cancellation' ? 'interrupted' : 'completed', + outcome: verdict.outcome, + completedAt: ended.completedAt + }), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + } + + /** Codex's `error` frame, already a row in the turn it names. A turn-ending one says why the + * command failed, so the failed completion that follows it adds no second row. */ + errorShown(params: unknown): void { + const providerTurnId = readCodexTurnId(params) + const carried = providerTurnId ? this.carried.get(providerTurnId) : undefined + // A stream error Codex is about to retry ends nothing. + if (carried && readCodexJournalRecord(params).willRetry !== true) { + carried.failureShown = true + } + } + + settled(providerTurnId: string): void { + this.carried.delete(providerTurnId) + } + + clear(): void { + this.awaiting = null + this.carried.clear() + } +} diff --git a/src/main/codex/codex-journal-turn-scopes.ts b/src/main/codex/codex-journal-turn-scopes.ts new file mode 100644 index 00000000000..eb49f243bf4 --- /dev/null +++ b/src/main/codex/codex-journal-turn-scopes.ts @@ -0,0 +1,45 @@ +// Which turn a Codex row belongs to, stated when the row is written. +// +// A primary-thread row belongs to the turn it names: that turn's lifecycle record, or the +// conversation command that claimed it. A child thread has turns of its own that the timeline +// does not draw, so its rows belong to the primary turn running when they arrive — the work the +// user is watching — or to no turn once the primary is idle. + +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns' + +export class CodexJournalTurnScopes { + constructor( + private readonly deps: { + /** Without it no turn record is keyed, so every row reads as the thread's. */ + sessionId: string | undefined + primaryThreadId: () => string | null + activeTurn: (threadId: string) => string | null + /** The command turn's scope, for a primary turn carrying a conversation command. */ + commandScope: (turnId: string) => AgentJournalTurnScope | null + } + ) {} + + scopeFor(threadId: string, turnId: string | null): AgentJournalTurnScope { + const primary = this.deps.primaryThreadId() + const primaryTurnId = + primary === null ? null : threadId === primary ? turnId : this.deps.activeTurn(primary) + if (primaryTurnId === null) { + return AGENT_JOURNAL_THREAD_SCOPE + } + const { sessionId } = this.deps + return ( + this.deps.commandScope(primaryTurnId) ?? + (sessionId === undefined + ? AGENT_JOURNAL_THREAD_SCOPE + : { + kind: 'turn', + turnItemId: agentJournalItemKey(codexTurnLifecycleIdentity(sessionId, primaryTurnId)) + }) + ) + } +} diff --git a/src/main/codex/codex-persistent-command-retention.test.ts b/src/main/codex/codex-persistent-command-retention.test.ts index 2c123a3b910..fa6aef48ae9 100644 --- a/src/main/codex/codex-persistent-command-retention.test.ts +++ b/src/main/codex/codex-persistent-command-retention.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' @@ -45,7 +46,7 @@ function fixture(maxMetadataBytes?: number) { { sink, maxMetadataBytes, - linkageFor: () => ({}), + attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }), schedule: (run) => { scheduled.add(run) return () => { @@ -119,7 +120,7 @@ describe('persistent command retention', () => { sink, streams: items.streams, activeItems: items.activeItems, - linkageFor: () => ({}) + attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }) ).toEqual({ accepted: true }) } @@ -215,7 +216,7 @@ describe('persistent command retention', () => { sink, streams: items.streams, activeItems: items.activeItems, - linkageFor: () => ({}) + attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }) ).toEqual({ accepted: true }) expect(items.activeItems.size).toBe(1) diff --git a/src/main/codex/codex-structured-compaction-refusal.test.ts b/src/main/codex/codex-structured-compaction-refusal.test.ts index 437f32f0a8f..fa8800f9605 100644 --- a/src/main/codex/codex-structured-compaction-refusal.test.ts +++ b/src/main/codex/codex-structured-compaction-refusal.test.ts @@ -1,6 +1,7 @@ // A compaction Codex refuses up front keeps Codex's own words for the chat's failure row. import { describe, expect, it } from 'vitest' +import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' import { CodexAppServerRequestError } from './codex-app-server-connection' import { acquired, fakeCodex } from './codex-structured-session-adapter-fixture' @@ -17,12 +18,25 @@ describe('Codex compaction refused at the request', () => { } }) const adapter = await acquired(codex) + const turn = structuredAgentSessionCommandTurn('cmd-1') await expect( - adapter.compact({ turnId: 'compact-1', sessionId: 'session-1', fence: 7 }) + adapter.compact({ + sessionId: 'session-1', + fence: 7, + command: { + clientMessageId: 'cmd-1', + ...turn, + running: { kind: 'turn', turnId: turn.turnId, state: 'running' } + } + }) ).resolves.toEqual({ - outcome: 'failed', - detail: { text: 'thread has nothing to compact', audience: 'person' } + state: 'rejected', + reason: 'The provider did not accept this message: thread has nothing to compact.', + rejection: { + kind: 'providerRejected', + detail: { text: 'thread has nothing to compact', audience: 'person' } + } }) }) }) diff --git a/src/main/codex/codex-structured-conversation-stop.test.ts b/src/main/codex/codex-structured-conversation-stop.test.ts index 1842afada72..5d8a9c5627f 100644 --- a/src/main/codex/codex-structured-conversation-stop.test.ts +++ b/src/main/codex/codex-structured-conversation-stop.test.ts @@ -2,6 +2,7 @@ // `turn/start` before it opens the turn, and refuses an interrupt until then. import { describe, expect, it, vi } from 'vitest' +import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' import { CODEX_TEST_THREAD_ID, codexTurnLifecycleRig @@ -71,16 +72,21 @@ describe('a Codex Stop that names no turn', () => { const rig = await codexTurnLifecycleRig() await openedTurn(rig) rig.turns.end('completed') + const turn = structuredAgentSessionCommandTurn('operation-1') const compaction = rig.adapter.compact({ - turnId: 'compact:operation-1', sessionId: 'session-1', - fence: 7 + fence: 7, + command: { + clientMessageId: 'operation-1', + ...turn, + running: { kind: 'turn', turnId: turn.turnId, state: 'running' } + } }) await vi.waitFor(() => expect(rig.codex.connections[0]!.calls.at(-1)?.method).toBe('thread/compact/start') ) - await expect(stop(rig, () => 'compact:operation-1')).resolves.toEqual({ cancelled: false }) + await expect(stop(rig, () => turn.turnId)).resolves.toEqual({ cancelled: false }) expect(rig.interrupts()).toEqual([]) rig.notify('turn/started', { threadId: CODEX_TEST_THREAD_ID, turn: { id: 'turn-compact' } }) @@ -89,6 +95,6 @@ describe('a Codex Stop that names no turn', () => { threadId: CODEX_TEST_THREAD_ID, turn: { id: 'turn-compact', status: 'completed' } }) - await expect(compaction).resolves.toEqual({ outcome: 'compacted' }) + await expect(compaction).resolves.toEqual({ state: 'accepted', providerIdentity: null }) }) }) diff --git a/src/main/codex/codex-structured-item-stream-contracts.ts b/src/main/codex/codex-structured-item-stream-contracts.ts index ebbd05731d4..733a81455c3 100644 --- a/src/main/codex/codex-structured-item-stream-contracts.ts +++ b/src/main/codex/codex-structured-item-stream-contracts.ts @@ -2,7 +2,7 @@ import type { AgentJournalItemIdentity } from '../../shared/agent-session-journa import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { codexJournalItem, CodexThreadItem } from './codex-structured-item-translation' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' export type CodexItemStreamDeps = { sink: StructuredAgentSessionEventSink @@ -13,7 +13,7 @@ export type CodexItemStreamDeps = { turnId: string | null, item: CodexThreadItem ) => AgentJournalItemIdentity - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution coalesceMs?: number maxRetainedBytes?: number maxTotalRetainedBytes?: number diff --git a/src/main/codex/codex-structured-item-streams.ts b/src/main/codex/codex-structured-item-streams.ts index 03085bf6913..306fcf27619 100644 --- a/src/main/codex/codex-structured-item-streams.ts +++ b/src/main/codex/codex-structured-item-streams.ts @@ -1,4 +1,8 @@ import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRowAttribution +} from '../../shared/agent-session-journal-types' import { createAgentSessionDeltaCoalescer } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import { CodexItemStreamRetention } from './codex-item-stream-retention' import { appendCodexItemAndPublish } from './codex-structured-journal-sink' @@ -51,12 +55,14 @@ export function createCodexStructuredItemStreams( const states = new CodexItemStreamRetention(deps.maxMetadataBytes) const checkpointLengths = new Map() const pendingCheckpoints = new Set() - // Which thread and turn produced each stream, resolved to linkage per append + // Which thread and turn produced each stream, resolved to its attribution per append // so a parent learned after the first checkpoint still reaches the row. const producers = new Map() - const linkageOf = (key: string) => { + const attributionOf = (key: string): AgentJournalRowAttribution => { const producer = producers.get(key) - return producer ? deps.linkageFor(producer.threadId, producer.turnId) : {} + return producer + ? deps.attributionFor(producer.threadId, producer.turnId) + : { turnScope: AGENT_JOURNAL_THREAD_SCOPE } } // Patch updates are authoritative item snapshots. Keep the latest rejected // snapshot until the journal admits it; unlike streamed deltas, there is no @@ -114,7 +120,7 @@ export function createCodexStructuredItemStreams( } return appendCodexItemAndPublish(deps.sink, state.identity, translated.body, { coalescingKey: `checkpoint:${agentJournalItemKey(state.identity)}`, - ...linkageOf(key) + ...attributionOf(key) }).accepted } @@ -191,7 +197,7 @@ export function createCodexStructuredItemStreams( deps.sink, pending.identity, pending.body, - linkageOf(key) + attributionOf(key) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-compactions.test.ts b/src/main/codex/codex-structured-journal-compactions.test.ts index b86cea2725d..4da87a0c75f 100644 --- a/src/main/codex/codex-structured-journal-compactions.test.ts +++ b/src/main/codex/codex-structured-journal-compactions.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' @@ -82,7 +83,7 @@ describe('compaction provider generation compatibility', () => { if (stage === 'append' && reject) { return { accepted: false, reason: 'backpressure' } } - sink.appendItem(identity, body) + sink.appendItem(identity, body, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) return { accepted: true } } sink.tryPublish = () => { diff --git a/src/main/codex/codex-structured-journal-compactions.ts b/src/main/codex/codex-structured-journal-compactions.ts index 9aa2e8f4cf1..61743c1996c 100644 --- a/src/main/codex/codex-structured-journal-compactions.ts +++ b/src/main/codex/codex-structured-journal-compactions.ts @@ -1,5 +1,4 @@ import { createHash } from 'node:crypto' -import { isCodexCompactionComplete } from '../native-chat/agent-session-wire/structured-session-compaction' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CODEX_JOURNAL_ADMITTED, @@ -8,7 +7,16 @@ import { import { MAX_CODEX_GENERIC_TURN_BUCKETS } from './codex-structured-journal-limits' import { appendCodexLifecycleItem, publishCodexLifecycle } from './codex-structured-journal-sink' import { readCodexTurnId } from './codex-structured-thread-facts' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import { readRecord } from './codex-item-field-readers' +import type { CodexRowAttribution } from './codex-subagent-linkage' + +function isCodexCompactionComplete(method: string, params: unknown): boolean { + return ( + method === 'thread/compacted' || + (method === 'item/completed' && + readRecord(readRecord(params).item).type === 'contextCompaction') + ) +} export class CodexJournalCompactions { private readonly turns = new Map() @@ -16,7 +24,9 @@ export class CodexJournalCompactions { constructor( private readonly sink: StructuredAgentSessionEventSink, private readonly activeTurn: (threadId: string) => string | null, - private readonly linkageFor: CodexRowLinkage + private readonly attributionFor: CodexRowAttribution, + /** Evidence for a conversation command the turn carries; its marker is the command's result. */ + private readonly compacted: (turnId: string) => void ) {} handle(event: { @@ -31,6 +41,7 @@ export class CodexJournalCompactions { if (!turnId) { return null } + this.compacted(turnId) // Collapse compactions within a thread/turn; the canonical item replaces its legacy fallback. const key = createHash('sha256') .update(JSON.stringify([event.threadId, turnId])) @@ -44,7 +55,7 @@ export class CodexJournalCompactions { this.sink, { provider: 'orca', clientMessageId: `codex-compaction:${key}` }, { kind: 'status', text: 'Context compacted', presentation: 'compaction' }, - this.linkageFor(event.threadId, turnId) + this.attributionFor(event.threadId, turnId) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-contracts.ts b/src/main/codex/codex-structured-journal-contracts.ts index 28b5583a9b4..74cf5013aff 100644 --- a/src/main/codex/codex-structured-journal-contracts.ts +++ b/src/main/codex/codex-structured-journal-contracts.ts @@ -4,6 +4,7 @@ import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-sessio import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import type { CodexSubagentExecutions } from './codex-subagent-executions' +import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' export type CodexJournalTranslatorDeps = { sink: StructuredAgentSessionEventSink @@ -34,6 +35,13 @@ export type CodexJournalTranslatorDeps = { export type CodexJournalTranslator = { handle: (event: CodexStructuredSessionEvent) => CodexJournalTranslationAdmission + /** Before Orca sends a conversation command: the next primary turn carries it out, and that + * turn's end is the command's. */ + beginCommand: (command: StructuredAgentSessionCommandRun) => void + /** The command was never taken. */ + forgetCommand: (turnId: string) => void + /** The provider turn a Stop naming `turnId` interrupts; undefined while a command has none. */ + commandProviderTurnId: (turnId: string) => string | undefined cancelPrompt: (journalItemId: string) => CodexJournalTranslationAdmission restoreThread: ( threadId: string, diff --git a/src/main/codex/codex-structured-journal-generic-frames.ts b/src/main/codex/codex-structured-journal-generic-frames.ts index 7d9f54462fc..a8b400a6d93 100644 --- a/src/main/codex/codex-structured-journal-generic-frames.ts +++ b/src/main/codex/codex-structured-journal-generic-frames.ts @@ -12,7 +12,8 @@ import { MAX_CODEX_GENERIC_TURN_BUCKETS } from './codex-structured-journal-limits' import { readCodexTurnId } from './codex-structured-thread-facts' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' +import type { CodexRowAttribution } from './codex-subagent-linkage' const OVERFLOW_BUCKET = '__codex-generic-overflow__' /** `producer` is absent only on the overflow bucket, which pools every thread's @@ -59,7 +60,7 @@ export class CodexJournalGenericFrames { constructor( private readonly deps: Pick & { - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution }, private readonly activeTurn: (threadId: string) => string | null ) { @@ -102,10 +103,10 @@ export class CodexJournalGenericFrames { provider: 'orca' as const, clientMessageId: `provider-frame:codex:${this.fallbackSequence}` } - const linkage = this.deps.linkageFor(threadId, frameTurnId) + const attribution = this.deps.attributionFor(threadId, frameTurnId) const admission = this.deps.sink.tryAppendItem - ? this.deps.sink.tryAppendItem(identity, translated.body, linkage) - : (this.deps.sink.appendItem(identity, translated.body, linkage), CODEX_JOURNAL_ADMITTED) + ? this.deps.sink.tryAppendItem(identity, translated.body, attribution) + : (this.deps.sink.appendItem(identity, translated.body, attribution), CODEX_JOURNAL_ADMITTED) if (!admission.accepted) { this.fallbackSequence -= 1 return admission @@ -139,11 +140,12 @@ export class CodexJournalGenericFrames { provider: 'orca' as const, clientMessageId: `provider-frame-suppressed:codex:${bucket}` } + // A summary across evicted turns names no producer and belongs to no turn. const options = { coalescingKey: `provider-frame-suppressed:codex:${bucket}`, ...(summary.producer - ? this.deps.linkageFor(summary.producer.threadId, summary.producer.turnId) - : {}) + ? this.deps.attributionFor(summary.producer.threadId, summary.producer.turnId) + : { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) } const admission = this.deps.sink.tryAppendItem ? this.deps.sink.tryAppendItem(identity, { kind: 'status', text }, options) diff --git a/src/main/codex/codex-structured-journal-goal-admission.test.ts b/src/main/codex/codex-structured-journal-goal-admission.test.ts index b0a9e50ba2c..f2a3a9b166c 100644 --- a/src/main/codex/codex-structured-journal-goal-admission.test.ts +++ b/src/main/codex/codex-structured-journal-goal-admission.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { @@ -137,7 +138,7 @@ describe('codex goal lifecycle admission', () => { appendTombstone: () => {}, publish: () => {} } satisfies StructuredAgentSessionEventSink - const goals = new CodexJournalGoals(sink, () => ({})) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) const update = (goal: Record = {}) => goals.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame(goal) }) const clear = () => @@ -178,7 +179,7 @@ describe('codex goal lifecycle admission', () => { appendTombstone: () => {}, publish: () => {} } satisfies StructuredAgentSessionEventSink - const goals = new CodexJournalGoals(sink, () => ({})) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) const send = (threadId: string) => goals.handle({ threadId, method: 'thread/goal/updated', params: goalFrame() }) @@ -211,7 +212,7 @@ describe('codex goal lifecycle admission', () => { appendTombstone: () => {}, publish: () => {} } satisfies StructuredAgentSessionEventSink - const goals = new CodexJournalGoals(sink, () => ({})) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) const event = { threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() } goals.handle(event) diff --git a/src/main/codex/codex-structured-journal-goal-resume.test.ts b/src/main/codex/codex-structured-journal-goal-resume.test.ts index 4609a06fb0d..6df0ba01575 100644 --- a/src/main/codex/codex-structured-journal-goal-resume.test.ts +++ b/src/main/codex/codex-structured-journal-goal-resume.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import type { @@ -130,7 +131,9 @@ describe('codex goal lifecycle resume', () => { it('does not append a cleared snapshot when the journal has no prior goal occurrence', async () => { const journal = goalJournal() journal.unbind() - const resumed = new CodexJournalGoals(journal.sink, () => ({})) + const resumed = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) expect( resumed.handle({ @@ -151,7 +154,9 @@ describe('codex goal lifecycle resume', () => { it('does not revisit durable history for accounting-only updates', async () => { const journal = goalJournal() - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) goals.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() }) await journal.drained() const visits = journal.visits() @@ -176,7 +181,9 @@ describe('codex goal lifecycle resume', () => { it('rebuilds dedupe state after the journal epoch is replaced', async () => { const journal = goalJournal() - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) const event = { threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() } goals.handle(event) @@ -198,7 +205,9 @@ describe('codex goal lifecycle resume', () => { it('visits a large journal once per epoch when thread churn exceeds the transient LRU', async () => { const journal = goalJournal() journal.seedProviderItems(10_000) - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) const threadCount = MAX_CODEX_GOAL_THREADS + 1 const sendRound = () => { for (let index = 0; index < threadCount; index += 1) { @@ -227,7 +236,9 @@ describe('codex goal lifecycle resume', () => { const journal = goalJournal() journal.seedProviderItems(10_000) journal.unbind() - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) for (let index = 0; index < MAX_CODEX_GOAL_THREADS; index += 1) { goals.handle({ @@ -249,7 +260,9 @@ describe('codex goal lifecycle resume', () => { it('retries a journal-derived transition after lifecycle backpressure', async () => { const journal = goalJournal({ watermarks: { maxLifecycleQueuedOperations: 1 } }) - const goals = new CodexJournalGoals(journal.sink, () => ({})) + const goals = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) journal.unbind() expect( @@ -317,7 +330,9 @@ describe('codex goal lifecycle resume', () => { }) } - const prior = new CodexJournalGoals(journal.sink, () => ({})) + const prior = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) for (const state of scenario.beforeResume) { send(prior, state) } @@ -329,7 +344,9 @@ describe('codex goal lifecycle resume', () => { prior.dispose() journal.unbind() - const resumed = new CodexJournalGoals(journal.sink, () => ({})) + const resumed = new CodexJournalGoals(journal.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) resumed.handle({ threadId: THREAD, method: scenario.resumed.method, diff --git a/src/main/codex/codex-structured-journal-goal-revision.test.ts b/src/main/codex/codex-structured-journal-goal-revision.test.ts index e6f96ad5503..2fbe33ab901 100644 --- a/src/main/codex/codex-structured-journal-goal-revision.test.ts +++ b/src/main/codex/codex-structured-journal-goal-revision.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -93,10 +94,10 @@ describe('codex goal accounting revisions', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'earlier' }, { kind: 'status', text: 'Context compacted' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const { sink, drained, subscribe, published } = journalSink(journal) - const goals = new CodexJournalGoals(sink, () => ({})) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) goals.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() }) await drained() @@ -104,7 +105,7 @@ describe('codex goal accounting revisions', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'later' }, { kind: 'status', text: 'Something after the goal' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) subscribe() @@ -160,14 +161,18 @@ describe('codex goal accounting revisions', () => { root = await mkdtemp(join(tmpdir(), 'orca-goal-resume-revision-')) const journal = await journals.open({ identity: IDENTITY, journalDir: root }) const first = journalSink(journal) - const prior = new CodexJournalGoals(first.sink, () => ({})) + const prior = new CodexJournalGoals(first.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) prior.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() }) await first.drained() prior.dispose() const [created] = journal.snapshot().items const second = journalSink(journal) - const resumed = new CodexJournalGoals(second.sink, () => ({})) + const resumed = new CodexJournalGoals(second.sink, () => ({ + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })) // Only a few seconds more: a resume snapshot still refreshes, since no live tick follows. const snapshot = goalFrame({ timeUsedSeconds: 3, updatedAt: 1789067991 }) resumed.handle({ threadId: THREAD, method: 'thread/goal/updated', params: snapshot }) diff --git a/src/main/codex/codex-structured-journal-goal-rows.test.ts b/src/main/codex/codex-structured-journal-goal-rows.test.ts index ff234c7d5fb..053376e30e0 100644 --- a/src/main/codex/codex-structured-journal-goal-rows.test.ts +++ b/src/main/codex/codex-structured-journal-goal-rows.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' @@ -35,8 +36,11 @@ function frames(): { }, publish: vi.fn() } as unknown as StructuredAgentSessionEventSink - const goals = new CodexJournalGoals(sink, () => ({})) - const generic = new CodexJournalGenericFrames({ sink, linkageFor: () => ({}) }, () => null) + const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE })) + const generic = new CodexJournalGenericFrames( + { sink, attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }, + () => null + ) return { rows, frames: { diff --git a/src/main/codex/codex-structured-journal-goals.ts b/src/main/codex/codex-structured-journal-goals.ts index 681a483c12f..d73b163765e 100644 --- a/src/main/codex/codex-structured-journal-goals.ts +++ b/src/main/codex/codex-structured-journal-goals.ts @@ -24,7 +24,7 @@ import { } from './codex-structured-journal-contracts' import { MAX_CODEX_GOAL_THREADS } from './codex-structured-journal-limits' import { appendCodexLifecycleTransition } from './codex-structured-journal-sink' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' type GoalAccounting = { key: string; timeUsedSeconds: number; tokenBudget: number | null } @@ -70,7 +70,7 @@ export class CodexJournalGoals { constructor( private readonly sink: StructuredAgentSessionEventSink, - private readonly linkageFor: CodexRowLinkage + private readonly attributionFor: CodexRowAttribution ) {} handle(event: { @@ -132,7 +132,7 @@ export class CodexJournalGoals { event.method === 'thread/goal/cleared' ), // A goal belongs to its thread, not to one run of it, so no turn is named. - this.linkageFor(event.threadId, null) + this.attributionFor(event.threadId, null) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index 6717f3edabe..4e6ebe8e5e0 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -1,7 +1,7 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity, - AgentJournalProducerLinkage + AgentJournalRowAttribution } from '../../shared/agent-session-journal-types' import { requiresTerminalSettlement } from '../native-chat/agent-session-journal/journal-terminal-settlement' import { @@ -31,7 +31,7 @@ import type { CodexActiveJournalItem } from './codex-structured-journal-settleme import { readCodexJournalString } from './codex-structured-journal-translation-values' import { readCodexTurnId } from './codex-structured-thread-facts' import { readCodexDispatchEcho } from './codex-structured-dispatch-echo' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' export class CodexJournalItems { readonly ordinals = new CodexTurnOrdinals() @@ -44,7 +44,7 @@ export class CodexJournalItems { private readonly deps: Pick< CodexJournalTranslatorDeps, 'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule' - > & { maxMetadataBytes?: number; linkageFor: CodexRowLinkage }, + > & { maxMetadataBytes?: number; attributionFor: CodexRowAttribution }, private readonly activeTurn: (threadId: string) => string | null, private readonly suppress: (threadId: string, turnId: string) => void ) { @@ -56,7 +56,7 @@ export class CodexJournalItems { maxMetadataBytes: deps.maxMetadataBytes, turnIdFor: (threadId, params) => readCodexTurnId(params) ?? this.activeTurn(threadId), identityFor: (threadId, turnId, item) => this.identityFor(threadId, turnId, item), - linkageFor: deps.linkageFor + attributionFor: deps.attributionFor }) } @@ -122,7 +122,7 @@ export class CodexJournalItems { event.method, identity, translated, - this.deps.linkageFor(event.threadId, turnId) + this.deps.attributionFor(event.threadId, turnId) ) if (!admission.accepted) { return { handled: true, admission } @@ -151,17 +151,22 @@ export class CodexJournalItems { method: string, identity: AgentJournalItemIdentity, translated: ReturnType, - linkage: AgentJournalProducerLinkage + attribution: AgentJournalRowAttribution ): CodexJournalTranslationAdmission { if (!translated.body) { return CODEX_JOURNAL_ADMITTED } if (method === 'item/completed') { - const admission = appendCodexLifecycleItem(this.deps.sink, identity, translated.body, linkage) + const admission = appendCodexLifecycleItem( + this.deps.sink, + identity, + translated.body, + attribution + ) return admission.accepted ? publishCodexLifecycle(this.deps.sink) : admission } const options = requiresTerminalSettlement(translated.body) ? { lifecycle: true } : {} - const appendOptions = { ...options, ...linkage } + const appendOptions = { ...options, ...attribution } const admission = this.deps.sink.tryAppendItem ? this.deps.sink.tryAppendItem(identity, translated.body, appendOptions) : (this.deps.sink.appendItem(identity, translated.body, appendOptions), @@ -235,7 +240,7 @@ export class CodexJournalItems { this.deps.sink, evicted.identity, evictedActiveBody(translated), - this.deps.linkageFor(evicted.threadId, evicted.turnId) + this.deps.attributionFor(evicted.threadId, evicted.turnId) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-prompts.ts b/src/main/codex/codex-structured-journal-prompts.ts index 96c58f9b8b7..9c145915fe5 100644 --- a/src/main/codex/codex-structured-journal-prompts.ts +++ b/src/main/codex/codex-structured-journal-prompts.ts @@ -20,7 +20,7 @@ import { } from './codex-structured-journal-sink' import type { CodexPendingJournalPrompt } from './codex-structured-journal-settlement' import { readCodexTurnId } from './codex-structured-thread-facts' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' import { journalLifecycleItemMutation } from '../native-chat/agent-session-journal/journal-row-builders' type CodexGroupedPendingJournalPrompt = CodexPendingJournalPrompt & { promptKey: string } @@ -30,7 +30,7 @@ export class CodexJournalPrompts { constructor( private readonly deps: Pick & { - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution }, private readonly detailFor: (threadId: string, itemId: string) => string | null, private readonly activeTurn: (threadId: string) => string | null @@ -118,7 +118,7 @@ export class CodexJournalPrompts { ) const mutations = group.flatMap(([, prompt]) => { const body = cancelledJournalPromptBody(prompt.body) - const producer = this.deps.linkageFor(prompt.threadId, prompt.turnId) + const producer = this.deps.attributionFor(prompt.threadId, prompt.turnId) return body ? [journalLifecycleItemMutation(producer, prompt.identity, body)] : [] }) const admission = appendCodexLifecycleMutations( @@ -152,7 +152,7 @@ export class CodexJournalPrompts { )}:${encodeURIComponent(event.promptKey)}`, items, // A child's approval arrives on the child's own thread, so it names the asker. - this.deps.linkageFor(event.threadId, turnId) + this.deps.attributionFor(event.threadId, turnId) ) } @@ -170,7 +170,7 @@ export class CodexJournalPrompts { this.deps.sink, evicted.identity, cancelled, - this.deps.linkageFor(evicted.threadId, evicted.turnId) + this.deps.attributionFor(evicted.threadId, evicted.turnId) ) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts index 8ce2591f57c..e513655cd8f 100644 --- a/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts +++ b/src/main/codex/codex-structured-journal-restore-turn-grouping.test.ts @@ -2,10 +2,11 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { nativeChatTurnFold } from '../../shared/native-chat-turn-fold' -import { nativeChatRowTurnKeys } from '../../shared/native-chat-turn-grouping' +import { nativeChatTurnMembership } from '../../shared/native-chat-turn-membership' import type { NativeChatMessage } from '../../shared/native-chat-types' import { projectStructuredItemToNativeChat } from '../../shared/structured-agent-session-projection' import { selectStructuredAgentTurnBars } from '../../shared/structured-agent-session-turn-timing' +import { JournalDerivedTurnScope } from '../native-chat/agent-session-journal/journal-derived-turn-scope' import { createCodexJournalTranslator } from './codex-structured-journal-translation' const THREAD_ID = 'thread-abc' @@ -29,19 +30,25 @@ function historicalTurn(index: number): Record { } } -/** What the journal hands a reader after a restore: rows in append order. */ -function restoredJournal(turnCount: number): AgentJournalRenderItem[] { +/** What the journal hands a reader after a restore: rows in append order. With `statesScope`, each + * row carries the turn scope the journal gives a row the translator writes without one. */ +function restoredJournal(turnCount: number, statesScope: boolean): AgentJournalRenderItem[] { const items: AgentJournalRenderItem[] = [] + const derived = new JournalDerivedTurnScope() const translator = createCodexJournalTranslator({ sink: { - appendItem: (identity, body) => { + appendItem: (identity, body, options) => { const sequence = items.length + 1 + const itemId = agentJournalItemKey(identity) + const turnScope = options.turnScope ?? derived.scopeFor(body) + derived.observe(itemId, true, undefined, body) items.push({ - itemId: agentJournalItemKey(identity), + itemId, revision: 1, body, sequence, - observedAt: sequence + observedAt: sequence, + ...(statesScope ? { turnScope } : {}) }) }, appendTombstone: () => {}, @@ -56,43 +63,50 @@ function restoredJournal(turnCount: number): AgentJournalRenderItem[] { } describe('grouping a Codex thread restored from full history', () => { - it('keeps each turn with its own rows and folds each to its own answer', () => { - const items = restoredJournal(3) - const bars = selectStructuredAgentTurnBars(items, [], null) - const messages = items - .map(projectStructuredItemToNativeChat) - .filter((message): message is NativeChatMessage => message !== null) - const turnKeys = nativeChatRowTurnKeys(messages, bars.turnKeysByItemId) - const opener = (index: number): string => `codex:${THREAD_ID}:turn-${index}:0` + it.each([ + ['states each row’s turn', true], + ['states no scope', false] + ])( + 'keeps each turn with its own rows and folds each to its own answer on a host that %s', + (_host, statesScope) => { + const items = restoredJournal(3, statesScope) + const bars = selectStructuredAgentTurnBars(items, [], null) + const messages = items + .map(projectStructuredItemToNativeChat) + .filter((message): message is NativeChatMessage => message !== null) + const { turnKeys } = nativeChatTurnMembership(messages, { items, submissions: [] }) + const opener = (index: number): string => `codex:${THREAD_ID}:turn-${index}:0` - expect(messages.map((message, index) => [message.role, turnKeys[index]])).toEqual([ - ['user', opener(1)], - ['assistant', opener(1)], - ['assistant', opener(1)], - ['user', opener(2)], - ['assistant', opener(2)], - ['assistant', opener(2)], - ['user', opener(3)], - ['assistant', opener(3)], - ['assistant', opener(3)] - ]) - expect([...bars.settledTurns.keys()]).toEqual([opener(1), opener(2), opener(3)]) - - const { foldedRows } = nativeChatTurnFold({ - rows: messages.map((message, index) => ({ - turnKey: turnKeys[index], - role: message.role, - rendersProse: true, - outlivesTurn: false - })), - settledTurnKeys: new Set(bars.settledTurns.keys()), - expandedTurnKeys: new Set() - }) - const visible = messages.filter((_, index) => !foldedRows.has(index)) - expect(visible.map((message) => message.blocks)).toEqual( - ['ask 1', 'answer 1', 'ask 2', 'answer 2', 'ask 3', 'answer 3'].map((text) => [ - { type: 'text', text } + expect(messages.map((message, index) => [message.role, turnKeys[index]])).toEqual([ + ['user', opener(1)], + ['assistant', opener(1)], + ['assistant', opener(1)], + ['user', opener(2)], + ['assistant', opener(2)], + ['assistant', opener(2)], + ['user', opener(3)], + ['assistant', opener(3)], + ['assistant', opener(3)] ]) - ) - }) + expect([...bars.settledTurns.keys()]).toEqual([opener(1), opener(2), opener(3)]) + + const { foldedRows } = nativeChatTurnFold({ + rows: messages.map((message, index) => ({ + turnKey: turnKeys[index], + role: message.role, + rendersProse: true, + outlivesTurn: false, + reportsTurnOutcome: false + })), + settledTurnKeys: new Set(bars.settledTurns.keys()), + expandedTurnKeys: new Set() + }) + const visible = messages.filter((_, index) => !foldedRows.has(index)) + expect(visible.map((message) => message.blocks)).toEqual( + ['ask 1', 'answer 1', 'ask 2', 'answer 2', 'ask 3', 'answer 3'].map((text) => [ + { type: 'text', text } + ]) + ) + } + ) }) diff --git a/src/main/codex/codex-structured-journal-settlement.ts b/src/main/codex/codex-structured-journal-settlement.ts index 39d52e6b047..a3f4c7f5599 100644 --- a/src/main/codex/codex-structured-journal-settlement.ts +++ b/src/main/codex/codex-structured-journal-settlement.ts @@ -1,7 +1,8 @@ -import type { - AgentJournalItemBody, - AgentJournalItemIdentity, - AgentJournalTurnLifecycle +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalItemIdentity, + type AgentJournalTurnLifecycle } from '../../shared/agent-session-journal-types' import { journalLifecycleItemMutation, @@ -26,7 +27,7 @@ import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns' import { appendCodexLifecycleMutations } from './codex-structured-journal-sink' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' export type CodexActiveJournalItem = { threadId: string @@ -53,9 +54,10 @@ export function settleCodexJournalSession(input: { currentTurnIds: ReadonlyMap> primaryThreadId: string | null ordinals: CodexTurnOrdinals - /** Terminal lifecycle for a turn the provider left running when it ended. */ - settledTurnLifecycle: (threadId: string, turnId: string) => AgentJournalTurnLifecycle - linkageFor: CodexRowLinkage + /** Terminal lifecycle for a turn the provider left running when it ended; null for a turn a + * conversation command claimed, whose record the host settles. */ + settledTurnLifecycle: (threadId: string, turnId: string) => AgentJournalTurnLifecycle | null + attributionFor: CodexRowAttribution }): StructuredAgentSessionSinkAdmission { // Rows from every thread settle in this one batch, so each names its own producer. const mutations: JournalLifecycleMutationInput[] = [] @@ -67,13 +69,13 @@ export function settleCodexJournalSession(input: { : codexJournalItem(active.item) const body = interruptedBody(translated.body) if (body) { - mutations.push(settledRow(input.linkageFor, active, body)) + mutations.push(settledRow(input.attributionFor, active, body)) } } for (const prompt of input.pendingPrompts.values()) { const body = cancelledJournalPromptBody(prompt.body) if (body) { - mutations.push(settledRow(input.linkageFor, prompt, body)) + mutations.push(settledRow(input.attributionFor, prompt, body)) } } for (const [threadId, turnIds] of input.currentTurnIds) { @@ -81,11 +83,15 @@ export function settleCodexJournalSession(input: { continue } for (const turnId of turnIds) { - mutations.push({ - kind: 'item', - identity: codexTurnLifecycleIdentity(input.event.sessionId, turnId), - body: codexTurnLifecycleBody(input.settledTurnLifecycle(threadId, turnId)) - }) + const turnLifecycle = input.settledTurnLifecycle(threadId, turnId) + if (turnLifecycle) { + mutations.push({ + kind: 'item', + identity: codexTurnLifecycleIdentity(input.event.sessionId, turnId), + body: codexTurnLifecycleBody(turnLifecycle), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + } turnOrdinalsToForget.push({ threadId, turnId }) } } @@ -114,7 +120,9 @@ export function settleCodexJournalTurn(input: { activeItems: Map pendingPrompts?: Map clearPromptTurn?: (threadId: string, turnId: string) => void - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution + /** The end of a conversation command the turn carried, which settles with it. */ + commandEnd?: readonly JournalLifecycleMutationInput[] }): StructuredAgentSessionSinkAdmission { const mutations: JournalLifecycleMutationInput[] = [] const activeItemsToForget: { key: string; threadId: string; itemId: string }[] = [] @@ -133,7 +141,7 @@ export function settleCodexJournalTurn(input: { : codexJournalItem(active.item) const body = interruptedBody(translated.body) if (body) { - mutations.push(settledRow(input.linkageFor, active, body)) + mutations.push(settledRow(input.attributionFor, active, body)) } activeItemsToForget.push({ key, threadId: active.threadId, itemId: active.item.id }) } @@ -143,7 +151,7 @@ export function settleCodexJournalTurn(input: { } const body = cancelledJournalPromptBody(prompt.body) if (body) { - mutations.push(settledRow(input.linkageFor, prompt, body)) + mutations.push(settledRow(input.attributionFor, prompt, body)) } pendingPromptsToForget.push(key) } @@ -152,9 +160,11 @@ export function settleCodexJournalTurn(input: { mutations.push({ kind: 'item', identity: codexTurnLifecycleIdentity(input.sessionId, input.turnId), - body: codexTurnLifecycleBody(input.turnLifecycle) + body: codexTurnLifecycleBody(input.turnLifecycle), + turnScope: AGENT_JOURNAL_THREAD_SCOPE }) } + mutations.push(...(input.commandEnd ?? [])) const admission = appendCodexLifecycleMutations( input.sink, `turn-completed:${input.sessionId}:${input.threadId}:${input.turnId}`, @@ -176,11 +186,11 @@ export function settleCodexJournalTurn(input: { /** A settled item or prompt, naming its producer: the settlement can be the row's first write. */ function settledRow( - linkageFor: CodexRowLinkage, + attributionFor: CodexRowAttribution, row: { threadId: string; turnId: string | null; identity: AgentJournalItemIdentity }, body: AgentJournalItemBody ): JournalLifecycleMutationInput { - return journalLifecycleItemMutation(linkageFor(row.threadId, row.turnId), row.identity, body) + return journalLifecycleItemMutation(attributionFor(row.threadId, row.turnId), row.identity, body) } function interruptedBody(body: AgentJournalItemBody | null): AgentJournalItemBody | null { diff --git a/src/main/codex/codex-structured-journal-sink.ts b/src/main/codex/codex-structured-journal-sink.ts index 95a27702365..0823e5679cf 100644 --- a/src/main/codex/codex-structured-journal-sink.ts +++ b/src/main/codex/codex-structured-journal-sink.ts @@ -1,10 +1,10 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity, - AgentJournalProducerLinkage + AgentJournalRowAttribution } from '../../shared/agent-session-journal-types' import type { - StructuredAgentSessionAppendOptions, + StructuredAgentSessionItemAppendOptions, StructuredAgentSessionEventSink, StructuredAgentSessionLifecycleIdentityResolver, StructuredAgentSessionSinkAdmission @@ -35,7 +35,11 @@ export function appendCodexLifecycleMutations( } else { for (const mutation of chunk) { if (mutation.kind === 'item') { - const options = { lifecycle: true, ...mutation.linkage } + const options = { + lifecycle: true, + ...mutation.linkage, + turnScope: mutation.turnScope + } if (sink.tryAppendItem) { admission = sink.tryAppendItem(mutation.identity, mutation.body, options) if (!admission.accepted) { @@ -74,7 +78,7 @@ export function appendCodexItemAndPublish( sink: StructuredAgentSessionEventSink, identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission { const admission = sink.tryAppendItem ? sink.tryAppendItem(identity, body, options) @@ -95,9 +99,9 @@ export function appendCodexLifecycleItem( sink: StructuredAgentSessionEventSink, identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - linkage: AgentJournalProducerLinkage + attribution: AgentJournalRowAttribution ): CodexJournalTranslationAdmission { - const options = { lifecycle: true, ...linkage } + const options = { lifecycle: true, ...attribution } if (sink.tryAppendItem) { return criticalAdmission(sink.tryAppendItem(identity, body, options)) } @@ -110,14 +114,14 @@ export function appendCodexLifecycleTransition( identitySizeBound: AgentJournalItemIdentity, body: AgentJournalItemBody, resolveIdentity: StructuredAgentSessionLifecycleIdentityResolver, - linkage: AgentJournalProducerLinkage + attribution: AgentJournalRowAttribution ): CodexJournalTranslationAdmission { if (sink.tryAppendLifecycleTransition) { return criticalAdmission( - sink.tryAppendLifecycleTransition(identitySizeBound, body, resolveIdentity, linkage) + sink.tryAppendLifecycleTransition(identitySizeBound, body, resolveIdentity, attribution) ) } - const admission = appendCodexLifecycleItem(sink, identitySizeBound, body, linkage) + const admission = appendCodexLifecycleItem(sink, identitySizeBound, body, attribution) return admission.accepted ? publishCodexLifecycle(sink) : admission } @@ -136,7 +140,7 @@ export function admitCodexLifecycleItems( sink: StructuredAgentSessionEventSink, settlementId: string, items: readonly Pick[], - linkage: AgentJournalProducerLinkage + attribution: AgentJournalRowAttribution ): CodexJournalTranslationAdmission { if (items.length === 0) { return { accepted: false, reason: 'untranslated' } @@ -145,14 +149,14 @@ export function admitCodexLifecycleItems( const admission = criticalAdmission( sink.tryAppendLifecycleBatch( settlementId, - items.map((item) => journalLifecycleItemMutation(linkage, item.identity, item.body)), + items.map((item) => journalLifecycleItemMutation(attribution, item.identity, item.body)), { lifecycle: true } ) ) return admission.accepted ? publishCodexLifecycle(sink) : admission } for (const item of items) { - const admission = appendCodexLifecycleItem(sink, item.identity, item.body, linkage) + const admission = appendCodexLifecycleItem(sink, item.identity, item.body, attribution) if (!admission.accepted) { return admission } diff --git a/src/main/codex/codex-structured-journal-subagent-readers.test.ts b/src/main/codex/codex-structured-journal-subagent-readers.test.ts index 117f204e963..7b7266345e7 100644 --- a/src/main/codex/codex-structured-journal-subagent-readers.test.ts +++ b/src/main/codex/codex-structured-journal-subagent-readers.test.ts @@ -9,7 +9,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { selectStructuredAgentTurnActivity } from '../../shared/native-chat-turn-activity' -import { latestStructuredAgentSessionAssistantMessage } from '../../shared/structured-agent-session-projection' +import { latestStructuredAgentSessionAssistantMessage } from '../../shared/structured-agent-session-latest-request' import { isStructuredAgentSessionThinking, statusStructuredAgentSessionToolCall diff --git a/src/main/codex/codex-structured-journal-translation-settlement.test.ts b/src/main/codex/codex-structured-journal-translation-settlement.test.ts index ab0e602e955..344d99e03b4 100644 --- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts +++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts @@ -551,7 +551,8 @@ describe('codex journal translation', () => { userItemId: `codex:${THREAD_ID}:${TURN_ID}:0`, startedAt: expect.any(Number), completedAt: expect.any(Number) - } + }, + turnScope: { kind: 'thread' } } ] } diff --git a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts index 3116a329c97..a853ca2bfd8 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts @@ -2,7 +2,10 @@ import type { AgentJournalTurnLifecycle, AgentJournalTurnOutcome } from '../../shared/agent-session-journal-types' -import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../shared/agent-session-journal-item-key' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CODEX_JOURNAL_ADMITTED, @@ -27,7 +30,10 @@ import { readCodexTurnId, readCodexTurnStatus } from './codex-structured-thread-facts' -import type { CodexRowLinkage } from './codex-subagent-linkage' +import type { CodexRowAttribution } from './codex-subagent-linkage' +import type { CodexJournalCommandTurn } from './codex-journal-command-turn' +import { readRecord, readString } from './codex-item-field-readers' +import { recordCodexCommandTurnClaim } from './codex-command-turn-claim' type TurnBoundaryEvent = { sessionId: string @@ -59,7 +65,8 @@ export class CodexJournalTurnBoundaries { clearPromptTurn?: (threadId: string, turnId: string) => void flushSuppression: () => CodexJournalTranslationAdmission resetActivity: (threadId: string) => void - linkageFor: CodexRowLinkage + attributionFor: CodexRowAttribution + commands: CodexJournalCommandTurn now?: () => number } ) {} @@ -73,15 +80,20 @@ export class CodexJournalTurnBoundaries { 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 - }) + const command = + event.threadId === this.deps.primaryThreadId() ? this.deps.commands.claim(turnId) : null + // The command's turn is the record: this turn writes none, and its rows join the command's. + const admission = command + ? recordCodexCommandTurnClaim(this.deps.sink, agentJournalItemKey(command.identity), turnId) + : 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, @@ -156,15 +168,20 @@ export class CodexJournalTurnBoundaries { // turn boundary is no evidence contact was lost. Only `settleSession` may // write `unverifiable`. const status = readCodexTurnStatus(event.params) - const turnLifecycle = - event.threadId === this.deps.primaryThreadId() - ? this.settled(event.threadId, turnId, { - state: codexTurnLifecycleState(status), - outcome: codexTurnOutcome(status), - completedAt: this.receiptTime(event), - durationMs: readCodexTurnDurationMs(event.params) - }) - : null + const completedAt = this.receiptTime(event) + const commandEnd = this.deps.commands.end(turnId, { + status, + error: readString(readRecord(readRecord(readRecord(event.params).turn).error), 'message'), + completedAt + }) + const turnLifecycle = this.ownsRecord(event.threadId, turnId) + ? this.settled(event.threadId, turnId, { + state: codexTurnLifecycleState(status), + outcome: codexTurnOutcome(status), + completedAt, + durationMs: readCodexTurnDurationMs(event.params) + }) + : null const requestOrigin = this.deps.activeTurns.requestOrigin(event.threadId, turnId) const latestDispatchSequence = this.deps.activeTurns.latestDispatchSequence( event.threadId, @@ -180,7 +197,8 @@ export class CodexJournalTurnBoundaries { activeItems: this.deps.items.activeItems, pendingPrompts: this.deps.pendingPrompts, ...(this.deps.clearPromptTurn ? { clearPromptTurn: this.deps.clearPromptTurn } : {}), - linkageFor: this.deps.linkageFor + attributionFor: this.deps.attributionFor, + commandEnd }) if (admission.accepted) { if (turnLifecycle) { @@ -193,6 +211,7 @@ export class CodexJournalTurnBoundaries { } this.deps.items.ordinals.forgetTurn(event.threadId, turnId) this.deps.activeTurns.forget(event.threadId, turnId) + this.deps.commands.settled(turnId) this.deps.resetActivity(event.threadId) } return admission @@ -216,9 +235,15 @@ export class CodexJournalTurnBoundaries { } } + /** Whether this translator writes the turn's record: a primary turn no command claimed. */ + ownsRecord(threadId: string, turnId: string): boolean { + return threadId === this.deps.primaryThreadId() && !this.deps.commands.isCarrying(turnId) + } + clear(): void { this.deps.activeTurns.clear() this.recentTurns.clear() + this.deps.commands.clear() } private receiptTime(event: TurnBoundaryEvent): number { diff --git a/src/main/codex/codex-structured-journal-translation-turns.ts b/src/main/codex/codex-structured-journal-translation-turns.ts index 52dca66a35b..349233a32d1 100644 --- a/src/main/codex/codex-structured-journal-translation-turns.ts +++ b/src/main/codex/codex-structured-journal-translation-turns.ts @@ -1,9 +1,10 @@ -import type { - AgentJournalItemIdentity, - AgentJournalTurnItem, - AgentJournalTurnLifecycle, - AgentJournalTurnLifecycleState, - AgentJournalTurnOutcome +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalTurnItem, + type AgentJournalTurnLifecycle, + type AgentJournalTurnLifecycleState, + type AgentJournalTurnOutcome } from '../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' @@ -109,6 +110,7 @@ export function publishCodexTurnLifecycle(input: { // The running row's `ts` is the host's turn-start receipt so clients can anchor a live counter. const appendOptions = { lifecycle: true, + turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...(input.state === 'running' && input.startedAt !== undefined ? { observedAt: input.startedAt } : {}) diff --git a/src/main/codex/codex-structured-journal-translation-writers.ts b/src/main/codex/codex-structured-journal-translation-writers.ts index 72c1026452d..b13a7212142 100644 --- a/src/main/codex/codex-structured-journal-translation-writers.ts +++ b/src/main/codex/codex-structured-journal-translation-writers.ts @@ -1,6 +1,6 @@ // The translator's writers, built once. Split out so the translator reads as // routing, and so one place shows that every writer is handed the same -// producer resolver: the roster's, which knows each child thread. +// row attribution: the roster's producer, which knows each child thread, and the turn scope. import { CodexJournalCompactions } from './codex-structured-journal-compactions' import type { CodexJournalTranslatorDeps } from './codex-structured-journal-contracts' @@ -9,31 +9,50 @@ import { CodexJournalGoals } from './codex-structured-journal-goals' import { CodexJournalItems } from './codex-structured-journal-items' import { CodexJournalPrompts } from './codex-structured-journal-prompts' import { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state' +import { CodexJournalTurnScopes } from './codex-journal-turn-scopes' +import { CodexJournalCommandTurn } from './codex-journal-command-turn' import { CodexSubagentRoster } from './codex-subagent-roster' +import type { CodexRowAttribution } from './codex-subagent-linkage' export function createCodexJournalTranslatorWriters(deps: CodexJournalTranslatorDeps) { const activeTurns = new CodexJournalActiveTurns() const activeTurn = (threadId: string): string | null => activeTurns.current(threadId) + const primaryThreadId = (): string | null => deps.primaryThreadId?.() ?? null + const commands = new CodexJournalCommandTurn() + const turnScopes = new CodexJournalTurnScopes({ + sessionId: deps.sessionId, + primaryThreadId, + activeTurn, + commandScope: (turnId) => commands.scopeFor(turnId) + }) const subagents = new CodexSubagentRoster({ sink: deps.sink, - primaryThreadId: () => deps.primaryThreadId?.() ?? null, + primaryThreadId, activeTurn, + turnScopeFor: (threadId, turnId) => turnScopes.scopeFor(threadId, turnId), ...(deps.subagentExecutions ? { executions: deps.subagentExecutions } : {}) }) const { linkageFor } = subagents.linkage - const producerDeps = { ...deps, linkageFor } + const attributionFor: CodexRowAttribution = (threadId, turnId) => ({ + ...linkageFor(threadId, turnId), + turnScope: turnScopes.scopeFor(threadId, turnId) + }) + const producerDeps = { ...deps, attributionFor } const genericFrames = new CodexJournalGenericFrames(producerDeps, activeTurn) const items = new CodexJournalItems(producerDeps, activeTurn, (threadId, turnId) => genericFrames.suppress(threadId, turnId) ) return { activeTurns, + commands, subagents, - linkageFor, + attributionFor, genericFrames, items, - compactions: new CodexJournalCompactions(deps.sink, activeTurn, linkageFor), - goals: new CodexJournalGoals(deps.sink, linkageFor), + compactions: new CodexJournalCompactions(deps.sink, activeTurn, attributionFor, (turnId) => + commands.compacted(turnId) + ), + goals: new CodexJournalGoals(deps.sink, attributionFor), prompts: new CodexJournalPrompts( producerDeps, (threadId, itemId) => items.detailFor(threadId, itemId), diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index fadab8b4589..8e05506509a 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -35,8 +35,17 @@ export { export function createCodexJournalTranslator( deps: CodexJournalTranslatorDeps ): CodexJournalTranslator { - const { activeTurns, subagents, linkageFor, genericFrames, items, compactions, goals, prompts } = - createCodexJournalTranslatorWriters(deps) + const { + activeTurns, + commands, + subagents, + attributionFor, + genericFrames, + items, + compactions, + goals, + prompts + } = createCodexJournalTranslatorWriters(deps) const flushStreams = (): CodexJournalTranslationAdmission => items.streams.flush() ? CODEX_JOURNAL_ADMITTED : { accepted: false, reason: 'backpressure' } let readActivity = createCodexProviderActivityReader() @@ -55,7 +64,8 @@ export function createCodexJournalTranslator( ...(deps.clearPromptTurn ? { clearPromptTurn: deps.clearPromptTurn } : {}), flushSuppression: () => genericFrames.flush(), resetActivity, - linkageFor, + attributionFor, + commands, ...(deps.now ? { now: deps.now } : {}) }) let primaryThreadStoppedRunning = false @@ -150,11 +160,13 @@ export function createCodexJournalTranslator( // The host saw the child go, not what Codex made of the turn, so the row // carries no outcome: the end is observed, the verdict is unknown. settledTurnLifecycle: (threadId, turnId) => - turnBoundaries.settled(threadId, turnId, { - state: 'interrupted', - completedAt: event.observedAt ?? deps.now?.() ?? Date.now() - }), - linkageFor + turnBoundaries.ownsRecord(threadId, turnId) + ? turnBoundaries.settled(threadId, turnId, { + state: 'interrupted', + completedAt: event.observedAt ?? deps.now?.() ?? Date.now() + }) + : null, + attributionFor }) if (!admission.accepted) { return admission @@ -253,8 +265,14 @@ export function createCodexJournalTranslator( event.params, event.threadId ) + if (unhandled.accepted && event.method === 'error') { + commands.errorShown(event.params) + } return publishActivity(event, unhandled) }, + beginCommand: (command) => commands.begin(command), + forgetCommand: (turnId) => commands.forget(turnId), + commandProviderTurnId: (turnId) => commands.providerTurnId(turnId), cancelPrompt: (journalItemId) => prompts.cancel(journalItemId), resolvePrompt: (journalItemId) => prompts.resolve(journalItemId), flush: () => { diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts index 1742d5ced69..ff42a105fc6 100644 --- a/src/main/codex/codex-structured-prompt-ownership.ts +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -4,7 +4,6 @@ import { type AgentSessionCancelOutcome, type StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { answerCodexPrompt, prepareCodexPromptAnswer, @@ -17,6 +16,11 @@ import type { CodexStructuredTurnCancellation } from './codex-structured-turn-ca type CancelInput = Parameters[0] type AnswerInput = Parameters[0] +/** A command's Stop interrupts the provider turn carrying it; any other turn is its own. */ +function providerTurnId(session: CodexSession, turnId: string): string | undefined { + return session.translator ? session.translator.commandProviderTurnId(turnId) : turnId +} + /** How long a Stop waits for Codex to open the turn it answered a send into. Under the quit * path's eviction budget, which a close or quit queued behind the Stop spends. */ export const CODEX_STOP_TURN_OPEN_WAIT_MS = 5_000 @@ -30,13 +34,13 @@ async function cancelCodexConversation( input: Parameters[0], session: CodexSession ): Promise { - const { request, sessions, compactions, cancellation } = input + const { request, sessions, cancellation } = input const liveTurnId = request.resolveLiveTurnId?.() ?? null // A turn the journal shows that Codex has not started yet (a compaction's) has nothing to stop. const turnId = liveTurnId === null ? ([...(session.activeTurnIds ?? [])].at(-1) ?? (await openedAnsweredTurn(session))) - : compactions.providerTurnId(request.sessionId, liveTurnId) + : providerTurnId(session, liveTurnId) if (!turnId) { return { cancelled: false } } @@ -69,17 +73,16 @@ async function openedAnsweredTurn(session: CodexSession): Promise export async function cancelCodexStructuredTurn(input: { request: CancelInput sessions: Map - compactions: StructuredSessionCompaction cancellation: CodexStructuredTurnCancellation }): Promise { - const { request, sessions, compactions, cancellation } = input + const { request, sessions, cancellation } = input const session = requireLiveCodexSession(sessions, request.sessionId) const prompt = request.prompt const requestedTurnId = request.turnId if (requestedTurnId === undefined) { return prompt ? { cancelled: false } : cancelCodexConversation(input, session) } - const turnId = compactions.providerTurnId(request.sessionId, requestedTurnId) + const turnId = providerTurnId(session, requestedTurnId) if (!turnId) { return { cancelled: false } } @@ -103,7 +106,7 @@ export async function cancelCodexStructuredTurn(input: { !session.ended && session.fence === request.fence && session.acquisitionGeneration === acquisitionGeneration && - compactions.providerTurnId(request.sessionId, requestedTurnId) === turnId && + providerTurnId(session, requestedTurnId) === turnId && session.prompts.ownsBoundClaim(claim, prompt.itemId, claim.prompt.threadId, promptTurnId) let interruptConfirmed = false try { diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index ff9a210e17a..59a36dbdf67 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -5,7 +5,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem +} from '../../shared/agent-session-journal-types' import { EMPTY_STRUCTURED_AGENT_SESSION, reduceStructuredAgentSession, @@ -125,7 +128,7 @@ function codexPrompts(): CodexJournalPrompts { throw new Error('session was never acquired') } return new CodexJournalPrompts( - { sink, linkageFor: () => ({}) }, + { sink, attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }, () => null, () => 'turn-1' ) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 0cbe62224c5..e765955d53c 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -33,6 +33,7 @@ import { reportedCodexThreadOptions } from './codex-structured-fast-mode' import { + assertCodexConnectionOpen, codexSessionLifecycle, mintCodexAcquisitionGeneration, type CodexAcquisitionRegistry, @@ -41,6 +42,7 @@ import { type CodexStructuredSessionAdapterDeps } from './codex-structured-session-state' import type { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' +import type { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' import type { CodexStructuredNotificationRetry } from './codex-structured-notification-retry' import type { deliverCodexServerRequest } from './codex-structured-provider-events' @@ -64,12 +66,7 @@ export async function acquireCodexStructuredSession(input: { request: Parameters[2] ) => void handleUnhandledFrame: (sessionId: string, kind: string, payload: unknown) => void - forceCloseUnexpected: ( - sessionId: string, - fence: number, - acquisitionGeneration: string, - reason: Error - ) => Promise + forceCloseUnexpected: CodexStructuredSessionTeardown['forceCloseUnexpected'] }): Promise { const { input: acquireInput, @@ -224,9 +221,7 @@ export async function acquireCodexStructuredSession(input: { }), acquisitionGeneration: mintCodexAcquisitionGeneration(deps) } - if (connection.closed) { - throw new Error(`codex app-server for session ${sessionId} exited while being acquired`) - } + assertCodexConnectionOpen(connection, sessionId) acquisitions.assertCurrent(sessionId, attempt) const options = restoredCodexSessionOptions(acquireInput.options) const catalogAccess = codexAcquireCatalogAccess(deps, launch) @@ -238,9 +233,7 @@ export async function acquireCodexStructuredSession(input: { timeoutMs: deps.requestTimeoutMs }) acquisitions.assertCurrent(sessionId, attempt) - if (connection.closed) { - throw new Error(`codex app-server for session ${sessionId} exited while being acquired`) - } + assertCodexConnectionOpen(connection, sessionId) acquisitions.deleteIfCurrent(sessionId, attempt) // Where this session's child work goes: the host's records, after each frame is journaled. const sink = codexChildWorkSink(sessionId, deps) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index eebebe3aa44..82f927e0e86 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -3,7 +3,6 @@ import type { AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { isCodexAppServerRequestError } from './codex-app-server-connection' import type { AgentSessionAcquisition, @@ -14,6 +13,7 @@ import type { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexJournalTranslationAdmission } from './codex-structured-journal-translation' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' +import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure' import { supportsCodexStructuredLocation } from './codex-structured-location-support' import { CodexStructuredSessionTeardown } from './codex-structured-session-teardown' import { @@ -34,7 +34,10 @@ import { translateCodexNotification } from './codex-structured-provider-events' import { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' -import { settleCodexSendsInEndedTurn } from './codex-structured-turn-end-settlement' +import { + codexDispatchRejection, + settleCodexSendsInEndedTurn +} from './codex-structured-turn-end-settlement' import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry' import { acquireCodexStructuredSession } from './codex-structured-session-acquire' import { changeCodexThreadGoal } from './codex-structured-thread-goal' @@ -50,7 +53,6 @@ export type { } from './codex-structured-session-state' export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdapter { - private readonly compactions = new StructuredSessionCompaction() private readonly sessions = new Map() private readonly acquisitions = new CodexAcquisitionRegistry() private readonly turnCancellation: CodexStructuredTurnCancellation @@ -154,7 +156,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap return admission } if (event.type === 'notification') { - this.compactions.codex(event.sessionId, event.method, event.params) // Only an admitted turn end settles; a refused one settles on the retry that lands. settleCodexSendsInEndedTurn(session, event.method, event.params, (settlement) => this.deps.onDispatchSettledLate?.({ sessionId: event.sessionId, ...settlement }) @@ -167,9 +168,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap // After the journal and the parent's republished row, never ahead of either. session.backgroundTasks.publishChildWork() } - if (event.type === 'ended') { - this.compactions.ended(event.sessionId) - } this.deps.onEvent?.(event) return admission } @@ -236,7 +234,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap cancelCodexStructuredTurn({ request, sessions: this.sessions, - compactions: this.compactions, cancellation: this.turnCancellation }) @@ -251,33 +248,31 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap recoverRewind: NonNullable = (input) => codexRewind.recoverCodexRewind(this.session(input.sessionId), input, this.deps.requestTimeoutMs) - compact: NonNullable = (input) => { + /** The ack is Codex's receipt; the translator ends the command's turn from the turn it opens. */ + compact: NonNullable = async (input) => { const session = this.session(input.sessionId) - return this.compactions.run( - input.sessionId, - session.threadId, - async () => { - await this.turnCancellation.captureBaseline(session) - return session.connection - .request( - 'thread/compact/start', - { threadId: session.threadId }, - { timeoutMs: this.deps.requestTimeoutMs } + session.translator?.beginCommand(input.command) + try { + await this.turnCancellation.captureBaseline(session) + await session.connection.request( + 'thread/compact/start', + { threadId: session.threadId }, + { timeoutMs: this.deps.requestTimeoutMs } + ) + return { state: 'accepted', providerIdentity: null } + } catch (error) { + session.translator?.forgetCommand(input.command.turnId) + if (isCodexAppServerRequestError(error)) { + // Codex's own words, when it gave any, are the one part of the error a person can use. + return { + state: 'rejected', + ...codexDispatchRejection( + agentSessionFailureFact('providerRejected', { detail: providerDiagnosticOf(error) }) ) - .then( - () => undefined, - (error) => { - if (isCodexAppServerRequestError(error)) { - const detail = error.providerDiagnostic - return { outcome: 'failed' as const, ...(detail ? { detail } : {}) } - } - throw error - } - ) - }, - input.onLateResult, - input.turnId - ) + } + } + throw error + } } changeThreadGoal: NonNullable = (input) => diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 02c56b419bd..627e33dbfa9 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -170,6 +170,16 @@ export function codexSessionLifecycle( } } +/** A child that exited while being acquired never becomes the session's. */ +export function assertCodexConnectionOpen( + connection: Pick, + sessionId: string +): void { + if (connection.closed) { + throw new Error(`codex app-server for session ${sessionId} exited while being acquired`) + } +} + export function requireLiveCodexSession( sessions: Map, sessionId: string diff --git a/src/main/codex/codex-subagent-linkage.ts b/src/main/codex/codex-subagent-linkage.ts index 59d57a8e4c0..52f6bdc2611 100644 --- a/src/main/codex/codex-subagent-linkage.ts +++ b/src/main/codex/codex-subagent-linkage.ts @@ -8,15 +8,18 @@ // run are learned from the roster's executions, and a later revision of the // row picks them up when they arrive. -import type { AgentJournalProducerLinkage } from '../../shared/agent-session-journal-types' +import type { + AgentJournalProducerLinkage, + AgentJournalRowAttribution +} from '../../shared/agent-session-journal-types' import type { CodexSubagentExecutions } from './codex-subagent-executions' -/** Linkage for a row one thread produced, within one of that thread's turns - * (null outside any). Every Codex write site resolves through this. */ -export type CodexRowLinkage = ( +/** Who produced a row one thread wrote within one of that thread's turns (null outside any), + * and which turn the row belongs to. Every Codex write site resolves through this. */ +export type CodexRowAttribution = ( threadId: string, turnId: string | null -) => AgentJournalProducerLinkage +) => AgentJournalRowAttribution export class CodexSubagentLinkage { constructor( @@ -26,7 +29,7 @@ export class CodexSubagentLinkage { } ) {} - linkageFor: CodexRowLinkage = (threadId, turnId) => { + linkageFor = (threadId: string, turnId: string | null): AgentJournalProducerLinkage => { const primary = this.deps.primaryThreadId() // An unknown primary means the session's thread is still opening, and no // turn has run that could have spawned a child. diff --git a/src/main/codex/codex-subagent-roster-state.ts b/src/main/codex/codex-subagent-roster-state.ts new file mode 100644 index 00000000000..02c79411b79 --- /dev/null +++ b/src/main/codex/codex-subagent-roster-state.ts @@ -0,0 +1,36 @@ +// One spawn group's roster state, and the ids its journal row is keyed on. + +import type { + AgentJournalItemIdentity, + AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' + +/** The turn a group belongs to when Codex reports activity outside any turn. + * Mirrors the generic-frame bucket name so the two read alike in the journal. */ +export type RosterGroup = { + groupId: string + identity: AgentJournalItemIdentity + /** The spawning turn's scope: the row reports its children beside that turn's work. */ + turnScope: AgentJournalTurnScope + /** Insertion order is the display order; the map holds the state. */ + entries: Map + executionTurns: Map + /** Times each label has been claimed, so a repeat gets an ordinal suffix. */ + labelCounts: Map + /** Last body written, so an idempotent replay writes no new revision. */ + lastSerialized: string | null +} + +/** Group identity: the parent turn that spawned the children. `agentPath` is a + * tree rooted at the parent thread, so every child of one turn shares a row + * no matter which thread's stream carried its activity item. */ +export function codexSubagentGroupId(threadId: string, turnId: string | null): string { + return `${threadId}:${turnId ?? 'outside-turn'}` +} + +/** Durable journal identity for the group's row — stable across revisions and + * across a restart, so replay finds the same row instead of appending a new one. */ +export function codexSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `codex-subagents:${groupId}` } +} diff --git a/src/main/codex/codex-subagent-roster.test.ts b/src/main/codex/codex-subagent-roster.test.ts index 9d3e2c690c4..dcaecb0e3e5 100644 --- a/src/main/codex/codex-subagent-roster.test.ts +++ b/src/main/codex/codex-subagent-roster.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { isAdmissibleAgentJournalItemBody } from '../../shared/agent-session-journal-schemas' import type { @@ -43,6 +44,7 @@ function createHarness(options: { threadId?: string | null } = {}): { tryPublish: () => ({ accepted: true }) } const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink, primaryThreadId: () => (options.threadId === undefined ? THREAD : options.threadId), activeTurn: () => TURN, @@ -138,6 +140,7 @@ function createCoalescingHarness(): { } } const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink, primaryThreadId: () => THREAD, activeTurn: () => TURN, @@ -668,6 +671,7 @@ describe('CodexSubagentRoster', () => { const published: number[] = [] const refusal = { accepted: false, reason: 'backpressure' } as const const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink: { appendItem: () => {}, appendTombstone: () => {}, @@ -721,6 +725,7 @@ describe('CodexSubagentRoster', () => { const appended: Appended[] = [] const published: number[] = [] const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink: { appendItem: () => {}, appendTombstone: () => {}, @@ -765,6 +770,7 @@ describe('CodexSubagentRoster', () => { it('propagates sink backpressure instead of reporting the row as written', () => { const roster = new CodexSubagentRoster({ + turnScopeFor: () => AGENT_JOURNAL_THREAD_SCOPE, sink: { appendItem: () => {}, appendTombstone: () => {}, diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts index 99b37e9c772..db02ac7f454 100644 --- a/src/main/codex/codex-subagent-roster.ts +++ b/src/main/codex/codex-subagent-roster.ts @@ -13,12 +13,9 @@ // thread, and a real turn id is assumed freshly minted per turn. Seeding from // the journal is the fix. -import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import type { AgentJournalTurnScope } from '../../shared/agent-session-journal-types' import { isTerminalSubagentState } from '../../shared/native-chat-subagent-summary' -import type { - NativeChatSubagentEntry, - NativeChatSubagentState -} from '../../shared/native-chat-types' +import type { NativeChatSubagentState } from '../../shared/native-chat-types' import type { StructuredAgentSessionEventSink, StructuredAgentSessionSinkAdmission @@ -40,6 +37,12 @@ import { readCodexTurnId } from './codex-structured-thread-facts' import { codexSubagentGroupBody } from './codex-subagent-group-body' import { CodexSubagentLinkage } from './codex-subagent-linkage' export { codexSubagentGroupBody } from './codex-subagent-group-body' +export { codexSubagentGroupId, codexSubagentGroupIdentity } from './codex-subagent-roster-state' +import { + codexSubagentGroupId, + codexSubagentGroupIdentity, + type RosterGroup +} from './codex-subagent-roster-state' import type { CodexThreadItem } from './codex-structured-item-translation' import { MAX_CODEX_SUBAGENT_GROUPS, @@ -49,38 +52,12 @@ import { const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } -/** The turn a group belongs to when Codex reports activity outside any turn. - * Mirrors the generic-frame bucket name so the two read alike in the journal. */ -type RosterGroup = { - groupId: string - identity: AgentJournalItemIdentity - /** Insertion order is the display order; the map holds the state. */ - entries: Map - executionTurns: Map - /** Times each label has been claimed, so a repeat gets an ordinal suffix. */ - labelCounts: Map - /** Last body written, so an idempotent replay writes no new revision. */ - lastSerialized: string | null -} - -/** Group identity: the parent turn that spawned the children. `agentPath` is a - * tree rooted at the parent thread, so every child of one turn shares a row - * no matter which thread's stream carried its activity item. */ -export function codexSubagentGroupId(threadId: string, turnId: string | null): string { - return `${threadId}:${turnId ?? 'outside-turn'}` -} - -/** Durable journal identity for the group's row — stable across revisions and - * across a restart, so replay finds the same row instead of appending a new one. */ -export function codexSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity { - return { provider: 'orca', clientMessageId: `codex-subagents:${groupId}` } -} - export type CodexSubagentRosterDeps = { sink: StructuredAgentSessionEventSink /** The thread that owns the agent tree; falls back to the event's thread. */ primaryThreadId: () => string | null activeTurn: (threadId: string) => string | null + turnScopeFor: (threadId: string, turnId: string | null) => AgentJournalTurnScope now?: () => number executions?: CodexSubagentExecutions } @@ -280,6 +257,7 @@ export class CodexSubagentRoster { const group: RosterGroup = { groupId, identity: codexSubagentGroupIdentity(groupId), + turnScope: this.deps.turnScopeFor(ownerThreadId, ownerTurnId), entries: new Map(), executionTurns: new Map(), labelCounts: new Map(), @@ -361,7 +339,10 @@ export class CodexSubagentRoster { // The publish must NOT reuse that key: the queue coalesces by key alone, // with no op-kind check, so a publish carrying it would splice out the // still-queued append and the row would never reach the journal. - const options = { coalescingKey: `codex-subagents:${group.groupId}` } + const options = { + coalescingKey: `codex-subagents:${group.groupId}`, + turnScope: group.turnScope + } const admission = this.deps.sink.tryAppendItem ? this.deps.sink.tryAppendItem(group.identity, body, options) : (this.deps.sink.appendItem(group.identity, body, options), ADMITTED) diff --git a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts b/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts index 978f076d6e9..c44390b738e 100644 --- a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A repair drops what it cannot replay, and says so. // // Two things make a suffix unreplayable: a row this build cannot parse, and a @@ -102,9 +103,18 @@ afterEach(async () => { describe('a malformed row', () => { it('keeps the readable prefix live and drops the rest of the epoch', async () => { const journal = await open() - await journal.appendItem(item(0), body('readable'), { fence: 1 }) - await journal.appendItem(item(1), body('unreadable'), { fence: 1 }) - await journal.appendItem(item(2), body('after the fault'), { fence: 1 }) + await journal.appendItem(item(0), body('readable'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(1), body('unreadable'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(2), body('after the fault'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withJournalDatabase((db) => { db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('{"not":"a row"}', 3) @@ -118,8 +128,14 @@ describe('a malformed row', () => { it('discloses the line it could not read', async () => { const journal = await open() - await journal.appendItem(item(0), body('readable'), { fence: 1 }) - await journal.appendItem(item(1), body('later'), { fence: 1 }) + await journal.appendItem(item(0), body('readable'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(1), body('later'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withJournalDatabase((db) => { db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('}{', 2) @@ -141,7 +157,10 @@ describe('a sequence gap', () => { it('drops every row after the hole and reports the epoch corrupt', async () => { const journal = await open() for (let ordinal = 0; ordinal < 5; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`m${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`m${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } await journal.close() // Sequence 1 is the epoch row, so the items occupy 2..6. Removing 4 leaves @@ -163,7 +182,10 @@ describe('a sequence gap', () => { it('still reports corrupt on the next probe, with the deleted suffix unrebuilt', async () => { const journal = await open() for (let ordinal = 0; ordinal < 5; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`m${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`m${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } await journal.close() await withJournalDatabase((db) => { @@ -177,7 +199,10 @@ describe('a sequence gap', () => { // Same policy the emptied-epoch repair takes: a session that writes into the // epoch owns it, and a later import must not replace rows the user has seen. const writable = await open() - await writable.appendItem(item(9), body('typed after the repair'), { fence: 1 }) + await writable.appendItem(item(9), body('typed after the repair'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await writable.close() expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false }) }) @@ -187,7 +212,10 @@ describe('a sequence gap', () => { it('is not settled by the repair disclosure it appends for a malformed row', async () => { const journal = await open() for (let ordinal = 0; ordinal < 3; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`m${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`m${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } await journal.close() await withJournalDatabase((db) => { @@ -207,7 +235,10 @@ describe('a missing epoch row', () => { // renders a repaired journal as a clean timeline. it('rejects the whole surviving range rather than declaring it contiguous', async () => { const journal = await open() - await journal.appendItem(item(0), body('anchor'), { fence: 1 }) + await journal.appendItem(item(0), body('anchor'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.appendSubmission({ clientMessageId: 'client-message-1', payloadFingerprint: 'fingerprint-1', @@ -245,7 +276,10 @@ describe('a missing epoch row', () => { // consulted again and the dropped rows never come back. it('keeps asking for provider history until the epoch has content of its own', async () => { const journal = await open() - await journal.appendItem(item(0), body('anchor'), { fence: 1 }) + await journal.appendItem(item(0), body('anchor'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withJournalDatabase((db) => { db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(1) @@ -258,7 +292,10 @@ describe('a missing epoch row', () => { // A session that writes into the epoch owns it: its own rows are not a // repair placeholder, and a later import must not replace them. const writable = await open() - await writable.appendItem(item(1), body('typed after the repair'), { fence: 1 }) + await writable.appendItem(item(1), body('typed after the repair'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await writable.close() expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts index 6cc267a9054..17fb2b1eb49 100644 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // The crash boundary: the host wrote a submission row, dispatched, and died // before it learned whether the provider took the message. Replay must reconcile // without duplicating the user's message and without losing it. @@ -138,7 +139,10 @@ describe('crash between provider accept and journal commit', () => { }) // The provider's own copy of the message arrives next, under the identity // reconciliation adopted. It must land in the bubble the user already sees. - await restarted.appendItem(outcome.identity, userMessage('deploy the thing'), { fence: 2 }) + await restarted.appendItem(outcome.identity, userMessage('deploy the thing'), { + fence: 2, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const items = restarted.snapshot().items expect(items).toHaveLength(1) diff --git a/src/main/native-chat/agent-session-journal/journal-derived-turn-scope.ts b/src/main/native-chat/agent-session-journal/journal-derived-turn-scope.ts new file mode 100644 index 00000000000..0fbadede3bc --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-derived-turn-scope.ts @@ -0,0 +1,51 @@ +// The turn scope of a row written before rows stated one: the root turn that was open when the +// row was created. Hosts now state a scope on every item they write, so this reads only legacy +// rows, and rows an older host writes into a newer journal after a downgrade. It needs no +// persisted state: the journal is replayed in full on every open, in creation order. +// +// A root turn record created running opens a span, which closes when a revision ends it or +// removes its turn body — a legacy `/compact` row was created as a running turn carrier and then +// overwritten with plain status. A turn record created already settled (rebuilt history) stays +// open until the next root turn record: that is its position within the provider's history. +// Rows created in the window between a crash and the stale-turn sweep land in the dead turn, +// which is where their position puts them too. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' + +export class JournalDerivedTurnScope { + private openTurnItemId: string | null = null + + /** The scope for a row being created now that states none. A turn record belongs to no turn. */ + scopeFor(body: AgentJournalItemBody): AgentJournalTurnScope { + return this.openTurnItemId === null || readAgentJournalTurn(body) + ? AGENT_JOURNAL_THREAD_SCOPE + : { kind: 'turn', turnItemId: this.openTurnItemId } + } + + /** Every change to an item, from any row, after it is applied. `before` is undefined when the + * row created the item, `after` when it removed it. Only the session's own turns open a span. */ + observe( + itemId: string, + root: boolean, + before: AgentJournalItemBody | undefined, + after: AgentJournalItemBody | undefined + ): void { + const turn = readAgentJournalTurn(after) + if (before === undefined && turn && root) { + this.openTurnItemId = itemId + return + } + if ( + itemId === this.openTurnItemId && + readAgentJournalTurn(before)?.state === 'running' && + turn?.state !== 'running' + ) { + this.openTurnItemId = null + } + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index 470ed3dfcb2..2af2623e862 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -7,6 +7,7 @@ import { } from '../../../shared/agent-session-failure' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import type { JournalReducerState } from './journal-reducer' +import { placeHandedOverMessage } from './journal-submission-fold' import type { JournalRow } from './journal-row-schema' export function applyJournalDispatchRow( @@ -39,6 +40,7 @@ export function applyJournalDispatchRow( submission.resolvedAt = row.state === 'pending' ? null : row.ts if (row.state === 'pending') { submission.handedOverAt = row.ts + placeHandedOverMessage(state, submission, row) } if (row.recovered) { submission.recovered = row.recovered diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts index 96273366d5f..7d87399c62b 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Republishing an epoch is ONE transaction. import { mkdtemp, rm } from 'node:fs/promises' @@ -86,8 +87,16 @@ describe('journal epoch replacement', () => { it('discards every superseded row in the same transaction', async () => { const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.appendItem(item(1), { kind: 'status', text: 'old' }, { fence: 1 }) - await journal.appendItem(item(2), { kind: 'status', text: 'older' }, { fence: 1 }) + await journal.appendItem( + item(1), + { kind: 'status', text: 'old' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.appendItem( + item(2), + { kind: 'status', text: 'older' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) const before = journal.epoch await journal.replaceEpochItems('legacy_import', 1, [ diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index 9512d5e9fa1..cc9a2adc1bd 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -7,6 +7,8 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity, + AgentJournalProducerLinkage, + AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type Database from '../../sqlite/sync-database' @@ -22,10 +24,13 @@ import { import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema' import { assertJournalFence } from './journal-write-guards' -export type JournalReplacementItem = { +export type JournalReplacementItem = AgentJournalProducerLinkage & { identity: AgentJournalItemIdentity body: AgentJournalItemBody observedAt?: number + /** Absent for history rebuilt from a source that never stated one: derived from position, as + * a legacy row's is, and then written down. */ + turnScope?: AgentJournalTurnScope } export function replaceJournalEpoch(input: { @@ -56,7 +61,9 @@ export function replaceJournalEpoch(input: { body: item.body, seq: state.lastSequence + 1, fence: input.fence, - ts: item.observedAt ?? input.now() + ts: item.observedAt ?? input.now(), + linkage: item, + turnScope: item.turnScope ?? state.derivedTurnScope.scopeFor(item.body) }) assertJournalFence(row.fence, state.highestFence) applyJournalRow(state, row) diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts index d6424ce7952..34fed89be53 100644 --- a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // An empty chat beside a pre-SQLite journal explains itself. // // The SQLite move shipped no importer, so a session whose history is a @@ -142,7 +143,7 @@ describe('a chat whose history is still in the pre-SQLite format', () => { await journal.appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'history' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() // Deleting the anchor leaves every row unanchored: replay keeps nothing, so diff --git a/src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts b/src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts index 592f7471c5c..7a69de557a4 100644 --- a/src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Every path that can open a SQLite connection releases it. // // Asserting that the happy path closes cleanly proves nothing: these sites are @@ -73,7 +74,11 @@ afterEach(async () => { describe('the standalone probe owns its own connection', () => { it('leaves no handle behind after fifty repeated loads', async () => { const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.appendItem(item(1), { kind: 'message', role: 'user', blocks: [] }, { fence: 1 }) + await journal.appendItem( + item(1), + { kind: 'message', role: 'user', blocks: [] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await journal.close() for (let attempt = 0; attempt < 50; attempt += 1) { @@ -141,7 +146,10 @@ describe('failure paths inside the open call', () => { // connection exists, which is what lets the factory need no `finally`. it('leaves nothing open when a post-connection step of open() throws', async () => { const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.appendItem(item(1), runningTool(), { fence: 1 }) + await journal.appendItem(item(1), runningTool(), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() // Replay runs after the connection is open, so a read it cannot serve diff --git a/src/main/native-chat/agent-session-journal/journal-item-fold.ts b/src/main/native-chat/agent-session-journal/journal-item-fold.ts new file mode 100644 index 00000000000..10aee4a99c4 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-item-fold.ts @@ -0,0 +1,97 @@ +// Folding one item write into the render model: the revision, tombstone and creating-write rules +// the reducer header states. Turn scope is the creating write's too, with one exception, which the +// submission fold owns: a queued message's scope and position are its handover's. + +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + agentJournalLinkageFields, + isRootAgentJournalItem, + namesAgentJournalProducer +} from '../../../shared/agent-session-journal-producer' +import type { JournalReducerState } from './journal-reducer' + +export function statedOrDerivedTurnScope( + state: JournalReducerState, + write: { body: AgentJournalItemBody; turnScope?: AgentJournalTurnScope } +): AgentJournalTurnScope { + return write.turnScope ?? state.derivedTurnScope.scopeFor(write.body) +} + +export function upsertJournalItem( + state: JournalReducerState, + itemId: string, + revision: number, + next: AgentJournalRenderItem, + /** The creating write's fence: the generation a running turn belongs to. */ + fence: number +): void { + const tombstoned = state.tombstones.get(itemId) + if (tombstoned !== undefined && revision <= tombstoned) { + return + } + const existing = state.items.get(itemId) + if (existing && revision <= existing.revision) { + return + } + state.derivedTurnScope.observe(itemId, isRootAgentJournalItem(next), existing?.body, next.body) + if (!existing) { + state.items.set(itemId, next) + state.itemFences.set(itemId, fence) + state.tombstones.delete(itemId) + return + } + // Creation sequence is the ordering key; a revision refreshes content only. + // `observedAt` is pinned with it: clients sort the timeline by that timestamp, + // so letting a revision advance it makes the row jump past everything that + // landed in between — the provider's own echo of a send revises the submission + // row, which relocated the user's bubble below later rows. + const submitted = + existing.body.kind === 'message' && + existing.body.role === 'user' && + parseAgentJournalItemKey(itemId)?.provider === 'orca' + const { sequenceIndex: _revisedAt, ...revised } = next + state.items.set(itemId, { + ...revised, + // Settlements, prompt answers and reopen sweeps revise rows any agent wrote + // without naming one; each would otherwise hand a subagent's row to the session. + ...(namesAgentJournalProducer(next) ? {} : agentJournalLinkageFields(existing)), + // Provider history may normalize text or omit local attachments from the original send. + body: submitted ? existing.body : next.body, + sequence: existing.sequence, + ...(existing.sequenceIndex !== undefined ? { sequenceIndex: existing.sequenceIndex } : {}), + observedAt: existing.observedAt, + turnScope: existing.turnScope ?? next.turnScope + }) + state.tombstones.delete(itemId) +} + +export function removeJournalItem( + state: JournalReducerState, + itemId: string, + revision: number +): void { + const existing = state.items.get(itemId) + if (existing && revision <= existing.revision) { + return + } + const tombstoned = state.tombstones.get(itemId) + if (tombstoned !== undefined && revision <= tombstoned) { + return + } + if (existing) { + state.derivedTurnScope.observe( + itemId, + isRootAgentJournalItem(existing), + existing.body, + undefined + ) + } + state.tombstones.set(itemId, revision) + state.items.delete(itemId) + state.itemFences.delete(itemId) +} diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts index 92b085e2e0f..7751265b72e 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Legacy import runs the existing per-agent transcript decoders and keys the // results by identity read off the same raw lines. Fixtures are shaped like the // files the providers actually write. @@ -567,7 +568,7 @@ describe('import failures', () => { await journal.appendItem( { provider: 'codex', threadId: CODEX_SESSION, turnId: 'turn-1', ordinal: 1 }, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'kept' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const before = journal.epoch const metadataOnly = await writeFixture('metadata-only.jsonl', [ diff --git a/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts b/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts index 07ae011b790..d31d2569c18 100644 --- a/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -79,7 +80,8 @@ function seeded() { seq, fence: 1, ts: 1_000 + seq, - ...(stamp ? { linkage: stamp } : {}) + ...(stamp ? { linkage: stamp } : {}), + turnScope: AGENT_JOURNAL_THREAD_SCOPE }) ) } @@ -90,7 +92,12 @@ function seeded() { journalLifecycleBatchRowBuilder( () => state, `settle-${seq}`, - identities.map((identity) => ({ kind: 'item' as const, identity, body: text('settled') })), + identities.map((identity) => ({ + kind: 'item' as const, + identity, + body: text('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })), { fence: 1 } )(seq, 1_000 + seq) ) @@ -180,15 +187,35 @@ describe('producer inheritance across a reopen', () => { it('replays an inherited producer from disk exactly as it was folded live', async () => { const journal = await open() - await journal.appendItem(child, text('working'), { fence: 1, ...linkage }) - await journal.appendItem(own, text('working'), { fence: 1 }) - await journal.appendItem(child, text('still working'), { fence: 1 }) + await journal.appendItem(child, text('working'), { + fence: 1, + ...linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(own, text('working'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(child, text('still working'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.appendLifecycleBatch({ settlementId: 'settle-1', fence: 1, mutations: [ - { kind: 'item', identity: child, body: text('settled') }, - { kind: 'item', identity: own, body: text('settled') } + { + kind: 'item', + identity: child, + body: text('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, + { + kind: 'item', + identity: own, + body: text('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ] }) const live = journal.snapshot().items diff --git a/src/main/native-chat/agent-session-journal/journal-reducer-producer-linkage.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer-producer-linkage.test.ts new file mode 100644 index 00000000000..6c3640c15cb --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-reducer-producer-linkage.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' +import { applyJournalRow, createJournalReducerState, renderJournalState } from './journal-reducer' +import { buildJournalItemRow, journalLifecycleBatchRowBuilder } from './journal-row-builders' + +const EPOCH = 'epoch-1' + +function text(value: string): AgentJournalMessageItem { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] } +} + +function base(seq: number): { v: number; epoch: string; seq: number; fence: number; ts: number } { + return { v: 1, epoch: EPOCH, seq, fence: 1, ts: 1_000 + seq } +} + +describe('producer linkage round-trips through the reducer', () => { + const identity: AgentJournalItemIdentity = { + provider: 'claude', + sessionId: 'claude-session', + uuid: 'child-1' + } + const linkage = { + agentId: 'task-1', + parentAgentId: 'task-parent', + providerParentRef: 'toolu_1', + producerKind: 'agent' as const, + attempt: 2 + } + + it('copies the whole bundle onto the render item on the plain item path', () => { + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('looking'), + seq: 1, + fence: 1, + ts: 1_001, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + expect(renderJournalState(state).items[0]).toMatchObject(linkage) + }) + + it('copies it on the lifecycle-batch path too, which is a separate upsert', () => { + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow(state, { + kind: 'lifecycle-batch', + settlementId: 'settle-1', + mutations: [{ kind: 'item', itemId: 'i-child', revision: 1, body: text('looking') }], + ...base(1), + ...linkage + }) + expect(renderJournalState(state).items[0]).toMatchObject(linkage) + }) + + it('reads each mutation of a mixed batch as its own producer', () => { + // A batch can CREATE rows several agents produced — a settlement landing + // before any checkpoint did. The mutation that names a producer is that + // producer's; the one naming none is the session's own, beside it. + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow( + state, + journalLifecycleBatchRowBuilder( + () => state, + 'settle-mixed', + [ + { + kind: 'item', + identity, + body: text('child'), + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, + { + kind: 'item', + identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'own-1' }, + body: text('own'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ], + { fence: 1 } + )(1, 1_001) + ) + + const [child, own] = renderJournalState(state).items + expect(child).toMatchObject({ body: text('child'), ...linkage }) + expect(own?.body).toEqual(text('own')) + expect(own && 'agentId' in own).toBe(false) + }) + + it('lets a correction win over the provisional row, without moving the bubble', () => { + // Write-through then correct: the row is written under the spawn call's own + // id, then re-appended under the canonical one. Revision is assigned inside + // the journal's serialized write step, so the later append always outranks + // — and `sequence`/`observedAt` stay pinned, so re-attributing a row does + // not relocate it in the timeline. + const state = createJournalReducerState('session-1', EPOCH) + const provisional = { agentId: 'toolu_1', providerParentRef: 'toolu_1' } + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('looking'), + seq: 1, + fence: 1, + ts: 1_001, + linkage: provisional, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('looking'), + seq: 9, + fence: 1, + ts: 9_999, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + + const items = renderJournalState(state).items + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ revision: 2, ...linkage }) + expect(items[0]).toMatchObject({ sequence: 1, observedAt: 1_001 }) + }) + + it('does not let a stale checkpoint undo a correction that already landed', () => { + // A text checkpoint carrying the OLD stamp, submitted after the correction, + // would re-root the row. It cannot: revision is read at write time, so the + // last write wins and the lane resolves linkage fresh on every checkpoint. + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('a'), + seq: 1, + fence: 1, + ts: 1, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body: text('a and more'), + seq: 2, + fence: 1, + ts: 2, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + const items = renderJournalState(state).items + expect(items[0]).toMatchObject({ revision: 2, ...linkage }) + }) + + it('keeps linkage when a later revision rewrites the row', () => { + // The resolved-append path lost the marker once before by rebuilding the + // row without it, so the SECOND write is the one that matters here. + const state = createJournalReducerState('session-1', EPOCH) + for (const [seq, body] of [ + [1, text('look')], + [2, text('looking at the lane')] + ] as const) { + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body, + seq, + fence: 1, + ts: 1_000 + seq, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + } + const items = renderJournalState(state).items + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ revision: 2, ...linkage }) + }) + + it("renders a row that predates linkage as the session's own", () => { + const state = createJournalReducerState('session-1', EPOCH) + applyJournalRow(state, { + kind: 'item', + itemId: 'i-legacy', + revision: 1, + body: text('written before linkage existed'), + ...base(1) + }) + const item = renderJournalState(state).items[0] + expect(item && 'agentId' in item).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts index eea740e754d..45681f32871 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { describe, expect, it } from 'vitest' import { agentJournalItemKey, @@ -17,11 +18,7 @@ import { renderJournalState, type JournalReducerState } from './journal-reducer' -import { - buildJournalItemRow, - buildJournalTombstoneRow, - journalLifecycleBatchRowBuilder -} from './journal-row-builders' +import { buildJournalItemRow, buildJournalTombstoneRow } from './journal-row-builders' import type { JournalRow } from './journal-row-schema' const EPOCH = 'epoch-1' @@ -657,7 +654,15 @@ describe('re-adding a tombstoned row', () => { const state = createJournalReducerState('session-1', EPOCH) applyJournalRow( state, - buildJournalItemRow({ state, identity, body: text('first'), seq: 1, fence: 1, ts: 1_001 }) + buildJournalItemRow({ + state, + identity, + body: text('first'), + seq: 1, + fence: 1, + ts: 1_001, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) applyJournalRow(state, buildJournalTombstoneRow({ state, itemId, seq: 2, fence: 1, ts: 1_002 })) expect(renderJournalState(state).items).toEqual([]) @@ -666,7 +671,15 @@ describe('re-adding a tombstoned row', () => { // `items` would restart at 1 and lose to the tombstone forever. applyJournalRow( state, - buildJournalItemRow({ state, identity, body: text('second'), seq: 3, fence: 1, ts: 1_003 }) + buildJournalItemRow({ + state, + identity, + body: text('second'), + seq: 3, + fence: 1, + ts: 1_003, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) expect(renderJournalState(state).items.map((item) => item.body)).toEqual([text('second')]) }) @@ -682,12 +695,28 @@ describe('re-adding a tombstoned row', () => { const state = createJournalReducerState('session-1', EPOCH) applyJournalRow( state, - buildJournalItemRow({ state, identity, body: text('first'), seq: 1, fence: 1, ts: 1_001 }) + buildJournalItemRow({ + state, + identity, + body: text('first'), + seq: 1, + fence: 1, + ts: 1_001, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) applyJournalRow(state, buildJournalTombstoneRow({ state, itemId, seq: 2, fence: 1, ts: 1_002 })) applyJournalRow( state, - buildJournalItemRow({ state, identity, body: text('second'), seq: 3, fence: 1, ts: 1_003 }) + buildJournalItemRow({ + state, + identity, + body: text('second'), + seq: 3, + fence: 1, + ts: 1_003, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) expect(state.tombstones.get(itemId)).toBeUndefined() @@ -695,170 +724,3 @@ describe('re-adding a tombstoned row', () => { expect(renderJournalState(state).items).toEqual([]) }) }) - -describe('producer linkage round-trips through the reducer', () => { - const identity: AgentJournalItemIdentity = { - provider: 'claude', - sessionId: 'claude-session', - uuid: 'child-1' - } - const linkage = { - agentId: 'task-1', - parentAgentId: 'task-parent', - providerParentRef: 'toolu_1', - producerKind: 'agent' as const, - attempt: 2 - } - - it('copies the whole bundle onto the render item on the plain item path', () => { - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow( - state, - buildJournalItemRow({ - state, - identity, - body: text('looking'), - seq: 1, - fence: 1, - ts: 1_001, - linkage - }) - ) - expect(renderJournalState(state).items[0]).toMatchObject(linkage) - }) - - it('copies it on the lifecycle-batch path too, which is a separate upsert', () => { - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow(state, { - kind: 'lifecycle-batch', - settlementId: 'settle-1', - mutations: [{ kind: 'item', itemId: 'i-child', revision: 1, body: text('looking') }], - ...base(1), - ...linkage - }) - expect(renderJournalState(state).items[0]).toMatchObject(linkage) - }) - - it('reads each mutation of a mixed batch as its own producer', () => { - // A batch can CREATE rows several agents produced — a settlement landing - // before any checkpoint did. The mutation that names a producer is that - // producer's; the one naming none is the session's own, beside it. - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow( - state, - journalLifecycleBatchRowBuilder( - () => state, - 'settle-mixed', - [ - { kind: 'item', identity, body: text('child'), linkage }, - { - kind: 'item', - identity: { provider: 'claude', sessionId: 'claude-session', uuid: 'own-1' }, - body: text('own') - } - ], - { fence: 1 } - )(1, 1_001) - ) - - const [child, own] = renderJournalState(state).items - expect(child).toMatchObject({ body: text('child'), ...linkage }) - expect(own?.body).toEqual(text('own')) - expect(own && 'agentId' in own).toBe(false) - }) - - it('lets a correction win over the provisional row, without moving the bubble', () => { - // Write-through then correct: the row is written under the spawn call's own - // id, then re-appended under the canonical one. Revision is assigned inside - // the journal's serialized write step, so the later append always outranks - // — and `sequence`/`observedAt` stay pinned, so re-attributing a row does - // not relocate it in the timeline. - const state = createJournalReducerState('session-1', EPOCH) - const provisional = { agentId: 'toolu_1', providerParentRef: 'toolu_1' } - applyJournalRow( - state, - buildJournalItemRow({ - state, - identity, - body: text('looking'), - seq: 1, - fence: 1, - ts: 1_001, - linkage: provisional - }) - ) - applyJournalRow( - state, - buildJournalItemRow({ - state, - identity, - body: text('looking'), - seq: 9, - fence: 1, - ts: 9_999, - linkage - }) - ) - - const items = renderJournalState(state).items - expect(items).toHaveLength(1) - expect(items[0]).toMatchObject({ revision: 2, ...linkage }) - expect(items[0]).toMatchObject({ sequence: 1, observedAt: 1_001 }) - }) - - it('does not let a stale checkpoint undo a correction that already landed', () => { - // A text checkpoint carrying the OLD stamp, submitted after the correction, - // would re-root the row. It cannot: revision is read at write time, so the - // last write wins and the lane resolves linkage fresh on every checkpoint. - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow( - state, - buildJournalItemRow({ state, identity, body: text('a'), seq: 1, fence: 1, ts: 1, linkage }) - ) - applyJournalRow( - state, - buildJournalItemRow({ - state, - identity, - body: text('a and more'), - seq: 2, - fence: 1, - ts: 2, - linkage - }) - ) - const items = renderJournalState(state).items - expect(items[0]).toMatchObject({ revision: 2, ...linkage }) - }) - - it('keeps linkage when a later revision rewrites the row', () => { - // The resolved-append path lost the marker once before by rebuilding the - // row without it, so the SECOND write is the one that matters here. - const state = createJournalReducerState('session-1', EPOCH) - for (const [seq, body] of [ - [1, text('look')], - [2, text('looking at the lane')] - ] as const) { - applyJournalRow( - state, - buildJournalItemRow({ state, identity, body, seq, fence: 1, ts: 1_000 + seq, linkage }) - ) - } - const items = renderJournalState(state).items - expect(items).toHaveLength(1) - expect(items[0]).toMatchObject({ revision: 2, ...linkage }) - }) - - it("renders a row that predates linkage as the session's own", () => { - const state = createJournalReducerState('session-1', EPOCH) - applyJournalRow(state, { - kind: 'item', - itemId: 'i-legacy', - revision: 1, - body: text('written before linkage existed'), - ...base(1) - }) - const item = renderJournalState(state).items[0] - expect(item && 'agentId' in item).toBe(false) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index d7e22575702..2b83f7665b5 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -5,9 +5,10 @@ // Rules: highest revision wins, a tombstone removes, a late lower revision is // dropped rather than resurrecting stale content, and ordering is by the // position (sequence, then place in the row) of the write that CREATED an item -// (a later revision updates the body, it does not move the bubble). Producer -// linkage is likewise the creating write's: a revision naming no producer keeps -// it, one naming any replaces it. +// (a later revision updates the body, it does not move the bubble) — except a +// queued message, which sits where its handover put it. Producer linkage is +// likewise the creating write's: a revision naming no producer keeps it, one +// naming any replaces it. import type { AgentJournalAcceptanceReceipt, @@ -21,13 +22,12 @@ import { agentJournalSubmissionKey, parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { - agentJournalLinkageFields, - namesAgentJournalProducer -} from '../../../shared/agent-session-journal-producer' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { JournalDerivedTurnScope } from './journal-derived-turn-scope' +import { removeJournalItem, statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' import { journalItemRevisionIsStale } from './journal-item-revision' import type { JournalRow } from './journal-row-schema' +import { acceptSubmissionFromProviderItem, applyJournalSubmission } from './journal-submission-fold' import { applyJournalDispatchRow } from './journal-dispatch-reducer' import { isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection' @@ -52,6 +52,8 @@ export type JournalReducerState = { * echo from appending a second copy of the user's own message. */ aliases: Map appliedSettlementIds: Set + /** Scope for rows stored without one; rebuilt by replay, never persisted. */ + derivedTurnScope: JournalDerivedTurnScope } export function createJournalReducerState(sessionId: string, epoch: string): JournalReducerState { @@ -68,7 +70,8 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou submissions: new Map(), receipts: new Map(), aliases: new Map(), - appliedSettlementIds: new Set() + appliedSettlementIds: new Set(), + derivedTurnScope: new JournalDerivedTurnScope() } } @@ -85,17 +88,17 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo } const itemId = resolveJournalItemId(state, row.itemId, row.body) acceptSubmissionFromProviderItem(state, row.itemId, itemId, row) - upsertItem( + upsertJournalItem( state, itemId, row.revision, - journalRenderItem(itemId, row.revision, row.body, row), + journalRenderItem(itemId, row.revision, row.body, row, statedOrDerivedTurnScope(state, row)), row.fence ) return } if (row.kind === 'tombstone') { - removeItem(state, resolveItemId(state, row.itemId), row.revision) + removeJournalItem(state, resolveItemId(state, row.itemId), row.revision) return } if (row.kind === 'lifecycle-batch') { @@ -111,17 +114,18 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo const itemId = resolveJournalItemId(state, mutation.itemId, body) acceptSubmissionFromProviderItem(state, mutation.itemId, itemId, row) const producer = journalBatchMutationProducer(row, mutation) - const item = journalRenderItem(itemId, revision, body, row, producer, sequenceIndex) - upsertItem(state, itemId, revision, item, row.fence) + const scope = statedOrDerivedTurnScope(state, mutation) + const item = journalRenderItem(itemId, revision, body, row, scope, producer, sequenceIndex) + upsertJournalItem(state, itemId, revision, item, row.fence) } else { - removeItem(state, resolveItemId(state, mutation.itemId), mutation.revision) + removeJournalItem(state, resolveItemId(state, mutation.itemId), mutation.revision) } } rememberAppliedSettlementId(state, row.settlementId) return } if (row.kind === 'submission') { - applySubmission(state, row) + applyJournalSubmission(state, row) return } applyJournalDispatchRow(state, row) @@ -192,117 +196,6 @@ function resolveItemId(state: JournalReducerState, itemId: string): string { return state.aliases.get(itemId) ?? itemId } -function upsertItem( - state: JournalReducerState, - itemId: string, - revision: number, - next: AgentJournalRenderItem, - fence: number -): void { - const tombstoned = state.tombstones.get(itemId) - if (tombstoned !== undefined && revision <= tombstoned) { - return - } - const existing = state.items.get(itemId) - if (existing && revision <= existing.revision) { - return - } - if (!existing) { - state.items.set(itemId, next) - state.itemFences.set(itemId, fence) - state.tombstones.delete(itemId) - return - } - // Creation sequence is the ordering key; a revision refreshes content only. - // `observedAt` is pinned with it: clients sort the timeline by that timestamp, - // so letting a revision advance it makes the row jump past everything that - // landed in between — the provider's own echo of a send revises the submission - // row, which relocated the user's bubble below later rows. - const submitted = - existing.body.kind === 'message' && - existing.body.role === 'user' && - parseAgentJournalItemKey(itemId)?.provider === 'orca' - const { sequenceIndex: _revisedAt, ...revised } = next - state.items.set(itemId, { - ...revised, - // Settlements, prompt answers and reopen sweeps revise rows any agent wrote - // without naming one; each would otherwise hand a subagent's row to the session. - ...(namesAgentJournalProducer(next) ? {} : agentJournalLinkageFields(existing)), - // Provider history may normalize text or omit local attachments from the original send. - body: submitted ? existing.body : next.body, - sequence: existing.sequence, - ...(existing.sequenceIndex !== undefined ? { sequenceIndex: existing.sequenceIndex } : {}), - observedAt: existing.observedAt - }) - state.tombstones.delete(itemId) -} - -function removeItem(state: JournalReducerState, itemId: string, revision: number): void { - const existing = state.items.get(itemId) - if (existing && revision <= existing.revision) { - return - } - const tombstoned = state.tombstones.get(itemId) - if (tombstoned !== undefined && revision <= tombstoned) { - return - } - state.tombstones.set(itemId, revision) - state.items.delete(itemId) - state.itemFences.delete(itemId) -} - -function applySubmission( - state: JournalReducerState, - row: Extract -): void { - state.submissions.set(row.clientMessageId, { - clientMessageId: row.clientMessageId, - fence: row.fence, - payloadFingerprint: row.payloadFingerprint, - dispatchState: 'pending', - providerItemId: null, - reason: null, - submittedAt: row.ts, - resolvedAt: null, - ...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {}) - }) - const itemId = agentJournalSubmissionKey(row.clientMessageId) - upsertItem(state, itemId, 0, journalRenderItem(itemId, 0, row.body, row), row.fence) -} - -function acceptSubmissionFromProviderItem( - state: JournalReducerState, - providerItemId: string, - resolvedItemId: string, - row: Pick -): void { - if (providerItemId === resolvedItemId) { - return - } - const submission = [...state.submissions.values()].find( - (candidate) => agentJournalSubmissionKey(candidate.clientMessageId) === resolvedItemId - ) - if ( - !submission || - submission.dispatchState === 'accepted' || - submission.dispatchState === 'rejected' - ) { - return - } - submission.fence = row.fence - submission.dispatchState = 'accepted' - submission.providerItemId = providerItemId - submission.reason = null - submission.resolvedAt = row.ts - delete submission.recovered - state.receipts.set(submission.clientMessageId, { - clientMessageId: submission.clientMessageId, - providerItemId, - cursor: { epoch: row.epoch, sequence: row.seq }, - acceptedAt: row.ts - }) -} - /** Project the folded state into the client-facing snapshot. */ export function renderJournalState(state: JournalReducerState): AgentJournalSnapshot { // The journal position is the sole ordering key; map insertion order is not, diff --git a/src/main/native-chat/agent-session-journal/journal-render-item.ts b/src/main/native-chat/agent-session-journal/journal-render-item.ts index 516086eed3d..fd58b2c25e4 100644 --- a/src/main/native-chat/agent-session-journal/journal-render-item.ts +++ b/src/main/native-chat/agent-session-journal/journal-render-item.ts @@ -1,7 +1,8 @@ import type { AgentJournalItemBody, AgentJournalProducerLinkage, - AgentJournalRenderItem + AgentJournalRenderItem, + AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import { agentJournalLinkageFields, @@ -19,6 +20,7 @@ export function journalRenderItem( revision: number, body: AgentJournalItemBody, row: JournalRow, + turnScope: AgentJournalTurnScope, producer: AgentJournalProducerLinkage = row, /** Which of the row's writes this is; only a lifecycle batch has more than one. */ sequenceIndex = 0 @@ -32,6 +34,7 @@ export function journalRenderItem( observedAt: row.ts, ...(row.recovered ? { recoveredAt: row.ts } : {}), ...(row.recovered ? { recovered: row.recovered } : {}), + turnScope, ...agentJournalLinkageFields(producer) } } diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts index 4428e13953e..aaac2bc1a20 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Wiring the restart reconciler: what provider history is allowed to decide // about a submission the crash boundary could only doubt. @@ -215,7 +216,8 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => { const journal = await open() // An identical message, delivered and committed BEFORE the one that crashed. await journal.appendItem(claudeIdentity('uuid-old'), userMessage('deploy the thing'), { - fence: 1 + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await journal.appendSubmission({ clientMessageId: 'cm_1', diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts index 7b36fc88dc6..f1484bf5da8 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts @@ -35,6 +35,8 @@ function comparableBody(body: AgentJournalMessageItem | undefined): boolean { return ( body?.kind === 'message' && body.role === 'user' && + // A conversation command leaves no user item in provider history to find. + body.command === undefined && body.blocks.length === 1 && body.blocks[0]?.type === 'text' && body.blocks[0].text.trim().length > 0 diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index ee56f28f8a2..9a1869bfe03 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -3,6 +3,8 @@ import type { AgentJournalItemIdentity, AgentJournalMessageItem, AgentJournalProducerLinkage, + AgentJournalRowAttribution, + AgentJournalTurnScope, AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' @@ -33,7 +35,7 @@ export function journalItemRowBuilder( state: () => JournalReducerState, identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: AgentJournalProducerLinkage & { fence: number; observedAt?: number; recovered?: true } + options: AgentJournalRowAttribution & { fence: number; observedAt?: number; recovered?: true } ): RowBuilder { return (seq, ts) => buildJournalItemRow({ @@ -44,7 +46,8 @@ export function journalItemRowBuilder( fence: options.fence, ts: options.observedAt ?? ts, recovered: options.recovered, - linkage: options + linkage: options, + turnScope: options.turnScope }) } @@ -76,7 +79,9 @@ export function journalDispatchRowBuilder( input: ResolveDispatchInput ): RowBuilder { const providerItemId = - input.state === 'accepted' ? agentJournalItemKey(input.providerIdentity) : null + input.state === 'accepted' && input.providerIdentity + ? agentJournalItemKey(input.providerIdentity) + : null // The only dispatch-row builder: its input type is what makes a rejected row carry its fact. return (seq, ts) => ({ kind: 'dispatch', @@ -86,7 +91,8 @@ export function journalDispatchRowBuilder( reason: boundedDispatchReason(input), ...(input.state === 'rejected' ? { rejection: input.rejection } : {}), ...journalRowBase(state().epoch, seq, input.fence, ts), - ...(input.recovered ? { recovered: input.recovered } : {}) + ...(input.recovered ? { recovered: input.recovered } : {}), + ...(input.state === 'pending' ? { turnScope: input.turnScope } : {}) }) } @@ -109,6 +115,8 @@ export type JournalLifecycleMutationInput = /** Who wrote the row. Absent ⇒ the session's own agent on a first write, * and the row's existing producer on a revision. */ linkage?: AgentJournalProducerLinkage + /** Which turn the row belongs to. Kept from the write that creates the row. */ + turnScope: AgentJournalTurnScope } | { kind: 'tombstone'; identity: AgentJournalItemIdentity } @@ -117,13 +125,14 @@ export type JournalLifecycleMutationInput = * settled before any checkpoint landed — and one batch can mix producers. * The session's own rows carry no key at all: absence is the claim. */ export function journalLifecycleItemMutation( - producer: AgentJournalProducerLinkage, + attribution: AgentJournalRowAttribution, identity: AgentJournalItemIdentity, body: AgentJournalItemBody ): JournalLifecycleMutationInput { - return namesAgentJournalProducer(producer) - ? { kind: 'item', identity, body, linkage: agentJournalLinkageFields(producer) } - : { kind: 'item', identity, body } + const { turnScope } = attribution + return namesAgentJournalProducer(attribution) + ? { kind: 'item', identity, body, turnScope, linkage: agentJournalLinkageFields(attribution) } + : { kind: 'item', identity, body, turnScope } } /** The persisted form of one mutation, shared with the partitioner's size probe @@ -139,6 +148,7 @@ export function journalLifecycleMutationRow( itemId, revision, body: mutation.body, + turnScope: mutation.turnScope, ...agentJournalLinkageFields(mutation.linkage) } : { kind: 'tombstone', itemId, revision } @@ -212,6 +222,7 @@ export function buildJournalItemRow(input: { ts: number recovered?: true linkage?: AgentJournalProducerLinkage + turnScope: AgentJournalTurnScope }): JournalItemRow { const itemId = agentJournalItemKey(input.identity) const resolved = input.state.aliases.get(itemId) ?? itemId @@ -229,6 +240,7 @@ export function buildJournalItemRow(input: { body: input.body, ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [input.body]), ...(input.recovered ? { recovered: input.recovered } : {}), + turnScope: input.turnScope, ...agentJournalLinkageFields(input.linkage) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts index fe23a5ddcb5..6ebc20e5a33 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Context facts ride turn rows. Every kind the writer produces must replay, and // one this build cannot read must cost the fact, never the row or the journal. @@ -91,7 +92,10 @@ describe('context facts on replayed turn rows', () => { it('replays every part and kind the writer produces, unchanged', async () => { const journal = await open() for (const [index, facts] of FACTS.entries()) { - await journal.appendItem(row(index), turn(`turn-${index}`, facts), { fence: 1 }) + await journal.appendItem(row(index), turn(`turn-${index}`, facts), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } const written = journal.snapshot().items.map((item) => item.body) await journal.close() @@ -104,11 +108,14 @@ describe('context facts on replayed turn rows', () => { it('keeps a turn row whose facts it cannot read, and everything after it, minus the facts', async () => { const journal = await open() - await journal.appendItem(row(0), turn('turn-0', FACTS[0]), { fence: 1 }) + await journal.appendItem(row(0), turn('turn-0', FACTS[0]), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.appendItem( row(1), { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() const opened = openJournalDatabase(journalDatabaseFile(root)) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts b/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts index b764ebd5ec9..f49dda6dcb6 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -39,12 +40,12 @@ describe('journal row schema versions', () => { await journal.appendItem( identity, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'legacy', agent: 'codex', sessionId: 'session-1', recordId: 'turn-lifecycle:t1' }, agentJournalTurnBody({ turnId: 't1', state: 'running', startedAt: 1_000 }), - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() const opened = openJournalDatabase(journalDatabaseFile(join(root, 'session-1'))) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts index 870b5259357..1d013255b21 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION +} from '../../../shared/agent-session-journal-types' import { MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS, parseJournalRow, @@ -277,7 +280,8 @@ describe('producer linkage on the persisted row', () => { seq: 1, fence: 1, ts: 1_700_000_000_000, - ...(withLinkage ? { linkage } : {}) + ...(withLinkage ? { linkage } : {}), + turnScope: AGENT_JOURNAL_THREAD_SCOPE }) const parsed = parseJournalRow(JSON.stringify(row)) return parsed.ok ? parsed.row : null @@ -348,10 +352,21 @@ describe('producer linkage on the persisted row', () => { it('round-trips a batch mutation that names its own producer, with no version bump', () => { const state = createJournalReducerState('session-1', 'epoch-1') - const own = { kind: 'item' as const, identity: { ...identity, uuid: 'u-own' }, body } + const own = { + kind: 'item' as const, + identity: { ...identity, uuid: 'u-own' }, + body, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } const build = (child: JournalLifecycleMutationInput) => journalLifecycleBatchRowBuilder(() => state, 'settle-1', [child, own], { fence: 1 })(1, 1) - const row = build({ kind: 'item', identity, body, linkage }) + const row = build({ + kind: 'item', + identity, + body, + linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const parsed = parseJournalRow(JSON.stringify(row)) const mutations = parsed.ok && parsed.row.kind === 'lifecycle-batch' ? parsed.row.mutations : [] @@ -359,7 +374,9 @@ describe('producer linkage on the persisted row', () => { expect(mutations[1] && 'agentId' in mutations[1]).toBe(false) // The same batch without the stamp writes the same version: an older host // ignores the unknown keys rather than latching the journal read-only. - expect(row.v).toBe(build({ kind: 'item', identity, body }).v) + expect(row.v).toBe( + build({ kind: 'item', identity, body, turnScope: AGENT_JOURNAL_THREAD_SCOPE }).v + ) }) it('keeps a batch mutation but drops its unusable producer id', () => { diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 786d422876c..0a4c6317ebc 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -12,6 +12,7 @@ import { type AgentJournalItemBody, type AgentJournalMessageItem, type AgentJournalProducerLinkage, + type AgentJournalTurnScope, type AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' import { @@ -37,6 +38,10 @@ type JournalRowBase = AgentJournalProducerLinkage & { ts: number /** Set when crash reconciliation appended the row after the fact. */ recovered?: true + /** Which turn the item this row creates belongs to. Rides the base, and is not a `v` bump, + * for the reason linkage does. Absent on rows from hosts that predate it: the reducer + * derives one for them on read. */ + turnScope?: AgentJournalTurnScope } /** First row of every epoch: binds the epoch to a provider handle and records why it opened. */ @@ -90,6 +95,8 @@ export type JournalDispatchRow = JournalRowBase & { /** Provider item identity adopted on accept. */ providerItemId: string | null reason: string | null + /** On `pending`: the turn the message was handed into, which becomes its row's scope. */ + turnScope?: AgentJournalTurnScope /** On `rejected`: why, typed. Older readers keep the key and ignore it; a malformed one is * dropped when read, never the row. */ rejection?: AgentSessionFailureFact @@ -105,6 +112,7 @@ export type JournalLifecycleMutation = itemId: string revision: number body: AgentJournalItemBody + turnScope?: AgentJournalTurnScope }) | { kind: 'tombstone'; itemId: string; revision: number } @@ -170,10 +178,12 @@ export function parseJournalRow(line: string): JournalRowParse { } const upcast = upcastRow(record, version) dropUnusableProducerLinkage(upcast) + dropUnusableTurnScope(upcast) if (upcast.kind === 'lifecycle-batch' && Array.isArray(upcast.mutations)) { for (const mutation of upcast.mutations) { if (isPlainObject(mutation)) { dropUnusableProducerLinkage(mutation) + dropUnusableTurnScope(mutation) } } } @@ -202,6 +212,24 @@ function dropUnusableProducerLinkage(record: Record): void { } } +/** A scope this build cannot place, removed like unusable linkage: the row then reads as one + * written before scopes existed, and the reducer derives its scope. */ +function dropUnusableTurnScope(record: Record): void { + const scope = record.turnScope + if ( + scope !== undefined && + !( + isPlainObject(scope) && + (scope.kind === 'thread' || + (scope.kind === 'turn' && + typeof scope.turnItemId === 'string' && + scope.turnItemId.length > 0)) + ) + ) { + delete record.turnScope + } +} + /** Context facts this build cannot read, removed from the turn row that carries * them. Same reasoning as linkage: they are an annotation on the turn, and * rejecting the row for them would truncate the journal from that row on. */ diff --git a/src/main/native-chat/agent-session-journal/journal-store-close.test.ts b/src/main/native-chat/agent-session-journal/journal-store-close.test.ts index af53cd16d3d..5ab255749c1 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-close.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-close.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // `close()`: enqueue-time admission, and the fulfilled/rejected split. // // The two failures this file exists to prevent: an append enqueued in the same @@ -80,7 +81,10 @@ afterEach(async () => { describe('closed-state admission happens at enqueue', () => { it('completes a write enqueued in the same turn as the close', async () => { const journal = await openJournal() - const append = journal.appendItem(item(1), body('before'), { fence: 1 }) + const append = journal.appendItem(item(1), body('before'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const closed = journal.close() await expect(append).resolves.toBeDefined() @@ -92,7 +96,10 @@ describe('closed-state admission happens at enqueue', () => { it('refuses a write offered while the close is still in flight, without queueing it', async () => { const journal = await openJournal() const closing = journal.close() - const refused = journal.appendItem(item(1), body('during'), { fence: 1 }) + const refused = journal.appendItem(item(1), body('during'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) // The rejection is available before the close step has run: it never joined // the queue, so nothing is ever chained behind a close. @@ -109,7 +116,12 @@ describe('closed-state admission happens at enqueue', () => { (error: unknown) => error ) const refusals = [ - settle(journal.appendItem(item(1), body('after'), { fence: 1 })), + settle( + journal.appendItem(item(1), body('after'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ), settle(journal.appendTombstone(item(3), { fence: 1 })), settle( journal.appendSubmission({ @@ -133,7 +145,14 @@ describe('closed-state admission happens at enqueue', () => { journal.appendLifecycleBatch({ settlementId: 'settle', fence: 1, - mutations: [{ kind: 'item', identity: item(4), body: body('x') }] + mutations: [ + { + kind: 'item', + identity: item(4), + body: body('x'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] }) ), settle(journal.rollEpoch('handle_forked', 1)), @@ -148,12 +167,20 @@ describe('closed-state admission happens at enqueue', () => { // caller's own turn, so a refusal never advances the queue. it('rejects without waiting for the queue to advance', async () => { const journal = await openJournal() - const inFlight = journal.appendItem(item(1), body('admitted'), { fence: 1 }) + const inFlight = journal.appendItem(item(1), body('admitted'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const closing = journal.close() // Settles while the admitted append is still running: it reached the gate in // the caller's own turn and never joined the queue behind it. - await expect(journal.appendItem(item(2), body('later'), { fence: 1 })).rejects.toMatchObject({ + await expect( + journal.appendItem(item(2), body('later'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).rejects.toMatchObject({ code: 'journal_closed' }) await expect(inFlight).resolves.toBeDefined() @@ -175,7 +202,10 @@ describe('closed-state admission happens at enqueue', () => { describe('a rejected close is a real retry', () => { it('retries the release, releases the handle, and then goes terminal', async () => { const journal = await openJournal() - await journal.appendItem(item(1), body('durable'), { fence: 1 }) + await journal.appendItem(item(1), body('durable'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const injected = injectReleaseFailure(journal) await expect(journal.close()).rejects.toThrow('injected release failure') @@ -183,7 +213,12 @@ describe('a rejected close is a real retry', () => { // Write-closed anyway: retry exists to release the OS handle, never to // resurrect the store. - await expect(journal.appendItem(item(2), body('after'), { fence: 1 })).rejects.toMatchObject({ + await expect( + journal.appendItem(item(2), body('after'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).rejects.toMatchObject({ code: 'journal_closed' }) diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index c2210efb253..8785be0dc8c 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -4,8 +4,9 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity, AgentJournalMessageItem, - AgentJournalProducerLinkage, AgentJournalResetReason, + AgentJournalRowAttribution, + AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { JournalLoad } from './journal-open' @@ -29,14 +30,18 @@ export type ResolveDispatchInput = { clientMessageId: string fence: number recovered?: true -} & ( - | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } - | { state: 'pending' } - /** `reason` is what released clients print, `rejection` what newer ones read: both from - * `agentSessionFailureWords`, never written by hand. */ - | ({ state: 'rejected' } & AgentJournalDispatchRejection) - | { state: 'unknown'; reason?: string | null } -) +} & + /** A null identity: the provider took the message without echoing an item of its own, as a + * conversation command it carries out in place. */ + ( + | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity | null } + /** The turn the message is handed into — the live root turn, or `thread` when none runs. */ + | { state: 'pending'; turnScope: AgentJournalTurnScope } + /** `reason` is what released clients print, `rejection` what newer ones read: both from + * `agentSessionFailureWords`, never written by hand. */ + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason?: string | null } + ) export type JournalAppendResult = { cursor: AgentJournalCursor @@ -44,7 +49,7 @@ export type JournalAppendResult = { revision: number } -export type JournalItemAppendOptions = AgentJournalProducerLinkage & { +export type JournalItemAppendOptions = AgentJournalRowAttribution & { fence: number observedAt?: number recovered?: true diff --git a/src/main/native-chat/agent-session-journal/journal-store-restore.ts b/src/main/native-chat/agent-session-journal/journal-store-restore.ts index fc69dd339d8..1b6f3a260a3 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-restore.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-restore.ts @@ -6,6 +6,7 @@ // same host the collaborators use, so the store keeps the state and this owns // the sequence. +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { JournalEpochController } from './journal-epoch-controller' import { replayJournal } from './journal-open' import type { JournalStoreHost } from './journal-store-collaborators' @@ -39,7 +40,9 @@ export function restoreJournalStore( publishRepairEpoch: () => collaborators.epochController.start('unreconcilable_prefix', host.state().highestFence), adopt: host.adopt, - appendItem: (identity, body, fence) => host.journal().appendItem(identity, body, { fence }), + // Repair and file-format notices are about the conversation, not any turn in it. + appendItem: (identity, body, fence) => + host.journal().appendItem(identity, body, { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), agent: host.identity.agent, highestFence: () => host.state().highestFence, malformedRows: host.malformedRows, diff --git a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts index df34ef73e73..daa2e819647 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Two independent version axes, both fail closed. // // `PRAGMA user_version` the DB SHAPE, known before the first read @@ -86,14 +87,19 @@ afterEach(async () => { describe('axis 1: the database shape', () => { it('latches read-only on a newer user_version and writes nothing', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withDatabase((db) => db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`)) const before = await stat(journalDatabaseFile(root)) const reopened = await open() expect(reopened.isReadOnly).toBe(true) - await expect(reopened.appendItem(item(1), body('b'), { fence: 1 })).rejects.toMatchObject({ + await expect( + reopened.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).rejects.toMatchObject({ code: 'journal_read_only' }) // The file this build must not touch is byte-identical afterwards. @@ -120,7 +126,10 @@ describe('axis 1: the database shape', () => { it('migrates an older user_version forward on reopen', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.close() await withDatabase((db) => db.pragma('user_version = 0')) @@ -137,7 +146,10 @@ describe('axis 1: the database shape', () => { describe('axis 2: the row body shape', () => { it('degrades to read-only on a row from a newer build, without skipping it', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const epoch = journal.epoch const nextSeq = journal.cursor().sequence + 1 await journal.close() @@ -159,7 +171,9 @@ describe('axis 2: the row body shape', () => { const reopened = await open() expect(reopened.isReadOnly).toBe(true) - await expect(reopened.appendItem(item(1), body('b'), { fence: 1 })).rejects.toMatchObject({ + await expect( + reopened.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).rejects.toMatchObject({ code: 'journal_read_only' }) await reopened.close() @@ -174,7 +188,10 @@ describe('axis 2: the row body shape', () => { it('skips a malformed row without giving up the journal, and discloses the skip', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const epoch = journal.epoch const nextSeq = journal.cursor().sequence + 1 await journal.close() @@ -195,7 +212,10 @@ describe('axis 2: the row body shape', () => { it('keeps one disclosure row across reopens instead of stacking duplicates', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const epoch = journal.epoch const nextSeq = journal.cursor().sequence + 1 await journal.close() @@ -214,7 +234,10 @@ describe('axis 2: the row body shape', () => { it('reopens a journal holding an admitted malformed-percent item id without throwing', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const epoch = journal.epoch const nextSeq = journal.cursor().sequence + 1 await journal.close() diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts index 3637f03dab6..67eb19f8cbe 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, readdir, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -76,7 +77,10 @@ describe('sequences', () => { const journal = await open() const results = await Promise.all( Array.from({ length: 25 }, (_unused, index) => - journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + journal.appendItem(item(index), body(`m${index}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ) ) const sequences = results.map((result) => result.cursor.sequence) @@ -89,9 +93,9 @@ describe('sequences', () => { it('serializes revisions of one item so the last write wins deterministically', async () => { const journal = await open() const results = await Promise.all([ - journal.appendItem(item(0), body('a'), { fence: 1 }), - journal.appendItem(item(0), body('b'), { fence: 1 }), - journal.appendItem(item(0), body('c'), { fence: 1 }) + journal.appendItem(item(0), body('a'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), + journal.appendItem(item(0), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), + journal.appendItem(item(0), body('c'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) ]) expect(results.map((result) => result.revision)).toEqual([1, 2, 3]) expect(journal.snapshot().items).toHaveLength(1) @@ -100,9 +104,18 @@ describe('sequences', () => { it('visits reduced items at their creation sequence without promoting an older revision', async () => { const journal = await open() - await journal.appendItem(item(0), body('first'), { fence: 1 }) - const latest = await journal.appendItem(item(1), body('second'), { fence: 1 }) - await journal.appendItem(item(0), body('first revised'), { fence: 1 }) + await journal.appendItem(item(0), body('first'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const latest = await journal.appendItem(item(1), body('second'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(0), body('first revised'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const visited: { itemId: string; sequence: number }[] = [] journal.visitItems((itemId, sequence) => visited.push({ itemId, sequence })) @@ -131,23 +144,26 @@ describe('sequences', () => { await journal.appendItem( turnItem('turn-1'), { kind: 'turn', turnId: 'turn-1', state: 'running' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - await journal.appendItem(item(0), body('work'), { fence: 1 }) + await journal.appendItem(item(0), body('work'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await bothAgreeOn('turn-1') // The completion is a revision, so it keeps the row's creation sequence rather than moving it. await journal.appendItem( turnItem('turn-1'), { kind: 'turn', turnId: 'turn-1', state: 'completed' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await bothAgreeOn(null) await journal.appendItem( turnItem('turn-2'), { kind: 'turn', turnId: 'turn-2', state: 'running' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await bothAgreeOn('turn-2') }) @@ -165,8 +181,14 @@ describe('sequences', () => { const mimicIdentity = identityFor(digestFormMimic) const journal = await open() - const oversized = await journal.appendItem(oversizedIdentity, body('oversized'), { fence: 1 }) - const mimic = await journal.appendItem(mimicIdentity, body('mimic'), { fence: 1 }) + const oversized = await journal.appendItem(oversizedIdentity, body('oversized'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const mimic = await journal.appendItem(mimicIdentity, body('mimic'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) expect(oversized.itemId).not.toBe(mimic.itemId) expect([oversized.revision, mimic.revision]).toEqual([1, 1]) @@ -187,17 +209,28 @@ describe('sequences', () => { describe('fences', () => { it('rejects an append from a writer behind the journal', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 7 }) - await expect(journal.appendItem(item(1), body('b'), { fence: 6 })).rejects.toBeInstanceOf( - AgentSessionJournalError - ) + await journal.appendItem(item(0), body('a'), { + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await expect( + journal.appendItem(item(1), body('b'), { fence: 6, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).rejects.toBeInstanceOf(AgentSessionJournalError) }) it('keeps accepting appends after a rejected one', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 7 }) - await journal.appendItem(item(1), body('b'), { fence: 6 }).catch(() => undefined) - await journal.appendItem(item(2), body('c'), { fence: 7 }) + await journal.appendItem(item(0), body('a'), { + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal + .appendItem(item(1), body('b'), { fence: 6, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + .catch(() => undefined) + await journal.appendItem(item(2), body('c'), { + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([body('a'), body('c')]) }) }) @@ -205,7 +238,10 @@ describe('fences', () => { describe('replay', () => { it('adopts a caller-provided load without replaying the rows again', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const loaded = await loadJournal(root, IDENTITY.sessionId) expect(loaded).not.toBeNull() await journal.close() @@ -216,9 +252,18 @@ describe('replay', () => { it('reopens to the same render model the live writer held', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) - await journal.appendItem(item(1), body('b'), { fence: 1 }) - await journal.appendItem(item(0), body('a2'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(1), body('b'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(0), body('a2'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.appendTombstone(item(1), { fence: 1 }) const live = journal.snapshot() @@ -228,9 +273,15 @@ describe('replay', () => { it('serves a resume from a cursor and refuses one from a stale epoch', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const cursor = journal.cursor() - await journal.appendItem(item(1), body('b'), { fence: 1 }) + await journal.appendItem(item(1), body('b'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const resumed = journal.readSince(cursor) expect(resumed.ok && resumed.rows).toHaveLength(1) @@ -241,7 +292,10 @@ describe('replay', () => { it('rebuilds from a clean epoch after a rollover', async () => { const journal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await journal.rollEpoch('unreconcilable_prefix', 2) expect(journal.snapshot().items).toHaveLength(0) @@ -253,7 +307,10 @@ describe('replay', () => { it('keeps the intact prefix and drops the rejected suffix', async () => { const journal = await open() for (let index = 0; index < 4; index += 1) { - await journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + await journal.appendItem(item(index), body(`m${index}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } const before = journal.epoch await journal.close() @@ -306,7 +363,14 @@ describe('lifecycle batches', () => { const input = { settlementId: 'concurrent-settlement', fence: 1, - mutations: [{ kind: 'item' as const, identity: item(1), body: body('settled') }] + mutations: [ + { + kind: 'item' as const, + identity: item(1), + body: body('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] } const [first, replay] = await Promise.all([ @@ -325,17 +389,27 @@ describe('lifecycle batches', () => { sessionId: 'session-1', recordId: 'turn-lifecycle:turn-1' } - await journal.appendItem(turn, { kind: 'status', text: 'working' }, { fence: 1 }) + await journal.appendItem( + turn, + { kind: 'status', text: 'working' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) const settled = await journal.appendLifecycleBatch({ settlementId: 'exit:turn-1', fence: 1, mutations: [ - { kind: 'item', identity: item(1), body: body('tool settled') }, + { + kind: 'item', + identity: item(1), + body: body('tool settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, { kind: 'item', identity: { provider: 'orca', clientMessageId: 'exit-status' }, - body: { kind: 'status', text: 'Provider exited' } + body: { kind: 'status', text: 'Provider exited' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE }, { kind: 'tombstone', identity: turn } ] @@ -356,7 +430,14 @@ describe('lifecycle batches', () => { const replay = await reopened.appendLifecycleBatch({ settlementId: 'exit:turn-1', fence: 1, - mutations: [{ kind: 'item', identity: item(9), body: body('must not appear') }] + mutations: [ + { + kind: 'item', + identity: item(9), + body: body('must not appear'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] }) expect(replay).toEqual(beforeReplay) expect( @@ -397,7 +478,10 @@ describe('journal location', () => { describe('on-disk layout', () => { it('keeps the session database and its projection in one directory', async () => { const journal: AgentSessionJournal = await open() - await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) expect(await readdir(root)).toContain('journal.db') await journal.close() await withJournalDatabase(root, (db) => { @@ -436,7 +520,10 @@ describe('what a handle found on disk when it opened', () => { it('names rows an earlier handle wrote, and never a row of a later epoch', async () => { const earlier = await open() - await earlier.appendItem(item(1), body('one'), { fence: 1 }) + await earlier.appendItem(item(1), body('one'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await earlier.appendSubmission(submission('earlier')) await earlier.close() diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 3be3aebbc51..99ec29b34ec 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -11,6 +11,7 @@ import type { AgentJournalSubmission, AgentJournalThreadGoal, AgentJournalTurnLifecycle, + AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' @@ -19,6 +20,7 @@ import type { AgentSessionContextUsage } from '../../../shared/agent-session-con import { latestStructuredAgentContextFacts } from '../../../shared/structured-agent-session-context-usage' import { activeStructuredAgentSessionTurnIdBySequence, + liveStructuredAgentSessionTurnScope, newestStructuredAgentSessionTurnBySequence } from '../../../shared/structured-agent-session-live-turn' import { agentSessionJournalCloseRetries } from './journal-close-retry' @@ -219,6 +221,10 @@ export class AgentSessionJournal { activeTurnId = (): string | null => activeStructuredAgentSessionTurnIdBySequence(this.state.items.values()) + /** Where a row written now belongs: the running turn, or the conversation. */ + liveTurnScope = (): AgentJournalTurnScope => + liveStructuredAgentSessionTurnScope(this.state.items.values()) + /** The newest turn record whatever state it settled in, for readers that need the outcome. */ newestTurn = (): AgentJournalTurnLifecycle | null => newestStructuredAgentSessionTurnBySequence(this.state.items.values()) @@ -273,7 +279,7 @@ export class AgentSessionJournal { appendItem( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: JournalItemAppendOptions = { fence: 0 } + options: JournalItemAppendOptions ): Promise { return this.itemAppender.append(identity, body, options) } diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts index b0dff559b1a..1fea8b9efa5 100644 --- a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -222,7 +223,10 @@ describe('journal reopen after the writing host is gone', () => { { id: 'a', label: 'read_readme', state: 'working', startedAt: 10 }, { id: 'b', label: 'read_package', state: 'working', startedAt: 10 } ]) - await live.appendItem(row.identity, row.body, { fence: 0 }) + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) // Still the writing host: it can see the children, so the row says so. const beforeRestart = live.snapshot().items.at(-1)! @@ -245,7 +249,10 @@ describe('journal reopen after the writing host is gone', () => { it('revises the row in place rather than appending a second one', async () => { const live = await open() const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }]) - await live.appendItem(row.identity, row.body, { fence: 0 }) + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const before = live.snapshot().items.length await live.close() @@ -257,7 +264,10 @@ describe('journal reopen after the writing host is gone', () => { it('settles a persisted working background task to unverifiable', async () => { const live = await open() const row = backgroundTaskRow() - await live.appendItem(row.identity, row.body, { fence: 0 }) + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const beforeRestart = live.snapshot().items.at(-1)! expect(taskOf(beforeRestart.body)).toMatchObject({ state: 'working' }) expect(twinOf(beforeRestart.body)).toBe('Started background command "sleep 20"') @@ -273,7 +283,10 @@ describe('journal reopen after the writing host is gone', () => { it('writes nothing on a second reopen once every child is settled', async () => { const live = await open() const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }]) - await live.appendItem(row.identity, row.body, { fence: 0 }) + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await live.close() const once = await open() diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts new file mode 100644 index 00000000000..fa5283d48e6 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -0,0 +1,97 @@ +// Folding submission rows: the queue entry, its message row, where a handover places it, and the +// provider item an accepted message adopts. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { journalRenderItem } from './journal-render-item' +import { statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' +import type { JournalReducerState } from './journal-reducer' +import type { JournalRow } from './journal-row-schema' + +export function applyJournalSubmission( + state: JournalReducerState, + row: Extract +): void { + state.submissions.set(row.clientMessageId, { + clientMessageId: row.clientMessageId, + fence: row.fence, + payloadFingerprint: row.payloadFingerprint, + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: row.ts, + resolvedAt: null, + ...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {}) + }) + const itemId = agentJournalSubmissionKey(row.clientMessageId) + // A message handed over later belongs to no turn until its handover names one. + const turnScope = row.handoverRecorded + ? AGENT_JOURNAL_THREAD_SCOPE + : statedOrDerivedTurnScope(state, row) + upsertJournalItem( + state, + itemId, + 0, + journalRenderItem(itemId, 0, row.body, row, turnScope), + row.fence + ) +} + +/** A queued message joins the conversation where it was handed over, not where it was accepted: + * what the agent did meanwhile — a command it waited behind, say — happened before it. It joins + * the turn that handover delivered it into — a steer — or none. Rows from hosts that predate the + * stated scope are scoped at the handover, as their creation would have been. */ +export function placeHandedOverMessage( + state: JournalReducerState, + submission: AgentJournalSubmission, + row: Extract +): void { + const itemId = agentJournalSubmissionKey(submission.clientMessageId) + const item = state.items.get(itemId) + if (!submission.handoverRecorded || !item) { + return + } + const { sequenceIndex: _acceptedAt, ...accepted } = item + state.items.set(itemId, { + ...accepted, + sequence: row.seq, + observedAt: row.ts, + turnScope: row.turnScope ?? state.derivedTurnScope.scopeFor(item.body) + }) +} + +export function acceptSubmissionFromProviderItem( + state: JournalReducerState, + providerItemId: string, + resolvedItemId: string, + row: Pick +): void { + if (providerItemId === resolvedItemId) { + return + } + const submission = [...state.submissions.values()].find( + (candidate) => agentJournalSubmissionKey(candidate.clientMessageId) === resolvedItemId + ) + if ( + !submission || + submission.dispatchState === 'accepted' || + submission.dispatchState === 'rejected' + ) { + return + } + submission.fence = row.fence + submission.dispatchState = 'accepted' + submission.providerItemId = providerItemId + submission.reason = null + submission.resolvedAt = row.ts + delete submission.recovered + state.receipts.set(submission.clientMessageId, { + clientMessageId: submission.clientMessageId, + providerItemId, + cursor: { epoch: row.epoch, sequence: row.seq }, + acceptedAt: row.ts + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts b/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts new file mode 100644 index 00000000000..6842c119f73 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from 'vitest' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalItemIdentity, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { projectNativeChatTranscriptMessages } from '../../../shared/native-chat-transcript-projection' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' +import { + applyJournalRow, + createJournalReducerState, + renderJournalState, + type JournalReducerState +} from './journal-reducer' +import { + buildJournalItemRow, + buildJournalSubmissionRow, + journalRowBase +} from './journal-row-builders' +import type { JournalRow } from './journal-row-schema' + +const TURN_1: AgentJournalItemIdentity = { provider: 'orca', clientMessageId: 'turn-1' } +const TURN_2: AgentJournalItemIdentity = { provider: 'orca', clientMessageId: 'turn-2' } +const COMMAND_TURN: AgentJournalItemIdentity = { + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' +} +const inTurn = (identity: AgentJournalItemIdentity): AgentJournalTurnScope => ({ + kind: 'turn', + turnItemId: agentJournalItemKey(identity) +}) + +function row(id: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: id } +} + +function prose(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +function turn(turnId: string, state: 'running' | 'completed'): AgentJournalItemBody { + return { kind: 'turn', turnId, state, startedAt: 1 } +} + +/** Builds rows against a scratch state, so the same list can be folded more than once. */ +function rows() { + const state = createJournalReducerState('session-1', 'epoch-1') + const built: JournalRow[] = [] + let seq = 0 + const push = (next: JournalRow): void => { + built.push(next) + applyJournalRow(state, next) + } + return { + state, + built, + item( + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + scope?: AgentJournalTurnScope + ) { + seq += 1 + const next = buildJournalItemRow({ + state, + identity, + body, + seq, + fence: 1, + ts: 1_000 + seq, + turnScope: scope ?? AGENT_JOURNAL_THREAD_SCOPE + }) + if (!scope) { + // A row from a host that predates stated scopes. + delete next.turnScope + } + push(next) + }, + submission(clientMessageId: string) { + seq += 1 + push( + buildJournalSubmissionRow({ + state, + clientMessageId, + payloadFingerprint: `fp-${clientMessageId}`, + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: clientMessageId }] + }, + seq, + fence: 1, + ts: 1_000 + seq, + handoverRecorded: true + }) + ) + }, + handover(clientMessageId: string, scope?: AgentJournalTurnScope) { + seq += 1 + // Built by hand: a row from an older host carries no scope, which the builder never writes. + push({ + kind: 'dispatch', + clientMessageId, + state: 'pending', + providerItemId: null, + reason: null, + ...journalRowBase(state.epoch, seq, 1, 1_000 + seq), + ...(scope ? { turnScope: scope } : {}) + }) + } + } +} + +function scopeOf(state: JournalReducerState, identity: AgentJournalItemIdentity) { + return state.items.get(agentJournalItemKey(identity))?.turnScope +} + +function submissionScope(state: JournalReducerState, clientMessageId: string) { + return state.items.get(agentJournalSubmissionKey(clientMessageId))?.turnScope +} + +function refold(built: readonly JournalRow[]) { + const state = createJournalReducerState('session-1', 'epoch-1') + for (const next of built) { + applyJournalRow(state, next) + } + return renderJournalState(state) +} + +describe('stated turn scope', () => { + it('keeps the scope of the write that created the row', () => { + const { state, item } = rows() + item(TURN_1, turn('t1', 'running'), AGENT_JOURNAL_THREAD_SCOPE) + item(row('answer'), prose('first'), inTurn(TURN_1)) + // A revision stating another scope only updates the content. + item(row('answer'), prose('second'), AGENT_JOURNAL_THREAD_SCOPE) + expect(scopeOf(state, row('answer'))).toEqual(inTurn(TURN_1)) + }) + + it('places a queued message only when its handover states a turn', () => { + const { state, item, submission, handover } = rows() + item(TURN_1, turn('t1', 'running'), AGENT_JOURNAL_THREAD_SCOPE) + submission('steer') + submission('later') + // Accepted while a turn runs, but not yet delivered into it. + expect(submissionScope(state, 'steer')).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + handover('steer', inTurn(TURN_1)) + handover('later', AGENT_JOURNAL_THREAD_SCOPE) + expect(submissionScope(state, 'steer')).toEqual(inTurn(TURN_1)) + expect(submissionScope(state, 'later')).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + }) + + it('keeps a message held behind a command out of the command turn', () => { + const { state, item, submission, handover } = rows() + submission('cmd-1') + handover('cmd-1', AGENT_JOURNAL_THREAD_SCOPE) + item(COMMAND_TURN, turn('compact:cmd-1', 'running'), AGENT_JOURNAL_THREAD_SCOPE) + submission('held') + expect(submissionScope(state, 'held')).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + item(row('result'), prose('compacted'), inTurn(COMMAND_TURN)) + item(COMMAND_TURN, turn('compact:cmd-1', 'completed'), AGENT_JOURNAL_THREAD_SCOPE) + const drawn = (messages: readonly { id: string }[]) => + messages + .map((message) => message.id) + .filter((id) => id === agentJournalItemKey(row('result')) || id.includes('held')) + const expected = [agentJournalItemKey(row('result')), agentJournalSubmissionKey('held')] + const onPhone = () => { + const { items, submissions } = renderJournalState(state) + return projectStructuredAgentSessionMessages(items, [], submissions) + } + // Still waiting: drawn after everything the agent did, the command's result included. + expect(drawn(onPhone())).toEqual(expected) + handover('held', AGENT_JOURNAL_THREAD_SCOPE) + expect(submissionScope(state, 'held')).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + // Delivered: placed where it was handed over, by sequence for paging and the phone, and by + // timestamp for the desktop transcript. + expect(drawn(onPhone())).toEqual(expected) + expect(drawn(projectNativeChatTranscriptMessages(onPhone()))).toEqual(expected) + }) +}) + +describe('scope derived for rows stored without one', () => { + it('places rows inside a live span and closes it at the terminal revision', () => { + const { state, item } = rows() + item(row('before'), prose('before')) + item(TURN_1, turn('t1', 'running')) + item(row('inside'), prose('inside')) + item(TURN_1, turn('t1', 'completed')) + item(row('after'), prose('after')) + expect(scopeOf(state, row('before'))).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + expect(scopeOf(state, TURN_1)).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + expect(scopeOf(state, row('inside'))).toEqual(inTurn(TURN_1)) + expect(scopeOf(state, row('after'))).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + }) + + it('keeps a turn rebuilt already settled open until the next turn record', () => { + const { state, item } = rows() + item(TURN_1, turn('t1', 'completed')) + item(row('first'), prose('first')) + item(TURN_2, turn('t2', 'completed')) + item(row('second'), prose('second')) + expect(scopeOf(state, row('first'))).toEqual(inTurn(TURN_1)) + expect(scopeOf(state, row('second'))).toEqual(inTurn(TURN_2)) + }) + + it('closes a legacy /compact carrier where its turn body is overwritten, and shows the carrier', () => { + const { state, item } = rows() + const carrier = row('compact:op-1') + item(carrier, { + kind: 'status', + text: 'Compacting conversation…', + turnLifecycle: { turnId: 'compact:op-1', state: 'running' } + }) + item(row('summary'), prose('summary')) + item(carrier, { kind: 'status', text: 'Conversation compacted.' }) + item(row('after'), prose('after')) + expect(scopeOf(state, carrier)).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + expect(scopeOf(state, row('summary'))).toEqual(inTurn(carrier)) + expect(scopeOf(state, row('after'))).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + }) + + it('lands rows written after a crash left a turn running in that turn', () => { + const { state, item } = rows() + item(TURN_1, turn('t1', 'running')) + item(row('crash-window'), prose('written before the sweep')) + expect(scopeOf(state, row('crash-window'))).toEqual(inTurn(TURN_1)) + }) + + it('derives the same scopes on replay as live', () => { + const { state, built, item, submission, handover } = rows() + item(TURN_1, turn('t1', 'running')) + item(row('inside'), prose('inside')) + submission('steer') + handover('steer') + item(TURN_1, turn('t1', 'completed')) + item(row('after'), prose('after')) + expect(submissionScope(state, 'steer')).toEqual(inTurn(TURN_1)) + expect(refold(built)).toEqual(renderJournalState(state)) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts b/src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts index 5a859fb2e81..1ab1089d494 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-empty-delta-retention.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { createCodexStructuredItemStreams } from '../../codex/codex-structured-item-streams' import { createAgentSessionDeltaCoalescer } from './agent-session-delta-coalescer' @@ -9,7 +10,7 @@ describe('empty streamed deltas', () => { sink: { appendItem() {}, appendTombstone() {}, publish() {} }, turnIdFor: () => 'turn', identityFor: () => ({ provider: 'codex', threadId: 'thread', turnId: 'turn', ordinal: 0 }), - linkageFor: () => ({}), + attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }), schedule: () => () => {} }) const append = (delta: string) => diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts index 17bdf710490..f13d41373d7 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -40,7 +41,10 @@ function body(text: string): AgentJournalItemBody { async function appendItems(count: number, text: string): Promise { for (let ordinal = 1; ordinal <= count; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`${text}-${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`${text}-${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } } diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts index cda878f5a01..97ce6ecd216 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts @@ -12,7 +12,10 @@ import type { AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION +} from '../../../shared/agent-session-journal-types' import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../shared/agent-session-wire' import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES, @@ -61,7 +64,10 @@ function body(text: string): AgentJournalItemBody { async function appendItems(count: number): Promise { for (let ordinal = 1; ordinal <= count; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`item-${ordinal}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`item-${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } } @@ -216,7 +222,10 @@ describe('history page byte ceiling', () => { async function appendLargeItems(count: number): Promise { for (let ordinal = 1; ordinal <= count; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } } @@ -300,7 +309,10 @@ describe('history page byte ceiling', () => { }) it('degrades a single over-budget item to a visible truncation marker instead of overflowing', async () => { - await journal.appendItem(item(1), body(`1:${'y'.repeat(3 * 1024 * 1024)}`), { fence: 1 }) + await journal.appendItem(item(1), body(`1:${'y'.repeat(3 * 1024 * 1024)}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const tail = pageOf( readAgentSessionHistory(journal, { sessionId: 'session-1', direction: 'tail', limit: 40 }) @@ -320,14 +332,28 @@ describe('projectJournalBatch', () => { sessionId: 'session-1', recordId: 'turn-lifecycle:turn-1' } - await journal.appendItem(turn, { kind: 'status', text: 'working' }, { fence: 1 }) + await journal.appendItem( + turn, + { kind: 'status', text: 'working' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) const cursor = journal.cursor() await journal.appendLifecycleBatch({ settlementId: 'settlement-1', fence: 1, mutations: [ - { kind: 'item', identity: item(1), body: body('one') }, - { kind: 'item', identity: item(2), body: body('two') }, + { + kind: 'item', + identity: item(1), + body: body('one'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, + { + kind: 'item', + identity: item(2), + body: body('two'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, { kind: 'tombstone', identity: turn } ] }) @@ -371,7 +397,10 @@ describe('projectJournalBatch', () => { it('publishes touched items at their current reduced state, not as a delta', async () => { await appendItems(1) const cursor = journal.cursor() - await journal.appendItem(item(1), body('revised'), { fence: 1 }) + await journal.appendItem(item(1), body('revised'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const since = journal.readSince(cursor) if (!since.ok) { throw new Error(`expected rows, got reset ${since.reset}`) @@ -440,7 +469,7 @@ describe('projectJournalBatch', () => { await journal.appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'root-turn', ordinal: 2 }, message, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const page = readAgentSessionHistory(journal, { @@ -636,7 +665,10 @@ describe('identity bounding at admission', () => { ordinal: 1 } const start = { epoch: journal.epoch, sequence: journal.cursor().sequence } - const appended = await journal.appendItem(oversized, body('bounded'), { fence: 1 }) + const appended = await journal.appendItem(oversized, body('bounded'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) expect(appended.itemId.length).toBeLessThan(2048) expect(appended.itemId).toContain('~orca-oversized~') diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts index 1c60029c7d3..df2330346ae 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Recovery drives the real journal loader against real on-disk damage: a hole // punched in the row sequence, and a row stamped with a schema this host cannot // read — on both version axes, because only one of them is detectable before a @@ -80,7 +81,7 @@ async function seedJournal(count: number): Promise { await journal.appendItem( item(ordinal), { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `item-${ordinal}` }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } const epoch = journal.epoch @@ -290,7 +291,8 @@ describe('openAgentSessionJournalWithRecovery', () => { { kind: 'item', identity: { provider: 'orca', clientMessageId: 'approval-1' }, - body: { kind: 'status', text: 'approved' } + body: { kind: 'status', text: 'approved' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE } ] }) @@ -405,7 +407,7 @@ describe('openAgentSessionJournalWithRecovery', () => { await reopened.journal.appendItem( item(2), { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'typed later' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const epoch = reopened.journal.epoch await reopened.journal.close() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index fb4ee607e38..98a6f7e42dc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A send is accepted, then delivered: the host answers once the message is recorded, and the // session's delivery loop starts a provider child for it and hands it over. Against the real host, // store and journal; each assertion reads what an open chat or the journal's next reader sees. @@ -545,7 +546,12 @@ describe('what an earlier host process left behind', () => { await writeAsEarlierProcess(async (journal, fence) => { await journal.appendSubmission({ ...earlierSubmission('legacy', 'l'), fence }) await journal.appendSubmission({ ...earlierSubmission('handed', 'h', true), fence }) - await journal.resolveDispatch({ clientMessageId: 'handed', state: 'pending', fence }) + await journal.resolveDispatch({ + clientMessageId: 'handed', + state: 'pending', + fence, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) }) await host.flushAllStreamedEvents() await startHost() @@ -819,7 +825,7 @@ describe('a compaction or rewind an earlier child left prepared', () => { await startHost() } - it('settles an interrupted compaction at open, so a send is accepted and delivered (R16)', async () => { + it("ignores an older build's interrupted compaction, so a send is accepted and delivered (R16)", async () => { await leftPrepared((fence) => store.setConversationCommand(SESSION, fence, { command: 'compact', @@ -834,10 +840,8 @@ describe('a compaction or rewind an earlier child left prepared', () => { const id = await accept('after the compaction') await eventually(async () => expect((await submission(id))?.dispatchState).toBe('accepted')) - expect(store.getRecord(SESSION)?.conversationCommand).toMatchObject({ - phase: 'committed', - state: 'unknown' - }) + // Nothing settles the record: it belongs to a child this host no longer runs. + expect(store.getRecord(SESSION)?.conversationCommand).toMatchObject({ phase: 'prepared' }) }) it('completes a rewind the provider already applied at open, so a send is accepted (R16)', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index e4ef8eb85f8..0515f7ed669 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -15,6 +15,7 @@ import type { AgentJournalItemBody, AgentJournalMessageItem, AgentJournalDispatchState, + AgentJournalTurnItem, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' @@ -41,7 +42,6 @@ import type { AgentJournalDispatchRejection } from '../../../shared/agent-sessio import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import type { StructuredSessionCompactionResult } from './structured-session-compaction' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' export class AgentSessionAcquisitionRefusal extends Error { @@ -145,6 +145,23 @@ export function isAgentSessionPreSpawnError(error: unknown): error is AgentSessi return error instanceof Error && error.name === 'AgentSessionPreSpawnError' } +/** A conversation command the host handed to a provider child: the running turn it opened for it, + * which the child's translator ends, and where the command's own result row goes. */ +export type StructuredAgentSessionCommandRun = { + clientMessageId: string + /** What a Stop names. */ + turnId: string + identity: AgentJournalItemIdentity + resultIdentity: AgentJournalItemIdentity + /** The turn record as the host wrote it; the translator's end revises it. */ + running: AgentJournalTurnItem +} + +/** A command the provider took answers it in place and echoes no item of its own. */ +export type AgentSessionCommandAdmission = + | AgentSessionDispatchOutcome + | { state: 'accepted'; providerIdentity: null } + export type AgentSessionDispatchOutcome = /** The provider owns the turn now, under this identity. */ | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } @@ -274,12 +291,14 @@ export type StructuredAgentSessionAdapter = { | { ok: true; items?: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] } | { ok: false; reason: AgentSessionRewindReason } > + /** Sends a conversation compaction and answers with the provider's receipt of it, as a dispatch + * does. The command's turn is the child's journal translator's to end, from the provider's own + * frames; a child that ends first leaves it to the host's settlement of that child. */ compact?(input: { - turnId: string sessionId: string fence: number - onLateResult?: (result: StructuredSessionCompactionResult) => Promise - }): Promise + command: StructuredAgentSessionCommandRun + }): Promise /** Cancels one turn, not the session: a session-wide interrupt would also kill * a turn the client never asked to stop. With no `turnId` the conversation asked to stop * everything it has in flight — a running turn, or a dispatch whose turn has not opened yet. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 4b8e1d87ba3..371b942c2f4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Source validation must finish before a new session claims the provider conversation. import { mkdtemp, rm, writeFile, truncate } from 'node:fs/promises' @@ -162,7 +163,7 @@ describe('adopting a provider conversation on create', () => { await journal.appendItem( { provider: 'legacy', agent: 'codex', sessionId: THREAD, recordId: 'journal-only' }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'not yet in rollout' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index 55128a23c9c..dbb3a63e7b5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // What an open chat receives, asserted at its subscriber rather than in the journal: a fresh // subscribe re-reads the journal and hides a write that never reached the readers already open. @@ -176,7 +177,11 @@ describe('an open chat receives every row its journal commits', () => { const identity = { provider: 'orca' as const, clientMessageId: 'context-usage' } const body = { kind: 'status' as const, text: 'context usage answered after the turn' } - expect(providerSink().tryReviseResolvedItem?.(4_096, () => ({ identity, body }))).toEqual({ + expect( + providerSink().tryReviseResolvedItem?.(4_096, () => ({ identity, body }), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).toEqual({ accepted: true }) await host.flushStreamedEvents(SESSION) @@ -194,7 +199,10 @@ describe('an open chat receives every row its journal commits', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'host-note' }, { kind: 'status', text: 'written by a writer that publishes nothing' }, - { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 } + { + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ) expect(pane.received().statuses).toEqual(['written by a writer that publishes nothing']) @@ -213,7 +221,8 @@ describe('an open chat receives each row once', () => { sink.appendItem( { provider: 'orca', clientMessageId: 'streamed' }, - { kind: 'status', text: 'streamed row' } + { kind: 'status', text: 'streamed row' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) sink.publish() await host.flushStreamedEvents(SESSION) @@ -233,7 +242,10 @@ describe('an open chat receives each row once', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'host-row' }, { kind: 'status', text: 'host row' }, - { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0 } + { + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ) host['subscribers'].publish(SESSION, journal) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 40804bf55e2..6fc715022aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -1,6 +1,5 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { recoverStructuredRewind } from './structured-rewind-recovery' -import { recoverInterruptedCompaction } from './structured-compaction-recovery' // The host's attach, lifted out of the host class. // // Attach is the one operation that touches every collaborator the host owns — the lease @@ -210,7 +209,6 @@ async function runAttach( context.deps.adapter, context.now ) - await recoverInterruptedCompaction(context.deps.store, sessionId, attached.journal, fence) if (fenceBefore !== null && fence !== fenceBefore) { context.subscribers.snapshot(sessionId, attached.journal, fence) } else { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts new file mode 100644 index 00000000000..61e3f7ef3ad --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts @@ -0,0 +1,291 @@ +// Stop and `/compact` on the shipping Claude adapter, driven by the frames a stream-json child +// writes: which result ends the command, and what the next message is handed to. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { ClaudeControlRequestError } from '../../claude/claude-agent-sdk-control-requests' +import { ClaudeStructuredSessionAdapter } from '../../claude/claude-structured-session-adapter' +import { + fakeClaude, + PROVIDER_SESSION_ID, + type FakeConnection +} from '../../claude/claude-structured-session-test-support' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' +import { structuredAgentSessionCommandTurn } from './structured-agent-session-command-turn' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let adapter: ClaudeStructuredSessionAdapter +let claude: ReturnType +/** The adapter's clock, which a real child's exit reads at a different instant than the host's. */ +let adapterNow = NOW + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-compact-stop-')) + resetHostTestOperationIds() + adapterNow = NOW + 7 + // No echo of its own: each test writes the frames Claude would. + claude = fakeClaude({ replayUuid: null }) + adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: root, + claudeConfigDir: join(root, 'claude-home'), + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0, + continuesChain: (store.getRecord(SESSION)?.providerHandleChain.length ?? 0) > 0 + }), + onEvent: (event) => { + const mapped = structuredClaudeLifecycleEvent(event) + if (mapped) { + void host.handleAdapterEvent(mapped) + } + }, + // The runtime's own wiring: the provider's answer is what settles a send it took. + onDispatchSettledLate: (settlement) => void host.settleLateDispatch(settlement), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + now: () => adapterNow + }) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + // The production router is what declares create support; the bare adapter only knows locations. + adapter: Object.assign(adapter, { supportsCreate: () => true }), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + const attached = await host.attach( + CALLER, + hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + ) + expect(attached).toMatchObject({ ok: true }) + await adapter.awaitStarted(SESSION) +}) + +afterEach(async () => { + await adapter.closeAll() + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function envelope(method: string, fields: Record) { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function compact(): Promise { + const params = { + command: 'compact' as const, + envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) + } + await expect(host.conversationCommand(CALLER, params)).resolves.toMatchObject({ ok: true }) + return params.envelope.clientOperationId +} + +function stop(cmid: string) { + const { turnId } = structuredAgentSessionCommandTurn(cmid) + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', { turnId }), turnId }) +} + +/** The uuid of the frame that carried `text` to Claude, and the child it went to. */ +async function sent(text: string): Promise<{ connection: FakeConnection; uuid: string }> { + return vi.waitFor(() => { + for (const connection of claude.connections) { + const frame = connection.sent.find((message) => JSON.stringify(message).includes(text)) + if (frame) { + return { connection, uuid: String(frame.uuid) } + } + } + throw new Error(`nothing sent ${text}`) + }) +} + +function frame(connection: FakeConnection, message: Record): void { + connection.handlers.onMessage?.({ session_id: PROVIDER_SESSION_ID, ...message }) +} + +function result(uuid: string, overrides: Record = {}): Record { + return { + type: 'result', + subtype: 'success', + is_error: false, + uuid: `result-${uuid}`, + user_message_uuid: uuid, + ...overrides + } +} + +const INTERRUPTED = { + subtype: 'error_during_execution', + is_error: true, + terminal_reason: 'aborted_streaming' +} + +async function commandState(cmid: string) { + await host.flushStreamedEvents(SESSION) + const { itemId } = structuredAgentSessionCommandTurn(cmid) + const snapshot = await host.journalSnapshot(SESSION) + return readAgentJournalTurn(snapshot.items.find((item) => item.itemId === itemId)?.body) +} + +it('ends a hung /compact Claude will not interrupt by stopping it, and answers the next send in its own turn', async () => { + claude.routes.interrupt = () => { + throw new ClaudeControlRequestError('interrupt', 'Claude did not answer the interrupt.') + } + const cmid = await compact() + const { connection: compacting } = await sent('/compact') + + await expect(stop(cmid)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + await vi.waitFor(async () => expect((await commandState(cmid))?.state).toBe('interrupted')) + + const body = hostTestMessage('what next?') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + const { connection, uuid } = await sent('what next?') + expect(compacting.closed).toBe(true) + expect(connection).not.toBe(compacting) + frame(connection, { + type: 'user', + uuid, + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'text', text: 'what next?' }] } + }) + frame(connection, { + type: 'assistant', + uuid: 'answer', + parent_tool_use_id: null, + message: { id: 'msg-1', role: 'assistant', content: [{ type: 'text', text: 'Here is.' }] } + }) + frame(connection, result(uuid)) + + await vi.waitFor(async () => { + await host.flushStreamedEvents(SESSION) + const snapshot = await host.journalSnapshot(SESSION) + const answer = snapshot.items.find( + (item) => item.body.kind === 'message' && item.body.role === 'assistant' + ) + expect(answer?.turnScope).toMatchObject({ kind: 'turn' }) + expect(answer?.turnScope).not.toEqual({ + kind: 'turn', + turnItemId: structuredAgentSessionCommandTurn(cmid).itemId + }) + }) +}) + +it('ends a stopped /compact on its own interrupted result, and a late copy of that result does not end the next one', async () => { + claude.routes.interrupt = () => ({}) + const first = await compact() + const { connection, uuid: firstUuid } = await sent('/compact') + + await expect(stop(first)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + // The interrupt was taken: the command runs until Claude answers it. + expect((await commandState(first))?.state).toBe('running') + frame(connection, result(firstUuid, INTERRUPTED)) + await vi.waitFor(async () => + expect(await commandState(first)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + ) + + const second = await compact() + await vi.waitFor(() => + expect( + connection.sent.filter((message) => JSON.stringify(message).includes('/compact')) + ).toHaveLength(2) + ) + const secondUuid = String(connection.sent.at(-1)?.uuid) + frame(connection, result(firstUuid, INTERRUPTED)) + frame( + connection, + result('an-earlier-send', { subtype: 'error_during_execution', is_error: true }) + ) + expect((await commandState(second))?.state).toBe('running') + + frame(connection, { type: 'system', subtype: 'compact_boundary', uuid: 'boundary' }) + frame(connection, result(secondUuid)) + await vi.waitFor(async () => + expect(await commandState(second)).toMatchObject({ state: 'completed', outcome: 'success' }) + ) +}) + +it('settles a /compact whose Claude child exits mid-command through that child, and delivers what waited', async () => { + const cmid = await compact() + const { connection: compacting } = await sent('/compact') + const body = hostTestMessage('after the exit') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + + // Claude sends no end frame at all: its child is gone. + compacting.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) + + await vi.waitFor(async () => expect((await commandState(cmid))?.state).toBe('interrupted')) + const { connection } = await sent('after the exit') + expect(connection).not.toBe(compacting) +}) + +it('tells why a /compact ended when its Claude child exits after taking it', async () => { + const cmid = await compact() + const { connection, uuid } = await sent('/compact') + // Claude echoes the command, so the send is answered; then the child dies with no end frame. + frame(connection, { + type: 'user', + uuid, + parent_tool_use_id: null, + message: { role: 'user', content: [{ type: 'text', text: '/compact' }] } + }) + await host.flushStreamedEvents(SESSION) + adapterNow = NOW + 7 + connection.handlers.onExit?.(new Error('claude stream-json exited (code 1)')) + + await vi.waitFor(async () => expect((await commandState(cmid))?.state).toBe('interrupted')) + await vi.waitFor(async () => { + await host.flushStreamedEvents(SESSION) + const snapshot = await host.journalSnapshot(SESSION) + // The exit's words are the host's sentence, never Claude's log text. + const exitRow = snapshot.items.find( + (item) => item.body.kind === 'status' && item.body.failure?.kind === 'providerExited' + ) + expect(exitRow?.body).toMatchObject({ + text: 'Claude stopped while this response was in progress. You can continue in this conversation.', + tone: 'error' + }) + expect(exitRow?.turnScope).toEqual({ + kind: 'turn', + turnItemId: structuredAgentSessionCommandTurn(cmid).itemId + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts index f5410de74f6..74d5355edd5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A close that REJECTED did not release the handle. // // `AgentSessionJournal.close()` is retryable by design: the release step is @@ -192,7 +193,8 @@ describe('the attach orchestration', () => { { kind: 'status', text: 'still writable' - } + }, + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) ).resolves.toBeDefined() expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([]) @@ -229,7 +231,8 @@ describe('the attach orchestration', () => { { kind: 'status', text: 'still writable' - } + }, + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) ).resolves.toBeDefined() expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts new file mode 100644 index 00000000000..993ee3fd258 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-readers.test.ts @@ -0,0 +1,191 @@ +// After `/compact`, every reader that reports on the user's requests reads past the command: the +// sidebar's prompt, preview, verdict and instant, restart resume, and an older client's label. + +import { describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { latestStructuredAgentSessionRequest } from '../../../shared/structured-agent-session-latest-request' +import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection' +import { projectTurnItemHistory } from '../../runtime/rpc/methods/structured-agent-session-turn-item-capability' +import { structuredAgentSessionWorkingAtStop } from './structured-agent-session-working-at-teardown' +import { + journal, + NOW, + record, + SESSION, + TEARDOWN_CURRENT +} from './structured-agent-session-restart-resume-test-harness' + +const PROMPT = agentJournalSubmissionKey('prompt-1') +const COMMAND = agentJournalSubmissionKey('command-1') +const REAL_TURN = 'legacy:codex:session:turn-lifecycle%3Aturn-1' +const COMMAND_TURN = 'orca:command-turn%3Acommand-1' + +let sequence = 0 +function item(itemId: string, body: AgentJournalRenderItem['body'], scope?: string) { + sequence += 1 + return { + itemId, + revision: 1, + body, + sequence, + observedAt: NOW, + turnScope: scope ? { kind: 'turn' as const, turnItemId: scope } : { kind: 'thread' as const } + } +} + +function accepted(clientMessageId: string): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: NOW, + resolvedAt: NOW + } +} + +/** "List three fruits", answered, then `/compact` in its own turn. */ +function compactedAfterARealTurn(commandState: 'running' | 'completed' = 'completed') { + sequence = 0 + const items: AgentJournalRenderItem[] = [ + item(PROMPT, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'List three fruits' }] + }), + item(REAL_TURN, { + kind: 'turn', + turnId: 'turn-1', + state: 'completed', + outcome: 'success', + userItemId: PROMPT, + startedAt: NOW, + completedAt: NOW + 1_000 + }), + item( + 'codex:answer', + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Apple, pear, fig' }] }, + REAL_TURN + ), + item(COMMAND, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + item(COMMAND_TURN, { + kind: 'turn', + turnId: 'compact:command-1', + state: commandState, + userItemId: COMMAND, + startedAt: NOW + 5_000, + ...(commandState === 'completed' ? { outcome: 'success', completedAt: NOW + 9_000 } : {}) + }), + item( + 'codex:summary', + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Summary of it all' }] }, + COMMAND_TURN + ), + item( + 'orca:command-result%3Acommand-1', + { kind: 'status', text: 'Conversation compacted.', presentation: 'compaction' }, + COMMAND_TURN + ) + ] + return { items, submissions: [accepted('prompt-1'), accepted('command-1')] } +} + +describe('the sidebar after /compact (B7)', () => { + it('keeps the last real prompt, its answer, its verdict and its instant', () => { + const { items, submissions } = compactedAfterARealTurn() + expect(projectStructuredAgentSessionStatusSummary(items, submissions)).toEqual({ + status: 'idle', + latestPrompt: 'List three fruits', + lastAssistantMessage: 'Apple, pear, fig', + turnOutcome: 'success', + statusStartedAt: NOW + 1_000 + }) + }) + + it('reads working while the command runs', () => { + const { items, submissions } = compactedAfterARealTurn('running') + expect(projectStructuredAgentSessionStatusSummary(items, submissions)).toMatchObject({ + status: 'working', + latestPrompt: 'List three fruits' + }) + }) +}) + +describe('the completion feed around /compact (B6)', () => { + // The feed announces on a change of latest request; /compact running or settled leaves it be. + it.each(['running', 'completed'] as const)( + 'keeps the last real turn latest while it is %s', + (state) => { + const { items, submissions } = compactedAfterARealTurn(state) + expect(latestStructuredAgentSessionRequest(items, submissions)).toMatchObject({ + kind: 'turn', + id: 'turn-1', + running: false, + outcome: 'success' + }) + } + ) +}) + +describe('restart resume around /compact (B14)', () => { + function markerFor(items: AgentJournalRenderItem[], tasks: boolean) { + return structuredAgentSessionWorkingAtStop({ + sessionId: SESSION, + session: { journal: journal(items), child: { fence: 1 } }, + getRecord: () => record(), + backgroundTasks: () => + tasks ? [{ id: 'task-a', kind: 'agent', description: 'Review', state: 'working' }] : [], + trigger: 'quit', + teardownId: TEARDOWN_CURRENT, + now: NOW + }) + } + + it('records nothing while the command runs', () => { + expect(markerFor(compactedAfterARealTurn('running').items, false)).toBeNull() + }) + + it('anchors a settled lead with live children on its last real turn', () => { + const marker = markerFor(compactedAfterARealTurn().items, true) + expect(marker?.work).toEqual({ kind: 'turn', id: 'turn-1' }) + // The newest user message is still the last real prompt, as before commands were messages. + expect(marker?.latestUserItemId).toBe(PROMPT) + }) +}) + +describe("an older client's label for a command turn (B17)", () => { + it("names the session's agent, not the one its key suggests", () => { + const { items, submissions } = compactedAfterARealTurn() + const history = { + ok: true as const, + page: { + sessionId: SESSION, + epoch: 'epoch-1', + direction: 'tail' as const, + items, + removedItemIds: [], + submissions, + window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 0 } }, + hasOlder: false, + hasNewer: false + } + } + const projected = projectTurnItemHistory(history, { clientKind: 'runtime' }, 'claude') + const commandTurn = projected.page.items.find((entry) => entry.itemId === COMMAND_TURN) + expect(commandTurn?.body).toMatchObject({ + kind: 'status', + text: expect.stringContaining('Claude') + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts new file mode 100644 index 00000000000..cb83dba870a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts @@ -0,0 +1,320 @@ +// A conversation command the user sent, such as `/compact`, carried out as a turn of its own. +// +// The command is an ordinary queued message until the delivery loop hands it over. There the loop +// opens the command's turn and sends it; the provider's receipt resolves the message, as it does +// any send. The provider child's journal translator ends the turn from the provider's own frames, +// and a child that ends first is settled with it. The host writes a command's end only when the +// provider never took it. While the turn runs it takes no input, so the loop hands nothing over. + +import { + agentSessionFailureFact, + type AgentSessionFailureFact, + type SubmissionRejectionFact +} from '../../../shared/agent-session-failure' +import { + agentSessionFailureWords, + type AgentJournalDispatchRejection, + type AgentSessionFailureWordsContext +} from '../../../shared/agent-session-failure-words' +import { + agentJournalItemKey, + agentJournalSubmissionKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalMessageItem, + type AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionConversationCommand } from '../../../shared/agent-session-conversation-command' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { agentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import { + agentJournalTurnBody, + readAgentJournalTurn +} from '../../../shared/agent-session-turn-record' +import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { + AgentSessionCommandAdmission, + StructuredAgentSessionAdapter, + StructuredAgentSessionProviderChildPhase +} from './structured-agent-session-adapter' +import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' +import { conversationCommandBlocked } from './structured-conversation-command-admission' + +export const STRUCTURED_AGENT_SESSION_COMPACT_COMMAND = 'compact' + +/** What the user sent for `/compact`: the text they typed, and the command it names. */ +export function structuredAgentSessionCompactBody(): AgentJournalMessageItem { + return { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND } + } +} + +type AwaitedSubmission = Pick< + AgentJournalSubmission, + 'clientMessageId' | 'dispatchState' | 'acceptedSequence' +> + +/** Optional `submissions` as the dead-generation journal reads it. */ +export type StructuredAgentSessionAwaitedCommandJournal = { + submissions?: () => readonly AwaitedSubmission[] + itemBody: AgentSessionJournal['itemBody'] +} + +/** The command the oldest message still waiting on the provider names: a start that fails now + * fails that message first, so its next step is to run the command again. */ +export function structuredAgentSessionAwaitedCommand( + journal: StructuredAgentSessionAwaitedCommandJournal +): AgentSessionConversationCommand | undefined { + let oldest: AwaitedSubmission | undefined + for (const submission of journal.submissions?.() ?? []) { + if ( + submission.dispatchState === 'pending' && + (oldest === undefined || (submission.acceptedSequence ?? 0) < (oldest.acceptedSequence ?? 0)) + ) { + oldest = submission + } + } + const body = oldest && journal.itemBody(agentJournalSubmissionKey(oldest.clientMessageId)) + return body?.kind === 'message' && body.command?.name === STRUCTURED_AGENT_SESSION_COMPACT_COMMAND + ? STRUCTURED_AGENT_SESSION_COMPACT_COMMAND + : undefined +} + +/** The command's turn: its record and the `turnId` a Stop names. The `compact:` prefix is how the + * host's Stop and delivery gate tell a command's turn from any other. */ +export function structuredAgentSessionCommandTurn(clientMessageId: string): { + identity: AgentJournalItemIdentity + itemId: string + turnId: string + /** The command's one result row, inside its turn. */ + resultIdentity: AgentJournalItemIdentity +} { + const identity = { provider: 'orca' as const, clientMessageId: `command-turn:${clientMessageId}` } + return { + identity, + itemId: agentJournalItemKey(identity), + turnId: `compact:${clientMessageId}`, + resultIdentity: { provider: 'orca', clientMessageId: `command-result:${clientMessageId}` } + } +} + +/** Whether the journal's running turn is a command's, which takes no input while it runs. */ +export function structuredAgentSessionCommandRunning( + journal: Pick +): boolean { + const turnId = journal.activeTurnId() + return turnId !== null && isStructuredAgentSessionCommandTurnId(turnId) +} + +export function isStructuredAgentSessionCommandTurnId(turnId: string): boolean { + return turnId.startsWith('compact:') +} + +const STOP_NOTE_PREFIX = 'stop:' + +/** A Stop's note, on the turn it named. The key says what it is, so a later Stop can read it. */ +export function structuredAgentSessionStopNoteIdentity( + clientOperationId: string +): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `${STOP_NOTE_PREFIX}${clientOperationId}` } +} + +/** Whether an earlier Stop already asked the running command `turnId` names to end. Read from the + * journal, so nothing is held that could outlive the command. */ +export function structuredAgentSessionCommandWasStopped( + journal: Pick, + turnId: string +): boolean { + const { itemId } = structuredAgentSessionCommandTurn(turnId.slice('compact:'.length)) + return journal.snapshot().items.some((item) => { + const identity = parseAgentJournalItemKey(item.itemId) + return ( + item.turnScope?.kind === 'turn' && + item.turnScope.turnItemId === itemId && + identity?.provider === 'orca' && + identity.clientMessageId.startsWith(STOP_NOTE_PREFIX) + ) + }) +} + +export type StructuredAgentSessionCommandHandoverContext = { + sessionId: string + journal: AgentSessionJournal + fence: number + adapter: StructuredAgentSessionAdapter + providerChildPhase?: () => StructuredAgentSessionProviderChildPhase | undefined + /** Who a failure the handover meets names, as the start's own row does. */ + failureTextContext?: AgentSessionFailureWordsContext + record: () => AgentSessionRecord | null + flushStreamedEvents: () => Promise + now: () => number +} + +/** Refuses the command, or opens its turn and sends it. */ +export async function handOverStructuredAgentSessionCommand( + ctx: StructuredAgentSessionCommandHandoverContext, + submission: AgentJournalSubmission, + body: AgentJournalMessageItem +): Promise { + const { clientMessageId } = submission + // Provider frames already received decide whether a turn is running. + await ctx.flushStreamedEvents() + const blocked = commandBlocked(ctx, body) + if (blocked) { + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'rejected', + ...agentSessionFailureWords(blocked, { ...ctx.failureTextContext, surface: 'rejection' }), + fence: ctx.fence + }) + return + } + const turn = structuredAgentSessionCommandTurn(clientMessageId) + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'pending', + fence: ctx.fence, + turnScope: ctx.journal.liveTurnScope() + }) + const startedAt = ctx.now() + const running = agentJournalTurnBody({ + turnId: turn.turnId, + state: 'running', + userItemId: agentJournalSubmissionKey(clientMessageId), + requestedAt: structuredAgentSessionHandoverOrigin(ctx.journal, submission), + startedAt + }) + await ctx.journal.appendItem(turn.identity, running, { + fence: ctx.fence, + observedAt: startedAt, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + let admission: AgentSessionCommandAdmission + try { + admission = await ctx.adapter.compact!({ + sessionId: ctx.sessionId, + fence: ctx.fence, + command: { clientMessageId, ...turn, running } + }) + } catch (error) { + // A child that had not proven its start took nothing, so the command provably did not run. Any + // other throw is a lost reply: the command may have run. + const unsent = + ctx.providerChildPhase?.() === 'starting' + ? { + state: 'rejected' as const, + ...structuredAgentSessionStartFailure({ error }, ctx.failureTextContext) + } + : { + state: 'unknown' as const, + reason: error instanceof Error ? error.message : String(error) + } + await settleUnsentCommand(ctx, clientMessageId, unsent) + return + } + if (admission.state === 'rejected') { + // The provider refused the compaction itself: its row reads as the compaction failing. + await settleUnsentCommand( + ctx, + clientMessageId, + admission, + agentSessionFailureFact('compactionFailed', { detail: admission.rejection.detail }) + ) + } else if (admission.state !== 'admitted') { + // An unknown write leaves the turn to the provider's end or the child's: it may have run. + await ctx.journal.resolveDispatch({ clientMessageId, ...admission, fence: ctx.fence }) + } +} + +/** Where the turn a handed-over submission runs in starts counting: its handover, so time spent + * held behind a command or a start is not counted as the agent's work. */ +export function structuredAgentSessionHandoverOrigin( + journal: AgentSessionJournal, + submission: AgentJournalSubmission +): number { + const handedOver = journal + .submissions() + .find((entry) => entry.clientMessageId === submission.clientMessageId) + return handedOver?.handedOverAt ?? submission.submittedAt +} + +/** The command's end when the provider never took it: refused, or lost with the adapter's throw. + * The message's answer goes first, so a crash before the turn's end leaves a running turn, which + * the stale-turn sweep settles, never an ended turn whose message still reads as in flight. */ +async function settleUnsentCommand( + ctx: StructuredAgentSessionCommandHandoverContext, + clientMessageId: string, + unsent: + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason: string }, + /** What the result row reports, when it is not the rejection's own fact. */ + rowFailure?: AgentSessionFailureFact +): Promise { + const turn = structuredAgentSessionCommandTurn(clientMessageId) + const running = readAgentJournalTurn(ctx.journal.itemBody(turn.itemId) ?? undefined) + await ctx.journal.resolveDispatch({ clientMessageId, ...unsent, fence: ctx.fence }) + if (running?.state !== 'running') { + return + } + const refused = unsent.state === 'rejected' + const mutations: JournalLifecycleMutationInput[] = [ + ...(refused + ? [ + { + kind: 'item' as const, + identity: turn.resultIdentity, + body: { + kind: 'status' as const, + ...agentSessionFailureWords(rowFailure ?? unsent.rejection, { + ...ctx.failureTextContext, + surface: 'row' + }), + tone: 'error' as const + }, + turnScope: { kind: 'turn' as const, turnItemId: turn.itemId } + } + ] + : []), + { + kind: 'item', + identity: turn.identity, + body: agentJournalTurnBody({ + ...running, + ...(refused + ? { state: 'completed', outcome: 'failure', completedAt: ctx.now() } + : { state: 'unverifiable' }) + }), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + await ctx.journal.appendLifecycleBatch({ + settlementId: `command-settled:${clientMessageId}`, + fence: ctx.fence, + mutations + }) +} + +/** Why the command may not run now, as the fact its message is rejected with; null when it may. */ +function commandBlocked( + ctx: StructuredAgentSessionCommandHandoverContext, + body: AgentJournalMessageItem +): SubmissionRejectionFact | null { + if (body.command?.name !== STRUCTURED_AGENT_SESSION_COMPACT_COMMAND || !ctx.adapter.compact) { + return agentSessionFailureFact('commandRefused') + } + const record = ctx.record() + if (!record) { + return agentSessionFailureFact('hostFault') + } + const refusal = conversationCommandBlocked(ctx, record, 'handover') + return refusal + ? agentSessionFailureFact('commandRefused', { refusal: agentSessionRefusalReference(refusal) }) + : null +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts index b294540990d..954525c6429 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -45,7 +46,7 @@ function appendStatus(journal: AgentSessionJournal, text: string) { return journal.appendItem( { provider: 'orca', clientMessageId: text }, { kind: 'status', text }, - { fence: 0 } + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index 0d83491ee21..f91c8baba15 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -11,6 +11,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { completedStructuredAgentTurnSeconds, @@ -132,10 +134,16 @@ async function seedClaudeToolTurn(): Promise { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run the loop' }] }, fence: 13 }) + const turnIdentity = { + provider: 'claude' as const, + sessionId: PROVIDER_SESSION, + uuid: 'uuid-turn' + } + const turnScope = { kind: 'turn' as const, turnItemId: agentJournalItemKey(turnIdentity) } await journal.appendItem( - { provider: 'claude', sessionId: PROVIDER_SESSION, uuid: 'uuid-turn' }, + turnIdentity, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: now }, - { fence: 13 } + { fence: 13, turnScope } ) now = TOOL_STARTED_AT await journal.appendItem( @@ -146,7 +154,7 @@ async function seedClaudeToolTurn(): Promise { input: { command: 'for i in $(seq 90); do sleep 1; done' }, state: 'running' }, - { fence: 13 } + { fence: 13, turnScope } ) await journal.close() } @@ -363,7 +371,8 @@ describe('a turn a read reached before the reconcile proved its owner dead', () // The new child is already working when its start lands, ahead of the queued revision. events?.appendItem( { provider: 'claude', sessionId: PROVIDER_SESSION, uuid: 'uuid-turn-2' }, - { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: RELAUNCHED_AT } + { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: RELAUNCHED_AT }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return { process, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 4a0febb6ceb..5a55bbb26cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -54,7 +55,7 @@ async function seedUnfinishedWork(): Promise { await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, { kind: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'running' }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 2 }, @@ -65,7 +66,7 @@ async function seedUnfinishedWork(): Promise { options: [{ id: 'yes', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 3 }, @@ -75,12 +76,12 @@ async function seedUnfinishedWork(): Promise { options: [{ id: 'web', label: 'Web' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 4 }, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 900 }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } @@ -198,7 +199,8 @@ describe('dead structured-session generation settlement', () => { { kind: 'status', text: "Orca ran into a problem, so this didn't go through. Try again.", - failure: { kind: 'hostFault' } + failure: { kind: 'hostFault' }, + tone: 'error' } ]) }) @@ -349,7 +351,7 @@ describe('dead structured-session generation settlement', () => { await journal.appendItem( childCall, { kind: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'running' }, - { fence: 7, ...child } + { fence: 7, ...child, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( childAsk, @@ -360,7 +362,7 @@ describe('dead structured-session generation settlement', () => { options: [{ id: 'yes', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 7, ...child } + { fence: 7, ...child, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await settleStructuredAgentSessionDeadGeneration({ @@ -420,12 +422,12 @@ describe('whether a dead generation interrupted anything', () => { options: [{ id: 'yes', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 2 }, { kind: 'turn', turnId: 'turn-1', state: 'completed', startedAt: 900, completedAt: 950 }, - { fence: 7 } + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 8ee3430c5c9..72caac40844 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -5,9 +5,10 @@ import { } from '../../../shared/agent-session-failure' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import type { - AgentJournalItemBody, - AgentJournalRenderItem +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' @@ -30,6 +31,10 @@ import { turnVerdictFromDeathEvidence, type StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' +import { + exitedRootTurnScope, + runningRootTurnScope +} from './structured-agent-session-exit-turn-scope' /** Bounds the exit reason the lease keeps as log evidence; a provider diagnostic is held to the * same cap. */ @@ -144,6 +149,7 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { mutations.push(structuredAgentSessionStartFailureRow(startKey, startupFailure)) } } else if (showUnexpectedExitOutcome) { + // The turn the exit ended, and an error so no fold ever hides why it stopped. mutations.push({ kind: 'item', identity: { provider: 'orca', clientMessageId: input.settlementId }, @@ -155,15 +161,22 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { ...input.failureTextContext, surface: 'row' } - ) - } + ), + tone: 'error' + }, + turnScope: exitedRootTurnScope(items, input.verdict) }) } for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) const body = terminalDeadGenerationBody(item) if (identity && body) { - mutations.push({ kind: 'item', identity, body }) + mutations.push({ + kind: 'item', + identity, + body, + turnScope: item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + }) } } mutations.push(...runningTurnLifecycleRevisions(items, input.verdict)) @@ -212,7 +225,12 @@ export async function settleStaleStructuredAgentSessionState(input: { const identity = parseAgentJournalItemKey(item.itemId) const body = terminalDeadGenerationBody(item) if (identity && body) { - mutations.push({ kind: 'item', identity, body }) + mutations.push({ + kind: 'item', + identity, + body, + turnScope: item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + }) } } const proven = provenUnverifiableTurnRevisions(items, input.deathEvidence, journal) @@ -240,8 +258,10 @@ export async function settleStaleStructuredAgentSessionState(input: { ...agentSessionFailureWords(agentSessionFailureFact('providerExited'), { ...input.failureTextContext, surface: 'row' - }) - } + }), + tone: 'error' + }, + turnScope: runningRootTurnScope(items) }) } for (const chunk of partitionJournalLifecycleMutations(settlementId, mutations)) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index 7dd17306105..373e2ae3c36 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -2,13 +2,15 @@ // it, and the one thing that settles a queued message because of a start, a child or a leftover. // // A send is accepted on its own serialized step and returns; this loop does the rest. It exists -// for a session exactly while a message is queued there — accepted, not yet handed over — and -// every step re-reads the journal and the conversation's child record to decide, so there is no -// loop state to disagree with them. Each step is its own serialized task. That is what lets a Stop +// for a session exactly while a message is queued there — accepted, not yet handed over — and no +// child is running a conversation command: a command's turn takes no input, and the commit that +// ends it wakes the loop again. Every step re-reads the journal and the conversation's child +// record to decide, so there is no loop state to disagree with them. Each step is its own serialized task. That is what lets a Stop // that arrives while a start holds the queue withdraw the queued messages before the handover that // would have written them. Stop and the conversation's close are the only other writers of a // queued message: a child's exit only ends the child, and this loop reads why. +import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionFailureFact, type SubmissionRejectionFact @@ -35,6 +37,7 @@ import { } from './structured-agent-session-start-failure-row' import { failedProviderChildStart } from './structured-agent-session-provider-child' import { handOverSubmission } from './structured-agent-session-turns' +import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap @@ -53,6 +56,9 @@ export type StructuredAgentSessionDeliveryLoopDeps = { /** Who the chat's failure sentences name. */ failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext onError: (sessionId: string, error: unknown) => void + record: (sessionId: string) => AgentSessionRecord | null + flushStreamedEvents: (sessionId: string) => Promise + now: () => number } type Step = 'continue' | 'stop' @@ -144,7 +150,9 @@ export class StructuredAgentSessionDeliveryLoop { (submission) => session.journal.wroteBeforeOpen(submission.acceptedSequence) ) const oldest = oldestQueuedSubmission(session) - if (!oldest) { + // A running command takes no input while its child carries it; its end is a commit, which + // wakes the loop again. With no child it is a gone generation's, which the start below settles. + if (!oldest || (session.child && structuredAgentSessionCommandRunning(session.journal))) { return this.stop(sessionId) } const failedStart = startThatFailedWhileQueued(session, oldest) @@ -208,7 +216,10 @@ export class StructuredAgentSessionDeliveryLoop { fence: awaitedChild.fence, adapter: this.deps.adapter, providerChildPhase: () => this.deps.sessions.get(sessionId)?.child?.phase, - failureTextContext: this.deps.failureTextContext(sessionId) + failureTextContext: this.deps.failureTextContext(sessionId), + record: () => this.deps.record(sessionId), + flushStreamedEvents: () => this.deps.flushStreamedEvents(sessionId), + now: this.deps.now }, next ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index b8c1d0b7c0e..46dc8fbf7e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody, @@ -37,7 +38,7 @@ type Recorded = { * it: a double that omits the third parameter makes every assertion about what * the sink forwards pass against `undefined`, which is how this went unnoticed * before. Kept separate so the call-order assertions stay about call order. */ -const journalAppendOptions: JournalItemAppendOptions[] = [] +const journalAppendOptions: Partial[] = [] function target( fence: number, @@ -92,8 +93,8 @@ describe('deferred structured agent-session event sink', () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() - deferred.sink.appendItem(identity(0), BODY) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.sink.publish() expect(log).toEqual([]) @@ -112,10 +113,10 @@ describe('deferred structured agent-session event sink', () => { const deferred = createDeferredStructuredAgentSessionEventSink() deferred.bind(target(1, log)) - deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) // The re-attach that raised the fence. deferred.bind(target(2, log)) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await deferred.drained() expect(log).toEqual([ @@ -130,7 +131,7 @@ describe('deferred structured agent-session event sink', () => { deferred.bind(target(1, log)) deferred.unbind() - deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) expect(log).toEqual([]) deferred.bind(target(2, log)) await deferred.drained() @@ -143,9 +144,15 @@ describe('deferred structured agent-session event sink', () => { const deferred = createDeferredStructuredAgentSessionEventSink() expect( - deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => identity(1)) + deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => identity(1), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) ).toEqual({ accepted: true }) - expect(deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => null)).toEqual({ + expect( + deferred.sink.tryAppendLifecycleTransition?.(identity(0), BODY, () => null, { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).toEqual({ accepted: true }) deferred.bind(target(2, log)) @@ -161,10 +168,10 @@ describe('deferred structured agent-session event sink', () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() - deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.close() deferred.bind(target(3, log)) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await deferred.drained() expect(log).toEqual([]) @@ -180,7 +187,7 @@ describe('deferred structured agent-session event sink', () => { }) deferred.bind(target(4, log, 0)) - deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.sink.appendTombstone(identity(1)) const barrier = await deferred.drained() @@ -202,14 +209,14 @@ describe('deferred structured agent-session event sink', () => { const runtime = new StructuredAgentSessionHostRuntimeState({ store: {} } as never) const failed = runtime.eventSinkFor('session-1') failed.bind(target(1, [], 0)) - failed.sink.appendItem(identity(0), BODY) + failed.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await expect(failed.drained()).resolves.toMatchObject({ ok: false }) const recovered = runtime.eventSinkFor('session-1') expect(recovered).not.toBe(failed) const log: Recorded[] = [] recovered.bind(target(2, log)) - recovered.sink.appendItem(identity(1), BODY) + recovered.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await expect(recovered.drained()).resolves.toEqual({ ok: true }) expect(log).toEqual([{ call: 'appendItem', fence: 2, ordinal: 1 }]) }) @@ -229,9 +236,15 @@ describe('deferred structured agent-session event sink', () => { onBackpressureChange: (paused) => changes.push(paused) }) - expect(deferred.sink.tryAppendItem?.(identity(0), BODY)).toEqual({ accepted: true }) - expect(deferred.sink.tryAppendItem?.(identity(1), BODY)).toEqual({ accepted: true }) - expect(deferred.sink.tryAppendItem?.(identity(2), BODY)).toEqual({ + expect( + deferred.sink.tryAppendItem?.(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(2), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: false, reason: 'backpressure' }) @@ -257,11 +270,17 @@ describe('deferred structured agent-session event sink', () => { } }) - expect(deferred.sink.tryAppendItem?.(identity(0), BODY)).toEqual({ accepted: true }) expect( - deferred.sink.tryAppendResolvedItemAndPublish?.(identity(1), BODY, () => identity(1)) + deferred.sink.tryAppendItem?.(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) ).toEqual({ accepted: true }) - expect(deferred.sink.tryAppendItem?.(identity(2), BODY)).toEqual({ + expect( + deferred.sink.tryAppendResolvedItemAndPublish?.(identity(1), BODY, () => identity(1), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(2), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: false, reason: 'backpressure' }) @@ -292,7 +311,9 @@ describe('deferred structured agent-session event sink', () => { onBackpressureChange: (paused) => changes.push(paused) }) - expect(deferred.sink.tryAppendItem?.(identity(0), BODY)).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: true }) expect(deferred.state()).toMatchObject({ backpressured: true, queuedOperations: 1 }) expect(readingControl.pauseReading).toHaveBeenCalledOnce() @@ -322,7 +343,7 @@ describe('deferred structured agent-session event sink', () => { deferred.sink.appendLifecycleBatch?.( 'settlement-1', - [{ kind: 'item', identity: identity(0), body: BODY }], + [{ kind: 'item', identity: identity(0), body: BODY, turnScope: AGENT_JOURNAL_THREAD_SCOPE }], { lifecycle: true } ) expect(deferred.sink.tryPublish?.({ lifecycle: true })).toEqual({ @@ -354,7 +375,9 @@ describe('deferred structured agent-session event sink', () => { }) const releaseFirst = deferred.sink.bindReadingControl?.(firstControl) - expect(deferred.sink.tryAppendItem?.(identity(0), BODY)).toEqual({ accepted: true }) + expect( + deferred.sink.tryAppendItem?.(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).toEqual({ accepted: true }) expect(firstControl.pauseReading).toHaveBeenCalledOnce() const releaseSecond = deferred.sink.bindReadingControl?.(secondControl) @@ -373,7 +396,7 @@ describe('deferred structured agent-session event sink', () => { it('replaces a queued same-item checkpoint before it runs', async () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() - const options = { coalescingKey: 'checkpoint:item-1' } + const options = { coalescingKey: 'checkpoint:item-1', turnScope: AGENT_JOURNAL_THREAD_SCOPE } deferred.sink.appendItem(identity(0), BODY, options) deferred.sink.appendItem(identity(1), BODY, options) @@ -390,7 +413,12 @@ describe('deferred structured agent-session event sink', () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() const batch = (ordinal: number) => [ - { kind: 'item' as const, identity: identity(ordinal), body: BODY } + { + kind: 'item' as const, + identity: identity(ordinal), + body: BODY, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ] deferred.sink.appendLifecycleBatch?.('turn-completed:turn-1', batch(0)) @@ -413,10 +441,10 @@ describe('deferred structured agent-session event sink', () => { it('keeps a replacement checkpoint after distinct intervening operations', async () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() - const options = { coalescingKey: 'checkpoint:item-1' } + const options = { coalescingKey: 'checkpoint:item-1', turnScope: AGENT_JOURNAL_THREAD_SCOPE } deferred.sink.appendItem(identity(0), BODY, options) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) deferred.sink.appendItem(identity(2), BODY, options) deferred.bind(target(6, log)) await deferred.drained() @@ -460,10 +488,15 @@ describe('producer linkage reaches the journal through every append path', () => const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() deferred.bind(target(5, log)) - deferred.sink[append]?.(identity(1), BODY, { ...LINKAGE }) + deferred.sink[append]?.(identity(1), BODY, { + ...LINKAGE, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await deferred.drained() - expect(journalAppendOptions).toEqual([{ fence: 5, ...LINKAGE }]) + expect(journalAppendOptions).toEqual([ + { fence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...LINKAGE } + ]) deferred.close() } }) @@ -479,10 +512,15 @@ describe('producer linkage reaches the journal through every append path', () => const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() deferred.bind(target(5, log)) - deferred.sink[append]?.(identity(1), BODY, () => identity(1), { ...LINKAGE }) + deferred.sink[append]?.(identity(1), BODY, () => identity(1), { + ...LINKAGE, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await deferred.drained() - expect(journalAppendOptions).toEqual([{ fence: 5, ...LINKAGE }]) + expect(journalAppendOptions).toEqual([ + { fence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...LINKAGE } + ]) deferred.close() } }) @@ -496,7 +534,7 @@ describe('producer linkage reaches the journal through every append path', () => deferred.bind(target(5, log)) deferred.sink.appendLifecycleBatch?.( 'settle-1', - [{ kind: 'item', identity: identity(1), body: BODY }], + [{ kind: 'item', identity: identity(1), body: BODY, turnScope: AGENT_JOURNAL_THREAD_SCOPE }], { ...LINKAGE } ) @@ -511,13 +549,13 @@ describe('producer linkage reaches the journal through every append path', () => const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink() deferred.bind(target(5, log)) - deferred.sink.appendItem(identity(1), BODY) + deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await deferred.drained() // A control, not a pin. Absence is the claim, so the keys must be missing // rather than present-and-undefined: a reader holding this options object // would read `agentId: undefined` as a key that exists. - expect(journalAppendOptions).toEqual([{ fence: 5 }]) + expect(journalAppendOptions).toEqual([{ fence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE }]) deferred.close() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts index ad92e1c24ca..f712c36cc91 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -2,7 +2,8 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item- import type { AgentJournalItemBody, AgentJournalItemIdentity, - AgentJournalProducerLinkage + AgentJournalProducerLinkage, + AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import type { AgentSessionTurnActivity } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -36,6 +37,11 @@ export type StructuredAgentSessionAppendOptions = AgentJournalProducerLinkage & observedAt?: number } +/** An item write states which turn its row belongs to; the write that creates the row decides. */ +export type StructuredAgentSessionItemAppendOptions = StructuredAgentSessionAppendOptions & { + turnScope: AgentJournalTurnScope +} + export type StructuredAgentSessionLifecycleJournal = Pick< AgentSessionJournal, 'epoch' | 'visitItems' @@ -58,7 +64,7 @@ export type StructuredAgentSessionRevisionResolver = ( /** A revision's body is derived from the row it revises, so coalescing one away would lose it. */ export type StructuredAgentSessionRevisionOptions = Omit< - StructuredAgentSessionAppendOptions, + StructuredAgentSessionItemAppendOptions, 'coalescingKey' > @@ -69,7 +75,7 @@ export type StructuredAgentSessionEventSink = { appendItem( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): void appendTombstone( identity: AgentJournalItemIdentity, @@ -84,40 +90,40 @@ export type StructuredAgentSessionEventSink = { tryAppendItem?( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission /** Queues an ordinary append whose identity is resolved after journal bind. */ tryAppendResolvedItem?( identitySizeBound: AgentJournalItemIdentity, body: AgentJournalItemBody, resolveIdentity: StructuredAgentSessionIdentityResolver, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission /** Queues one resolved append and its publication as a single admitted operation. */ tryAppendResolvedItemAndPublish?( identitySizeBound: AgentJournalItemIdentity, body: AgentJournalItemBody, resolveIdentity: StructuredAgentSessionIdentityResolver, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission /** Queues a read-modify-write of one row; `reservedBytes` must bound the resolved write. */ tryReviseResolvedItem?( reservedBytes: number, resolve: StructuredAgentSessionRevisionResolver, - options?: StructuredAgentSessionRevisionOptions + options: StructuredAgentSessionRevisionOptions ): StructuredAgentSessionSinkAdmission /** Queues one revision and its publication as a single admitted operation. */ tryReviseResolvedItemAndPublish?( reservedBytes: number, resolve: StructuredAgentSessionRevisionResolver, - options?: StructuredAgentSessionRevisionOptions + options: StructuredAgentSessionRevisionOptions ): StructuredAgentSessionSinkAdmission /** Queues one journal-derived lifecycle append; a null resolution is a no-op. */ tryAppendLifecycleTransition?( identitySizeBound: AgentJournalItemIdentity, body: AgentJournalItemBody, resolveIdentity: StructuredAgentSessionIdentityResolver, - options?: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): StructuredAgentSessionSinkAdmission /** Current durable epoch, when this deferred sink is bound to its journal. */ journalEpoch?(): string | null @@ -231,7 +237,7 @@ export function createDeferredStructuredAgentSessionEventSink( return { sink: { - appendItem: (identity, body, options = {}) => { + appendItem: (identity, body, options) => { queue.submit( { bytes: estimateStructuredAgentSessionItemBytes(identity, body), @@ -246,7 +252,7 @@ export function createDeferredStructuredAgentSessionEventSink( options ) }, - tryAppendItem: (identity, body, options = {}) => + tryAppendItem: (identity, body, options) => queue.submit( { bytes: estimateStructuredAgentSessionItemBytes(identity, body), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts new file mode 100644 index 00000000000..c336a1d3eca --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-exit-turn-scope.ts @@ -0,0 +1,36 @@ +// Which turn a gone generation's exit row belongs to, so the row reports on the turn it ended. + +import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { StructuredAgentSessionTurnVerdict } from './structured-agent-session-stale-turn-verdict' + +/** The turn the exit ended: still running, or already ended at the exit's instant by the child's + * own translator, which settles its open turn when the child goes. */ +export function exitedRootTurnScope( + items: readonly AgentJournalRenderItem[], + verdict: StructuredAgentSessionTurnVerdict +): AgentJournalTurnScope { + const endedAt = verdict.state === 'interrupted' ? verdict.completedAt : undefined + const ended = items.findLast((item) => { + const turn = isRootAgentJournalItem(item) ? readAgentJournalTurn(item.body) : null + return ( + turn?.state === 'running' || + (turn?.state === 'interrupted' && endedAt !== undefined && turn.completedAt === endedAt) + ) + }) + return ended ? { kind: 'turn', turnItemId: ended.itemId } : AGENT_JOURNAL_THREAD_SCOPE +} + +export function runningRootTurnScope( + items: readonly AgentJournalRenderItem[] +): AgentJournalTurnScope { + const running = items.findLast( + (item) => isRootAgentJournalItem(item) && readAgentJournalTurn(item.body)?.state === 'running' + ) + return running ? { kind: 'turn', turnItemId: running.itemId } : AGENT_JOURNAL_THREAD_SCOPE +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index 90e07ddf88c..8ce8d7d1507 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Removing a session from the host's map and removing its status row are ONE operation. // // The store keeps a row until told to drop it, and `structuredHostOwned` bypasses the staleness @@ -110,12 +111,12 @@ async function workingSession(): Promise<{ await journal.appendItem( PROMPT, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'ship it' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const sessions = new Map([ [ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index 8181110a5a6..72317e8ed81 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -70,30 +71,34 @@ async function seedGroupedQuestion(): Promise<{ itemId: string; revision: number if (!events) { throw new Error('seedGroupedQuestion requires an acquired session') } - events.appendItem(identity, { - kind: 'question', - question: '2 grouped questions from Claude', - options: [], - questions: [ - { - id: 'q1', - question: 'Targets', - multiSelect: true, - options: [ - { id: 'target-web', label: 'Web' }, - { id: 'target-mobile', label: 'Mobile' } - ] - }, - { - id: 'q2', - question: 'Host', - multiSelect: false, - options: [], - freeTextQuestionId: 'q2' - } - ], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }) + events.appendItem( + identity, + { + kind: 'question', + question: '2 grouped questions from Claude', + options: [], + questions: [ + { + id: 'q1', + question: 'Targets', + multiSelect: true, + options: [ + { id: 'target-web', label: 'Web' }, + { id: 'target-mobile', label: 'Mobile' } + ] + }, + { + id: 'q2', + question: 'Host', + multiSelect: false, + options: [], + freeTextQuestionId: 'q2' + } + ], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await host.flushStreamedEvents(SESSION) const itemId = agentJournalItemKey(identity) const page = await host.history({ sessionId: SESSION, direction: 'tail' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index d85d1dfee0f..ed2e48fd562 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -12,6 +12,7 @@ import { type StructuredAgentSessionConversationOpenOptions } from './structured-agent-session-conversation-open' import { StructuredAgentSessionDeliveryLoop } from './structured-agent-session-delivery-loop' +import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionResumeOutcome } from './structured-agent-session-agent-start' import type { StructuredAgentSessionHostDeps, @@ -19,7 +20,6 @@ import type { } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' -import { settleInterruptedCompaction } from './structured-compaction-recovery' import { recoverStructuredRewind } from './structured-rewind-recovery' export type StructuredAgentSessionConversationDelivery = { @@ -29,6 +29,10 @@ export type StructuredAgentSessionConversationDelivery = { sessionId: string, options?: StructuredAgentSessionConversationOpenOptions ) => Promise + /** Every commit a conversation's journal makes: one may have ended the command that held its + * queue. Enqueued through the session's serialize, never read here, so a commit that lands while + * a step is deciding to stop wakes the loop after that step rather than being lost to it. */ + afterCommit: (sessionId: string, journal: AgentSessionJournal) => void /** Stops the loop and the resettle on a proof of death; quit's first step. */ dispose: () => void /** Indexes a conversation some other open produced, as `open` would have. */ @@ -50,6 +54,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { ) => Promise reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void publishRestored: (sessionId: string) => void + flushStreamedEvents: (sessionId: string) => Promise }): StructuredAgentSessionConversationDelivery { const { deps, sessions } = input const loop = new StructuredAgentSessionDeliveryLoop({ @@ -61,8 +66,14 @@ export function createStructuredAgentSessionConversationDelivery(input: { conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), failureTextContext: (sessionId) => - structuredAgentSessionFailureWordsContext(deps.store.getRecord(sessionId)), - onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }) + structuredAgentSessionFailureWordsContext( + deps.store.getRecord(sessionId), + sessions.get(sessionId)?.journal + ), + onError: (sessionId, error) => deps.onEventSinkError?.({ sessionId, error }), + record: (sessionId) => deps.store.getRecord(sessionId), + flushStreamedEvents: input.flushStreamedEvents, + now: () => deps.now?.() ?? Date.now() }) const adoptOpened = async ( sessionId: string, @@ -79,6 +90,26 @@ export function createStructuredAgentSessionConversationDelivery(input: { loop.wake(sessionId) } } + const wakesQueued = new Set() + const afterCommit = (sessionId: string, journal: AgentSessionJournal): void => { + if ( + wakesQueued.has(sessionId) || + structuredAgentSessionCommandRunning(journal) || + !journal.submissions().some(isQueuedAgentJournalSubmission) + ) { + return + } + wakesQueued.add(sessionId) + void input + .serialize(sessionId, async () => { + wakesQueued.delete(sessionId) + loop.wake(sessionId) + }) + .catch((error: unknown) => { + wakesQueued.delete(sessionId) + deps.onEventSinkError?.({ sessionId, error }) + }) + } // A chat open before its owner's death was proven revises what its open settled. Queued, never // awaited: the writer can hold this session's serialize (an attach recovering its lease). const stopResettling = deps.store.onDeathEvidence((sessionId) => { @@ -94,6 +125,7 @@ export function createStructuredAgentSessionConversationDelivery(input: { }) return { loop, + afterCommit, adoptOpened, dispose: () => { loop.dispose() @@ -105,10 +137,10 @@ export function createStructuredAgentSessionConversationDelivery(input: { } /** - * A compaction or rewind found prepared when the conversation opens was started under a child - * this process no longer has — the open runs only when none is indexed — so nothing will finish - * it, and left alone it refuses every send, so no agent would ever start to. Settled here instead - * of by a start inside acceptance. A Codex rewind only its provider can prove stays for the attach. + * A rewind found prepared when the conversation opens was started under a child this process no + * longer has — the open runs only when none is indexed — so nothing will finish it, and left alone + * it refuses every send until a view attaches. Settled here instead of by a start inside + * acceptance. A Codex rewind only its provider can prove stays for the attach. */ async function settleInterruptedCommands( deps: StructuredAgentSessionHostDeps, @@ -117,7 +149,6 @@ async function settleInterruptedCommands( ): Promise { const fence = structuredAgentSessionConversationFence(deps.store, sessionId) try { - await settleInterruptedCompaction(deps.store, sessionId, session.journal, fence) await recoverStructuredRewind(deps.store, sessionId, session.journal, fence) } catch (error) { deps.onEventSinkError?.({ sessionId, error }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 1586bd4a1c1..f7f26ead440 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -78,7 +78,8 @@ export type StructuredAgentSessionMutationContext = { now: () => number } -function mutate( +/** Admits the envelope and runs the plan inside the session's serialize. */ +export function mutateStructuredAgentSession( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, envelope: AgentSessionMutationEnvelope, @@ -113,7 +114,7 @@ export function sendStructuredAgentSessionTurn( } ): Promise> { const plan = sendPlan(params) - return mutate( + return mutateStructuredAgentSession( context, caller, params.envelope, @@ -146,28 +147,21 @@ export function cancelStructuredAgentSessionTurn( prompt?: { itemId: string; expectedRevision: number } } ): Promise> { - const command = context.deps.store.getRecord(params.envelope.sessionId)?.conversationCommand - // Interrupts must reach a provider while the command awaits its terminal frame. - const cancellationContext = - command?.command === 'compact' && command.phase === 'prepared' - ? { - ...context, - serialize: (sessionId: string, task: () => Promise) => - context.serialize(`compact-cancel:${sessionId}`, task) - } - : context - const plan = cancelPlan(params) if (params.scope || params.prompt) { - return mutate( - cancellationContext, + return mutateStructuredAgentSession( + context, caller, params.envelope, - plan, + cancelPlan(params), openForWrite(context, params.envelope) ) } - return mutate( - cancellationContext, + const plan = cancelPlan({ + ...params, + stopChild: () => context.stopAgent(params.envelope.sessionId) + }) + return mutateStructuredAgentSession( + context, caller, params.envelope, { @@ -212,7 +206,7 @@ export function respondToStructuredAgentSessionPrompt( caller: StructuredAgentSessionCaller, params: AgentSessionPromptRequest & { envelope: AgentSessionMutationEnvelope } ): Promise> { - return mutate( + return mutateStructuredAgentSession( context, caller, params.envelope, @@ -230,7 +224,7 @@ export async function setStructuredAgentSessionOption( await context.deps.adapter.awaitOptionWritable?.(params.envelope.sessionId) const plan = setOptionPlan(params) const atRest = () => !context.sessions.get(params.envelope.sessionId)?.child - return mutate( + return mutateStructuredAgentSession( context, caller, params.envelope, @@ -255,7 +249,7 @@ export function changeStructuredAgentSessionThreadGoal( caller: StructuredAgentSessionCaller, params: { envelope: AgentSessionMutationEnvelope; change: AgentSessionThreadGoalChange } ): Promise> { - return mutate( + return mutateStructuredAgentSession( context, caller, params.envelope, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index 1e0d77b0d52..d354ad911f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -98,13 +99,17 @@ async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revis if (!events) { throw new Error('seedApproval requires an acquired session') } - events.appendItem(identity, { - kind: 'approval', - title: 'Run the command?', - detail: null, - options: [{ id: optionId, label: 'Allow' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - }) + events.appendItem( + identity, + { + kind: 'approval', + title: 'Run the command?', + detail: null, + options: [{ id: optionId, label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await host.flushStreamedEvents(SESSION) const itemId = agentJournalItemKey(identity) const page = await host.history({ sessionId: SESSION, direction: 'tail' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 58a84cecd5f..5db9e5d0d74 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' @@ -186,7 +187,8 @@ describe('attach', () => { }) events?.appendItem( { provider: 'orca', clientMessageId: 'old-journal-write' }, - { kind: 'status', text: 'old journal write' } + { kind: 'status', text: 'old journal write' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await vi.waitFor(() => expect(append).toHaveBeenCalledOnce()) const released = await store.evictProvenDeadOwner({ @@ -389,7 +391,7 @@ describe('respondToPrompt', () => { options: [{ id: 'allow', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - child + { ...child, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) const itemId = agentJournalItemKey(identity) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 26697d3f681..9faabccfb1e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -63,7 +63,10 @@ export class StructuredAgentSessionHost { this ) private readonly sessions = new StructuredAgentSessionConversations({ - deliver: (sessionId, journal) => this.subscribers.publish(sessionId, journal), + deliver: (sessionId, journal) => { + this.subscribers.publish(sessionId, journal) + this.conversationDelivery.afterCommit(sessionId, journal) + }, onDeliveryError: (sessionId, error) => this.deps.onEventSinkError?.({ sessionId, error }), now: () => this.now() }) @@ -123,7 +126,8 @@ export class StructuredAgentSessionHost { reset, structuredAgentSessionConversationFence(deps.store, sessionId) ), - publishRestored: this.clientDelivery.publishRestored + publishRestored: this.clientDelivery.publishRestored, + flushStreamedEvents: (sessionId) => this.flushStreamedEvents(sessionId) }) this.restore = createStructuredAgentSessionHostRestore(deps, { reconcile: this.reconcileLeases, @@ -170,6 +174,7 @@ export class StructuredAgentSessionHost { private now = (): number => this.deps.now?.() ?? Date.now() hasSession = (sessionId: string): boolean => this.sessions.has(sessionId) + sessionAgent = (sessionId: string) => this.deps.store.getRecord(sessionId)?.provider ?? null handleAdapterEvent = (event: Parameters[0]) => this.eventRecovery.handle(event) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index ebc2009fa7d..25288dc6490 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // The idle sweep on a real host: what puts an agent to rest, what keeps it running, and what a // reader and the session lists see when it does. @@ -185,7 +186,8 @@ describe('the idle sweep', () => { await foundRestTestChat(rig) providerEvents().appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 50 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await rig.host.flushStreamedEvents(SESSION) rig.clock.now += IDLE_MS + 1 @@ -210,7 +212,8 @@ describe('the idle sweep', () => { // Activity at 29 minutes: a provider row reaching the journal. providerEvents().appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 60 }, - { kind: 'status', text: 'still thinking' } + { kind: 'status', text: 'still thinking' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await rig.host.flushStreamedEvents(SESSION) rig.clock.now += 60_001 @@ -247,7 +250,8 @@ describe('the idle sweep', () => { detail: null, options: [{ id: 'allow', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await rig.host.flushStreamedEvents(SESSION) rig.clock.now += IDLE_MS + 1 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts index b35690c4e7d..e7ce667ed2b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-append-options.ts @@ -6,15 +6,16 @@ import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' import type { JournalItemAppendOptions } from '../agent-session-journal/journal-store-contracts' -import type { StructuredAgentSessionAppendOptions } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionItemAppendOptions } from './structured-agent-session-event-sink' export function structuredAgentSessionJournalAppendOptions( fence: number, - options: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionItemAppendOptions ): JournalItemAppendOptions { return { fence, ...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }), + turnScope: options.turnScope, ...agentJournalLinkageFields(options) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts index 8dabd942705..f6ca0bc4a4e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Journal handle ownership across the wire layer. // // Every one of these sites is reached only when something has already gone @@ -91,7 +92,7 @@ describe('site 6: recovery rehydration', () => { await seeded.appendItem( { provider: 'codex', threadId: SESSION, turnId: 'turn-1', ordinal }, { kind: 'status', text: `seed-${ordinal}` }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } await seeded.close() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 2194b992fb0..8a43b1a8eff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -271,7 +272,10 @@ describe('settling a send the provider proves it received after the ack window', await journal().appendItem( { provider: 'claude', sessionId: THREAD, uuid: 'echo-row' }, params.body, - { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 } + { + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ) expect(await submissions()).toMatchObject([ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts index 35c02972829..95861c81f29 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -9,7 +9,10 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionBackgroundTaskState } from '../../../shared/agent-session-background-task-wire' -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody +} from '../../../shared/agent-session-journal-types' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' @@ -138,7 +141,7 @@ async function send(text: string): Promise { } async function provider(ordinal: number, body: AgentJournalItemBody): Promise { - events!.appendItem({ ...PROVIDER_ROW, ordinal }, body) + events!.appendItem({ ...PROVIDER_ROW, ordinal }, body, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) events!.publish() await host.flushStreamedEvents(SESSION) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 2007f9bc174..a88359e121d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -14,7 +14,12 @@ import type { AgentSessionPromptResult, AgentSessionSendResult } from '../../../shared/agent-session-wire' +import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command' import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { + STRUCTURED_AGENT_SESSION_COMPACT_COMMAND, + structuredAgentSessionCompactBody +} from './structured-agent-session-command-turn' import { performCancel, performPrompt, @@ -96,12 +101,52 @@ export function sendPlan(params: { } } +export type ConversationCommandAcceptance = + | { clientMessageId: string } + /** What an older build's run of this operation recorded. */ + | { recorded: AgentSessionConversationCommandResult } + +/** `/compact` accepted like a send: one submission, keyed by the operation id, that the delivery + * loop carries out as the command's own turn. */ +export function conversationCommandPlan(params: { + envelope: AgentSessionMutationEnvelope + priorRecord: () => AgentSessionConversationCommandResult | null +}): MutationPlan { + const clientMessageId = params.envelope.clientOperationId + return { + method: 'agentSession.conversationCommand', + conversationWrite: true, + markUnknownBeforeRun: true, + fields: { command: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND }, + recoverUnknownFromDurableState: true, + run: async (ctx) => { + const sent = await performSend(ctx, { + clientMessageId, + payloadFingerprint: params.envelope.payloadFingerprint, + body: structuredAgentSessionCompactBody() + }) + return sent.ok ? { ok: true, value: { clientMessageId } } : sent + }, + replay: (ctx, outcome) => { + if (outcome.status === 'succeeded' && outcome.conversationCommand) { + return { recorded: outcome.conversationCommand } + } + if (ctx.journal.submissions().some((entry) => entry.clientMessageId === clientMessageId)) { + return { clientMessageId } + } + const prior = params.priorRecord() + return prior ? { recorded: prior } : null + } + } +} + export function cancelPlan(params: { envelope: AgentSessionMutationEnvelope turnId?: string scope?: 'background-tasks' taskId?: string prompt?: { itemId: string; expectedRevision: number } + stopChild?: () => Promise }): MutationPlan { return { method: 'agentSession.cancel', @@ -119,7 +164,8 @@ export function cancelPlan(params: { ...(params.turnId !== undefined ? { turnId: params.turnId } : {}), ...(params.scope ? { scope: params.scope } : {}), ...(params.taskId ? { taskId: params.taskId } : {}), - ...(params.prompt ? { prompt: params.prompt } : {}) + ...(params.prompt ? { prompt: params.prompt } : {}), + ...(params.stopChild ? { stopChild: params.stopChild } : {}) }), // Interrupting twice would kill a turn the client never asked to stop, so a // replay reports the turn as already handled instead. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts index a9e32b026e6..e0ae5da9d81 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -50,7 +51,7 @@ async function pendingPrompt(): Promise<{ journal: AgentSessionJournal; itemId: resolvedAt: null } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return { journal, itemId: item.itemId } } @@ -117,7 +118,7 @@ describe('performCancel for a pending prompt', () => { resolvedAt: null } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index fb66a1e1a8f..30e81ab2d1e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // The provider child is its own record on the conversation: stopping it, losing it or failing to // start it ends the child, never the conversation. Against the real host, store and journal, with a // live subscriber opened before each action. @@ -339,7 +340,7 @@ describe('settling an earlier child before the next one takes its message', () = await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'earlier-turn', ordinal: 0 }, { kind: 'turn', turnId: 'earlier-turn', state: 'running', startedAt: NOW - 5_000 }, - { fence: releasedFence } + { fence: releasedFence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() const id = await accept('for the next child') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index a2be1610327..7a90850dfc6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A turn that was running when its host went away ends when recovery settles it. That settlement is // the edge the user needs to see — their work stopped — so the session reads as newly done then, // and nothing along the way may call it a success. Every hop is the real one: durable journal, @@ -59,12 +60,12 @@ async function sessionWithRunningTurn() { await journal.appendItem( { provider: 'orca', clientMessageId: 'prompt-1' }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'long job' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 9 }, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: TURN_STARTED }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const server = new AgentHookServer() const sessions = new Map([[SESSION, indexedStatusFeedSession({ journal })]]) @@ -196,7 +197,7 @@ describe('a turn recovery settled after its host went away', () => { startedAt: TURN_STARTED, completedAt: EXIT_OBSERVED }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) session.publish() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts index 005986adb44..d3c15de933c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { @@ -63,7 +64,11 @@ describe('resolved revisions', () => { const deferred = createDeferredStructuredAgentSessionEventSink() const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('abc')) for (const suffix of ['a', 'b', 'c']) { - expect(deferred.sink.tryReviseResolvedItem?.(bytes, appendSuffix(suffix))).toEqual({ + expect( + deferred.sink.tryReviseResolvedItem?.(bytes, appendSuffix(suffix), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).toEqual({ accepted: true }) } @@ -77,7 +82,9 @@ describe('resolved revisions', () => { it('skips a revision that resolves to nothing', async () => { const rows = new Map() const deferred = createDeferredStructuredAgentSessionEventSink() - deferred.sink.tryReviseResolvedItem?.(1_000, () => null) + deferred.sink.tryReviseResolvedItem?.(1_000, () => null, { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const target = journalTarget(rows) deferred.bind(target) await expect(deferred.drained()).resolves.toEqual({ ok: true }) @@ -88,8 +95,12 @@ describe('resolved revisions', () => { const rows = new Map() const deferred = createDeferredStructuredAgentSessionEventSink() const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('a')) - deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, appendSuffix('a')) - deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, () => null) + deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, appendSuffix('a'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + deferred.sink.tryReviseResolvedItemAndPublish?.(bytes, () => null, { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) const target = journalTarget(rows) const published: string[] = [] vi.mocked(target.publish).mockImplementation(() => @@ -105,10 +116,14 @@ describe('resolved revisions', () => { const rows = new Map() const deferred = createDeferredStructuredAgentSessionEventSink() const bytes = estimateStructuredAgentSessionItemBytes(ROW, text('a')) - deferred.sink.tryReviseResolvedItem?.(bytes, () => ({ - identity: ROW, - body: text('a'.repeat(64)) - })) + deferred.sink.tryReviseResolvedItem?.( + bytes, + () => ({ + identity: ROW, + body: text('a'.repeat(64)) + }), + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) deferred.bind(journalTarget(rows)) await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) expect(rows.size).toBe(0) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts index de1190aefc1..eb2fbea97fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.ts @@ -4,7 +4,7 @@ import type { } from '../../../shared/agent-session-journal-types' import { estimateStructuredAgentSessionItemBytes } from './structured-agent-session-event-sink-estimate' import type { - StructuredAgentSessionAppendOptions, + StructuredAgentSessionItemAppendOptions, StructuredAgentSessionEventSink, StructuredAgentSessionRevisionJournal } from './structured-agent-session-event-sink' @@ -30,7 +30,7 @@ export function createStructuredAgentSessionResolvedAppend( const submit = ( reservedBytes: number, resolve: (journal: StructuredAgentSessionRevisionJournal) => ResolvedItem | null, - options: StructuredAgentSessionAppendOptions, + options: Omit, publish: boolean ) => queue.submit( @@ -58,7 +58,7 @@ export function createStructuredAgentSessionResolvedAppend( options ) const identityOnly = (publish: boolean) => - ((identitySizeBound, body, resolveIdentity, options = {}) => + ((identitySizeBound, body, resolveIdentity, options) => submit( estimateStructuredAgentSessionItemBytes(identitySizeBound, body) + (publish ? 1 : 0), (journal) => { @@ -71,11 +71,11 @@ export function createStructuredAgentSessionResolvedAppend( return { tryAppendResolvedItem: identityOnly(false), tryAppendResolvedItemAndPublish: identityOnly(true), - tryReviseResolvedItem: (reservedBytes, resolve, options = {}) => + tryReviseResolvedItem: (reservedBytes, resolve, options) => submit(reservedBytes, resolve, options, false), - tryReviseResolvedItemAndPublish: (reservedBytes, resolve, options = {}) => + tryReviseResolvedItemAndPublish: (reservedBytes, resolve, options) => submit(reservedBytes + 1, resolve, options, true), - tryAppendLifecycleTransition: (identitySizeBound, body, resolveIdentity, options = {}) => { + tryAppendLifecycleTransition: (identitySizeBound, body, resolveIdentity, options) => { const bytes = estimateStructuredAgentSessionItemBytes(identitySizeBound, body) return queue.submit( { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts index 50008ce79c4..b16f76c1633 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts @@ -6,7 +6,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' -import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-projection' +import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-latest-request' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { adapterSupportsRecord } from './structured-agent-session-provider-support' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts index cfbdd9ce35e..4dff44ff2ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts @@ -13,7 +13,8 @@ const MESSAGE = 'continuation-1' let submission: AgentJournalSubmission const journal = { submissions: (): AgentJournalSubmission[] => [submission], - cursor: () => ({ epoch: 'epoch-1', sequence: 1 }) + cursor: () => ({ epoch: 'epoch-1', sequence: 1 }), + activeTurnId: () => null } beforeEach(() => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 9dd04929385..823b5926897 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -6,7 +6,10 @@ // message asks the agent to verify its last action before repeating it, and the launch toast // reports what happened. -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' import { readAgentSessionFailureFact, type UnreadAgentSessionFailureFact @@ -113,7 +116,8 @@ function restartNoteWriter( await session.journal.appendItem( { provider: 'orca', clientMessageId: `restart-continuation:${sessionId}:${host.now()}` }, { kind: 'status', text, ...(tone ? { tone } : {}) }, - { fence } + // About the conversation, not any turn in it. + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index d88a6330e78..6566105ee87 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A chat interrupted mid-turn by a restart, rebuilt on a fresh host over the same store, for the // restart-resume ownership and failure tests. @@ -60,7 +61,8 @@ export async function interruptedRestart( // An earlier exchange had finished; the user's next send had not opened a turn yet. events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'earlier-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'earlier-turn', state: 'completed' } + { kind: 'turn', turnId: 'earlier-turn', state: 'completed' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await previous.host.flushStreamedEvents(SESSION) } @@ -73,7 +75,8 @@ export async function interruptedRestart( } else if (work === 'children') { events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'settled-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'settled-turn', state: 'completed' } + { kind: 'turn', turnId: 'settled-turn', state: 'completed' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const group = { provider: 'codex', @@ -92,20 +95,21 @@ export async function interruptedRestart( } ] }) - events.appendItem(group, roster('working')) + events.appendItem(group, roster('working'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) previous.host.deps.adapter.backgroundTaskState = () => ({ state: 'monitoring', tasks: [{ id: 'child-1', kind: 'agent', description: 'Review loop 4', state: 'working' }] }) // As the real adapters do: the child's own close settles the children it can no longer hear. previous.host.deps.adapter.closeSession = async () => { - events.appendItem(group, roster('unverifiable')) + events.appendItem(group, roster('unverifiable'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) return true } } else { events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'interrupted-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'interrupted-turn', state: 'running' } + { kind: 'turn', turnId: 'interrupted-turn', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } await previous.host.flushStreamedEvents(SESSION) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index b45e08314a0..59ff6958d53 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdir, rm, writeFile } from 'node:fs/promises' import { AgentSessionRecoveryCapsule, @@ -279,7 +280,8 @@ it('serializes teardown publication behind an explicit dismissal', async () => { } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) host.restartResume.beginTeardown('quit') @@ -437,7 +439,8 @@ it('logs teardown capsule publication failure and still releases the provider', } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await previous.host.flushStreamedEvents(SESSION) const capsulePath = join(previous.root, AGENT_SESSION_RECOVERY_CAPSULE_FILE) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts index bf740ac405b..2ea9e6f57b7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { attach, hostTestState } from './structured-agent-session-host-test-harness' @@ -49,12 +50,14 @@ it.each(['approval', 'question', 'completed'])( } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 2 }, - { kind: 'status', text: 'Provider is requesting approval' } + { kind: 'status', text: 'Provider is requesting approval' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 3 }, @@ -65,7 +68,7 @@ it.each(['approval', 'question', 'completed'])( kind: event === 'approval' ? 'approval' : 'question' } : { kind: 'turn', turnId: 'working', state: 'completed' }, - { lifecycle: true } + { lifecycle: true, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushAllStreamedEvents() const offered = await new AgentSessionRecoveryCapsule(root).list(NOW) @@ -90,7 +93,8 @@ it.each(['approval', 'question', 'completed'] as const)( } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) host.deps.adapter.closeSession = async () => { @@ -103,7 +107,8 @@ it.each(['approval', 'question', 'completed'] as const)( }, event === 'completed' ? { kind: 'turn', turnId: 'working', state: 'completed' } - : { ...pendingApproval().body, question: 'Which action?', kind: event } + : { ...pendingApproval().body, question: 'Which action?', kind: event }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return true } @@ -123,7 +128,8 @@ it('marks a settled chat whose subagent was still running', async () => { } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'settled', ordinal: 1 }, - { kind: 'turn', turnId: 'settled', state: 'completed' } + { kind: 'turn', turnId: 'settled', state: 'completed' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) host.deps.adapter.backgroundTaskState = () => ({ @@ -153,7 +159,8 @@ it('offers nothing for a chat whose child was not proven stopped', async () => { } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await host.flushStreamedEvents(SESSION) host.deps.adapter.closeSession = async () => false diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index a596cc5c9ed..a9caf642523 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A Codex rewind whose outcome only the provider can prove, found on a chat at rest: nothing on // screen will start the agent that settles it, so the next send does, and its message is kept. @@ -158,8 +159,10 @@ function sendParams(text: string) { async function interruptedRewindAtRest(): Promise { expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) const drop = { ...KEPT, turnId: 'drop' } - sink.appendItem(KEPT, hostTestMessage('verified history')) - sink.appendItem(drop, hostTestMessage('to be rewound')) + sink.appendItem(KEPT, hostTestMessage('verified history'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem(drop, hostTestMessage('to be rewound'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await host.flushStreamedEvents(SESSION) rewind.mockImplementation(async (input) => { await input.onReverted?.() @@ -205,7 +208,7 @@ describe('an interrupted Codex rewind on a chat at rest (R16)', () => { describe('a rewind asked of a chat at rest (P2-23)', () => { it('starts the agent first and answers with what the provider says', async () => { expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) - sink.appendItem(KEPT, hostTestMessage('kept')) + sink.appendItem(KEPT, hostTestMessage('kept'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await host.flushStreamedEvents(SESSION) const epoch = (await host.journalSnapshot(SESSION)).cursor.epoch await host.flushAllStreamedEvents() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index a8ded60e6ff..fcba5479423 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -8,6 +9,7 @@ import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { nativeChatTurnMembership } from '../../../shared/native-chat-turn-membership' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { @@ -151,7 +153,7 @@ async function seed(acceptedSubmissions = false) { selectedItemId = agentJournalSubmissionKey(clientOperationId) } } else { - sink.appendItem(identity, body) + sink.appendItem(identity, body, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) } } await host.flushStreamedEvents(HOST_TEST_SESSION) @@ -268,7 +270,8 @@ describe('host rewind', () => { const target = await seed() sink.appendItem( { provider: 'orca', clientMessageId: 'active' }, - { kind: 'status', text: 'working', turnLifecycle: { turnId: 'active', state: 'running' } } + { kind: 'status', text: 'working', turnLifecycle: { turnId: 'active', state: 'running' } }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) expect(await host.rewind(caller, await params(target))).toMatchObject({ ok: false, @@ -377,12 +380,24 @@ describe('host rewind', () => { completedAt: HOST_TEST_NOW - 4_000, durationMs: 5_000 } - sink.appendItem(message('kept'), hostTestMessage('kept')) - sink.appendItem(goalRow, goalBody) - sink.appendItem(turnRow('kept'), keptTurn) - sink.appendItem(message('drop'), hostTestMessage('drop')) - sink.appendItem(turnRow('drop'), { ...keptTurn, turnId: 'drop', durationMs: 1_000 }) - sink.appendItem(message('tip'), { ...hostTestMessage('tip'), role: 'assistant' }) + sink.appendItem(message('kept'), hostTestMessage('kept'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem(goalRow, goalBody, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + sink.appendItem(turnRow('kept'), keptTurn, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + sink.appendItem(message('drop'), hostTestMessage('drop'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem( + turnRow('drop'), + { ...keptTurn, turnId: 'drop', durationMs: 1_000 }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + sink.appendItem( + message('tip'), + { ...hostTestMessage('tip'), role: 'assistant' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await host.flushStreamedEvents(HOST_TEST_SESSION) // The provider preflight knows only its own items, never the host's turn rows. const items = [{ identity: message('kept'), body: hostTestMessage('kept from provider') }] @@ -411,6 +426,98 @@ describe('host rewind', () => { expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') }) + it('keeps each kept turn opened by the message that opened it, under its provider key', async () => { + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const message = (turnId: string, ordinal = 0) => ({ + provider: 'codex' as const, + threadId: HOST_TEST_THREAD, + turnId, + ordinal + }) + const turnRow = (turnId: string) => ({ + provider: 'legacy' as const, + agent: 'codex', + sessionId: HOST_TEST_SESSION, + recordId: `turn-lifecycle:${turnId}` + }) + for (const turnId of ['kept', 'drop']) { + const body = hostTestMessage(turnId) + const clientOperationId = hostTestOperationId() + vi.mocked(adapter.dispatch).mockResolvedValueOnce({ + state: 'accepted', + providerIdentity: message(turnId) + }) + expect( + await host.send(caller, { + body, + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId, + expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: HOST_TEST_SESSION, + fields: { body } + }) + } + }) + ).toMatchObject({ ok: true }) + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(HOST_TEST_SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientOperationId + )?.dispatchState + ).toBe('accepted') + ) + // As the translator writes it: the turn names the submission that opened it. + sink.appendItem( + turnRow(turnId), + { + kind: 'turn', + turnId, + state: 'completed', + outcome: 'success', + userItemId: agentJournalSubmissionKey(clientOperationId), + startedAt: HOST_TEST_NOW - 2_000, + completedAt: HOST_TEST_NOW - 1_000 + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + sink.appendItem( + message(turnId, 1), + { ...hostTestMessage(`answer ${turnId}`), role: 'assistant' }, + { turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(turnRow(turnId)) } } + ) + await host.flushStreamedEvents(HOST_TEST_SESSION) + } + const items = [ + { identity: message('kept'), body: hostTestMessage('kept') }, + { + identity: message('kept', 1), + body: { ...hostTestMessage('answer kept'), role: 'assistant' as const } + } + ] + rewind.mockImplementationOnce(async (input) => { + await input.onPrepared?.(items) + await input.onReverted?.() + return { ok: true, items } + }) + const target = (await host.journalSnapshot(HOST_TEST_SESSION)).submissions.at(-1)! + expect( + await host.rewind(caller, await params(agentJournalSubmissionKey(target.clientMessageId))) + ).toMatchObject({ ok: true }) + + const rebuilt = await host.journalSnapshot(HOST_TEST_SESSION) + const membership = nativeChatTurnMembership( + rebuilt.items.flatMap(({ itemId, body }) => + body.kind === 'message' ? [{ id: itemId, role: body.role }] : [] + ), + rebuilt + ) + const opener = agentJournalItemKey(message('kept')) + expect(membership.turnKeys).toEqual([opener, opener]) + }) + it('keeps a host goal row when interrupted Codex rewind recovery rebuilds provider history', async () => { expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) const message = (turnId: string) => ({ @@ -432,10 +539,18 @@ describe('host rewind', () => { payload: { head: '{}', byteLength: 2, digest: '4'.repeat(64), truncated: false } } } - sink.appendItem(message('kept'), hostTestMessage('kept')) - sink.appendItem(goalRow, goalBody) - sink.appendItem(message('drop'), hostTestMessage('drop')) - sink.appendItem(message('tip'), { ...hostTestMessage('tip'), role: 'assistant' }) + sink.appendItem(message('kept'), hostTestMessage('kept'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem(goalRow, goalBody, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + sink.appendItem(message('drop'), hostTestMessage('drop'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem( + message('tip'), + { ...hostTestMessage('tip'), role: 'assistant' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await host.flushStreamedEvents(HOST_TEST_SESSION) rewind.mockImplementationOnce(async (input) => { await input.onReverted?.() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index 45dd7244896..f1870dc124d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -1,4 +1,5 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer' import { agentJournalItemKey, agentJournalSubmissionKey, @@ -11,6 +12,7 @@ import type { AgentSessionRewindResult } from '../../../shared/agent-session-rewind' import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { openWithAgent } from './structured-agent-session-send-preparation' @@ -120,10 +122,12 @@ export async function rewindStructuredAgentSession( } const retained = snapshot.items .slice(0, boundary) - .map(({ itemId, body, observedAt }) => ({ + .map(({ itemId, body, observedAt, turnScope, ...linkage }) => ({ itemId: providerKey(itemId), - body, - observedAt + body: withRenamedTurnOpener(body, providerKey), + observedAt, + ...(turnScope ? { turnScope } : {}), + ...agentJournalLinkageFields(linkage) })) if ( retained.length > 10_000 || @@ -229,3 +233,18 @@ export async function rewindStructuredAgentSession( : result }) } + +/** The new epoch keeps no submissions, so a sent message survives only under its provider key; the + * turn it opened must name it by that key too, or the turn anchors on nothing. */ +function withRenamedTurnOpener( + body: AgentJournalItemBody, + rename: (itemId: string) => string +): AgentJournalItemBody { + if (body.kind === 'turn' && body.userItemId !== undefined) { + return { ...body, userItemId: rename(body.userItemId) } + } + const lifecycle = body.kind === 'status' ? body.turnLifecycle : undefined + return body.kind === 'status' && lifecycle?.userItemId !== undefined + ? { ...body, turnLifecycle: { ...lifecycle, userItemId: rename(lifecycle.userItemId) } } + : body +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts index 79882025889..d4862e3e544 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-block.test.ts @@ -48,14 +48,12 @@ describe('a send refused by the conversation command it follows', () => { }) }) - it('keeps an unconfirmed /compact as an unconfirmed command', () => { - const blocked = structuredAgentSessionSendBlock( - withCommand({ ...COMMAND, command: 'compact', state: 'unknown', phase: 'prepared' }) - ) - - expect(blocked?.refusal).toMatchObject({ - details: { reason: 'conversationCommandUnconfirmed' } - }) + it("lets a send follow an older build's unconfirmed /compact, whose child this host no longer runs", () => { + expect( + structuredAgentSessionSendBlock( + withCommand({ ...COMMAND, command: 'compact', state: 'unknown', phase: 'prepared' }) + ) + ).toBeNull() }) it('lets a send follow a /clear whose new conversation failed to start', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts index 0a14f436119..630e3fa386e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A send can be what opens a conversation this process has not read yet: a chat nobody has on // screen after the app died, sent to from a phone or the CLI. Whatever that journal shows running // belongs to a generation that is gone, so it is settled when the journal opens, not only when a @@ -51,7 +52,8 @@ async function relaunchAfterCrashMidTurn( } events.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'crashed-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'crashed-turn', state: 'running' } + { kind: 'turn', turnId: 'crashed-turn', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await dying.host.flushStreamedEvents(SESSION) // An empty renewal queues behind every record write, so they are on disk. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index a7a0e971055..57b7137481e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -17,6 +17,10 @@ import { classifyJournalOpenFailure, type JournalOpenFailure } from '../agent-session-journal/journal-open-failure' +import { + structuredAgentSessionAwaitedCommand, + type StructuredAgentSessionAwaitedCommandJournal +} from './structured-agent-session-command-turn' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { AGENT_SESSION_NOT_ATTACHED, @@ -34,10 +38,11 @@ export function structuredAgentSessionSendBlock( return rewindRefusal('outcome-unknown') } const command = record?.conversationCommand + // Only a clear's record gates sends; an older build's compaction record belongs to a child this + // host no longer runs. if ( - command && - ((command.state === 'unknown' && command.phase === 'prepared') || - (command.command === 'clear' && command.replacementSessionId)) + command?.command === 'clear' && + ((command.state === 'unknown' && command.phase === 'prepared') || command.replacementSessionId) ) { return { ok: false, @@ -49,17 +54,11 @@ export function structuredAgentSessionSendBlock( 'This conversation has been cleared. Use the current conversation.' ) : // A prepared /clear names its replacement before that conversation exists. - command.command === 'clear' - ? refuse( - 'agent_session_operation_invalid', - { reason: 'clearUnconfirmed' }, - "The last /clear didn't finish. Start a new chat to continue." - ) - : refuse( - 'agent_session_operation_invalid', - { reason: 'conversationCommandUnconfirmed' }, - 'The conversation operation is unconfirmed.' - ) + refuse( + 'agent_session_operation_invalid', + { reason: 'clearUnconfirmed' }, + "The last /clear didn't finish. Start a new chat to continue." + ) } } return null @@ -125,9 +124,15 @@ export function sendPreparation( } } -/** Who a failure sentence names: the chat's agent, when the record says. */ +/** Who a failure sentence names: the chat's agent, when the record says; and, given the journal, + * the command a failed start leaves to run again. */ export function structuredAgentSessionFailureWordsContext( - record: AgentSessionRecord | null + record: AgentSessionRecord | null, + journal?: StructuredAgentSessionAwaitedCommandJournal ): AgentSessionFailureWordsContext { - return record ? { agentName: TUI_AGENT_DISPLAY_NAMES[record.provider] } : {} + const command = journal && structuredAgentSessionAwaitedCommand(journal) + return { + ...(record ? { agentName: TUI_AGENT_DISPLAY_NAMES[record.provider] } : {}), + ...(command ? { command } : {}) + } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts index ff9765d1cee..8155da7a2bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.test.ts @@ -27,6 +27,71 @@ function emptyJournal(): AgentSessionJournal { } as unknown as AgentSessionJournal } +/** A message accepted and not yet handed over, while `activeTurnId` names the running turn. */ +function queuedJournal( + activeTurnId: string | null, + handedOver = false +): Pick { + return { + cursor: () => ({ epoch: 'epoch-1', sequence: handedOver ? 3 : 2 }), + activeTurnId: () => activeTurnId, + submissions: () => [ + { + clientMessageId: 'client-1', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + handoverRecorded: true, + ...(handedOver ? { handedOverAt: 3 } : {}) + } + ] + } +} + +describe('a wait that also ends behind a running command', () => { + const until = 'handed-over-or-behind-command' as const + + it('ends once the message waits behind a running command', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => queuedJournal('turn-1')) + const pending = settlements.wait('session-1', 'client-1', { until }) + + settlements.publish('session-1', queuedJournal('compact:cmd-1')) + + const settled = await pending + expect(settled?.value.submission.dispatchState).toBe('pending') + expect(settled?.value.submission).not.toHaveProperty('handedOverAt') + }) + + it('keeps waiting for the handover behind anything that is not a command', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => queuedJournal(null)) + let settled = false + const pending = settlements.wait('session-1', 'client-1', { until }).then((result) => { + settled = true + return result + }) + + settlements.publish('session-1', queuedJournal('turn-1')) + await Promise.resolve() + expect(settled).toBe(false) + settlements.publish('session-1', queuedJournal('turn-1', true)) + + await expect(pending).resolves.toMatchObject({ value: { submission: { handedOverAt: 3 } } }) + }) + + it('leaves the plain handover wait to the handover', async () => { + const settlements = new StructuredAgentSessionSendSettlement(() => + queuedJournal('compact:cmd-1') + ) + const pending = settlements.wait('session-1', 'client-1', { until: 'handed-over', budgetMs: 1 }) + + await expect(pending).resolves.toBeUndefined() + }) +}) + describe('structured send settlement compatibility wait', () => { afterEach(() => vi.useRealTimers()) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts index bc265833c42..a9815b953f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts @@ -5,9 +5,10 @@ import type { import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { isStructuredAgentSessionCommandTurnId } from './structured-agent-session-command-turn' /** What a settlement read needs of a journal. */ -type SendSettlementJournal = Pick +type SendSettlementJournal = Pick type SettledSend = { cursor: AgentJournalCursor @@ -16,8 +17,9 @@ type SettledSend = { type SendSettlement = SettledSend | 'pending' | 'missing' -/** What ends a wait: the provider's answer, or only the host handing the message over. */ -export type SendSettlementPoint = 'answered' | 'handed-over' +/** What ends a wait: the provider's answer, the host handing the message over, or either that or + * the message waiting behind a running command, which hands nothing over until it ends. */ +export type SendSettlementPoint = 'answered' | 'handed-over' | 'handed-over-or-behind-command' export type SendSettlementWaitOptions = { signal?: AbortSignal @@ -55,12 +57,18 @@ function settledSend( return 'missing' } const waiting = - until === 'handed-over' - ? isQueuedAgentJournalSubmission(submission) - : submission.dispatchState === 'pending' + until === 'answered' + ? submission.dispatchState === 'pending' + : isQueuedAgentJournalSubmission(submission) && + !(until === 'handed-over-or-behind-command' && runningCommand(journal)) return waiting ? 'pending' : { cursor: journal.cursor(), value: { clientMessageId, submission } } } +function runningCommand(journal: SendSettlementJournal): boolean { + const turnId = journal.activeTurnId() + return turnId !== null && isStructuredAgentSessionCommandTurnId(turnId) +} + function abortError(signal: AbortSignal): Error { return signal.reason instanceof Error ? signal.reason diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts index 83959233f6f..204da81a2e6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts @@ -3,6 +3,7 @@ // reading it before the restart restore reaches it. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { setStructuredAgentSessionHost } from './structured-agent-session-registry' import { @@ -38,7 +39,8 @@ async function exitWithUnwrittenSettlement(refusedWrites = 2): Promise { .at(-1)?.[0] .events?.appendItem( { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'working', ordinal: 50 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await rig.host.flushStreamedEvents(SESSION) const append = vi.spyOn(open.journal, 'appendLifecycleBatch') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 2c7a91e7637..54acad4832b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -1,9 +1,14 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' 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 { + AgentJournalItemIdentity, + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -23,6 +28,10 @@ const RUNNING_IDENTITY = { ordinal: 0 } +function turnScopeOf(identity: AgentJournalItemIdentity): AgentJournalTurnScope { + return { kind: 'turn', turnItemId: agentJournalItemKey(identity) } +} + function lifecycleItem( turnId: string, state: 'running' | 'completed', @@ -169,7 +178,8 @@ describe('running turn lifecycle revisions', () => { state: 'interrupted', startedAt: 30, completedAt: 40 - } + }, + turnScope: { kind: 'thread' } } ] ) @@ -238,7 +248,8 @@ describe('running turn lifecycle revisions', () => { { kind: 'item', identity: RUNNING_IDENTITY, - body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 } + body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 }, + turnScope: { kind: 'thread' } } ]) }) @@ -295,7 +306,8 @@ describe('stale session state on a cold acquire', () => { { kind: 'item', identity: RUNNING_IDENTITY, - body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 } + body: { kind: 'turn', turnId: 'turn-2', state: 'unverifiable', startedAt: 30 }, + turnScope: { kind: 'thread' } } ] }) @@ -337,7 +349,8 @@ describe('stale session state on a cold acquire', () => { resolvedBy: null, resolvedAt: null } - } + }, + turnScope: { kind: 'thread' } } ] }) @@ -367,8 +380,15 @@ describe('stale session state on a cold acquire', () => { turnId: 'turn-1', ordinal: 1 }) - await journal.appendItem(prompt('thread-child'), body, { fence: 1, ...child }) - await journal.appendItem(prompt(THREAD), body, { fence: 1 }) + await journal.appendItem(prompt('thread-child'), body, { + fence: 1, + ...child, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(prompt(THREAD), body, { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) await settleStaleStructuredAgentSessionState({ journal, @@ -408,19 +428,20 @@ describe('stale session state on a cold acquire', () => { }) const command = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 1 } const shell = { kind: 'tool-call' as const, name: 'shell', input: { command: 'pnpm test' } } + const turnScope = turnScopeOf({ ...command, ordinal: 0 }) await journal.appendItem( { ...command, ordinal: 0 }, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 100 }, - { fence: 1 } + { fence: 1, turnScope } ) now = 200 - await journal.appendItem(command, { ...shell, state: 'running' }, { fence: 1 }) + await journal.appendItem(command, { ...shell, state: 'running' }, { fence: 1, turnScope }) // Rows can outlast the last renewal; only the renewal is proof of life. now = 700 await journal.appendItem( command, { ...shell, input: { command: 'pnpm test', streamed: 'ok' }, state: 'running' }, - { fence: 1 } + { fence: 1, turnScope } ) now = 9_000 @@ -474,13 +495,13 @@ describe('stale session state on a cold acquire', () => { await journal.appendItem( RUNNING_IDENTITY, { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: 100 }, - { fence: 1 } + { fence: 1, turnScope: turnScopeOf(RUNNING_IDENTITY) } ) now = 200 await journal.appendItem( { ...RUNNING_IDENTITY, ordinal: 1 }, { kind: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'running' }, - { fence: 1 } + { fence: 1, turnScope: turnScopeOf(RUNNING_IDENTITY) } ) const settle = (deathEvidence: AgentSessionDeathEvidence | null) => settleStaleStructuredAgentSessionState({ @@ -503,7 +524,12 @@ describe('stale session state on a cold acquire', () => { fence: 1, handoverRecorded: true }) - await journal.resolveDispatch({ clientMessageId: 'send-1', state: 'pending', fence: 1 }) + await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'pending', + turnScope: AGENT_JOURNAL_THREAD_SCOPE, + fence: 1 + }) now = 3_606_000 await settle({ @@ -540,7 +566,7 @@ describe('stale session state on a cold acquire', () => { await journal.appendItem( RUNNING_IDENTITY, { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: 100 }, - { fence: 1 } + { fence: 1, turnScope: turnScopeOf(RUNNING_IDENTITY) } ) now = 400 // The open, before anything proved the fence-1 owner gone. @@ -603,7 +629,7 @@ describe('stale session state on a cold acquire', () => { await journal.appendItem( { ...RUNNING_IDENTITY, turnId }, { kind: 'turn', turnId, state: 'running', startedAt: 100 }, - { fence: 1 } + { fence: 1, turnScope: turnScopeOf({ ...RUNNING_IDENTITY, turnId }) } ) } now = 9_000 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts index b23018aeba8..97c951b4517 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts @@ -7,9 +7,10 @@ // with no end at all, until a proof naming its owner is written and revises it. import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { - AgentJournalRenderItem, - AgentJournalTurnLifecycle +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem, + type AgentJournalTurnLifecycle } from '../../../shared/agent-session-journal-types' import { agentJournalTurnBody, @@ -95,7 +96,14 @@ function turnLifecycleRevision( ): JournalLifecycleMutationInput[] { const identity = parseAgentJournalItemKey(item.itemId) return identity - ? [{ kind: 'item', identity, body: agentJournalTurnBody(settledLifecycle(turn, verdict)) }] + ? [ + { + kind: 'item', + identity, + body: agentJournalTurnBody(settledLifecycle(turn, verdict)), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] : [] } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index 77ba7fe5f58..12fa05710f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -1,4 +1,5 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { structuredAgentSessionStartFailureRowIdentity } from '../../../shared/structured-agent-session-start-failure-row-key' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' @@ -24,7 +25,9 @@ export function structuredAgentSessionStartFailureRow( kind: 'item', identity: structuredAgentSessionStartFailureRowIdentity(startKey), // The row repeats the sentence the start's rejected messages carry. - body: { kind: 'status', text: words.reason, tone: 'error', failure: words.rejection } + body: { kind: 'status', text: words.reason, tone: 'error', failure: words.rejection }, + // A start that failed opened no turn. + turnScope: AGENT_JOURNAL_THREAD_SCOPE } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts index dae8635cd69..3c4096be138 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-failure-exit.test.ts @@ -4,6 +4,7 @@ import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import { structuredAgentSessionCompactBody } from './structured-agent-session-command-turn' import { settleUnexpectedStructuredAgentSessionExit, type StructuredAgentSessionUnexpectedExitContext, @@ -22,6 +23,7 @@ function startedSession(): StructuredAgentSessionUnexpectedExitSession & { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, // Nothing ran: the start failed before any response or acknowledged prompt. snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), @@ -140,4 +142,30 @@ describe('a provider that ends before it finished starting', () => { }) ) }) + + it('names /compact as the next step when the start that failed was carrying it', async () => { + const base = startedSession() + const session = { + ...base, + child: { generation: GENERATION, fence: 7, phase: 'starting' as const }, + journal: { + ...base.journal, + submissions: () => [{ clientMessageId: 'compact-1', dispatchState: 'pending' as const }], + itemBody: () => structuredAgentSessionCompactBody() + } + } + + await settleUnexpectedStructuredAgentSessionExit(contextFor(session), ended) + + const text = 'Claude stopped before it finished starting. Run /compact again.' + expect(session.journal.rejectPendingSubmissions).toHaveBeenCalledWith( + 7, + expect.objectContaining({ reason: text }) + ) + expect(session.journal.appendLifecycleBatch).toHaveBeenCalledWith( + expect.objectContaining({ + mutations: [expect.objectContaining({ body: expect.objectContaining({ text }) })] + }) + ) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts index dcd06c0c7ef..8576df8a95c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // Which journal changes republish a session's status now that the summary carries its own state // clock: a moved clock always does, and row activity alone does not once the clock dates the state. @@ -73,7 +74,7 @@ async function openFeed() { } }) const write = async (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { - await journal.appendItem(identity, body, { fence: 1 }) + await journal.appendItem(identity, body, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) feed.publish(SESSION, journal) } await write( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index eda619694e3..8ff9de818a3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -105,7 +106,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) expect(events.at(-1)).toEqual({ @@ -215,12 +216,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) @@ -251,12 +252,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) now = 200 @@ -282,20 +283,20 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const assistant = { ...USER_IDENTITY, ordinal: 2 } await journal.appendItem( assistant, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'first' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) now = 200 await journal.appendItem( assistant, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'finished' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) expect(events.at(-1)).toMatchObject({ @@ -319,12 +320,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const { feed, events } = feedFor(new Map([[SESSION, { journal }]])) for (let revision = 1; revision <= 20; revision += 1) { @@ -332,7 +333,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) } @@ -356,12 +357,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run the tests' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) expect(events.at(-1)).toEqual({ @@ -372,7 +373,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( { ...USER_IDENTITY, ordinal: 2 }, { kind: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'running' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) @@ -390,7 +391,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run it' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, @@ -401,7 +402,7 @@ describe('StructuredAgentSessionStatusFeed', () => { options: [{ id: 'yes', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) @@ -418,7 +419,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) expect(events.at(-1)).toEqual({ @@ -449,7 +450,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const late: AgentSessionStatusEvent[] = [] @@ -481,7 +482,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION) @@ -500,12 +501,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fix the auth bug' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) @@ -533,7 +534,7 @@ describe('StructuredAgentSessionStatusFeed', () => { role: 'user', blocks: [{ type: 'text', text: 'Fix auth' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const seen: (string | null)[] = [] const { feed } = feedFor(new Map([[SESSION, { journal }]]), null, (summary) => @@ -613,7 +614,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) expect(() => feed.publish(SESSION, journal)).not.toThrow() @@ -628,12 +629,12 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( TURN_IDENTITY, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const snapshot = vi.spyOn(journal, 'snapshot') let taskState: 'working' | 'waiting' = 'working' @@ -662,7 +663,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const record = { options: { model: 'first-model' }, providerHandleChain: [] } const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), record) @@ -692,7 +693,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) expect(events.at(-1)).toEqual({ @@ -731,7 +732,7 @@ describe('StructuredAgentSessionStatusFeed', () => { await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) const before = events.length @@ -792,7 +793,7 @@ describe('the status sink sees the roster the broadcast cache deliberately lacks await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) // A second identical publication is deduped for the sink exactly as for subscribers, so the @@ -854,7 +855,7 @@ describe('the status sink sees the roster the broadcast cache deliberately lacks await journal.appendItem( USER_IDENTITY, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) feed.publish(SESSION, journal) expect(() => feed.forget(SESSION)).not.toThrow() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts index 57218d0f4bd..ad081c6774f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -39,7 +40,7 @@ async function openJournal(): Promise { await journal.appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 1 }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return journal } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts index cb8b3d7d7c5..a0912e86dbc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // A subagent's work must not re-date the session that spawned it. // // The status row takes its completion stamp and acknowledgement clock from the summary's @@ -92,7 +93,7 @@ async function openSession() { journal.appendItem( { provider: 'orca', clientMessageId }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const latestStatus = () => { const event = events.findLast((candidate) => candidate.type === 'status') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index 82f8c551642..c39cfe44e1d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -2,7 +2,10 @@ 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 { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION +} from '../../../shared/agent-session-journal-types' import type { AgentSessionStatusEvent, AgentSessionSubscribeEvent @@ -116,7 +119,7 @@ describe('AgentSessionSubscribers', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'clocked' }, { kind: 'status', text: 'Clocked' }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) subscribers.publish(SESSION, journal) subscribers.backgroundTasks(SESSION, null, 1) @@ -253,12 +256,12 @@ describe('AgentSessionSubscribers', () => { await journal.appendItem( { ...turn, ordinal: 1 }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( turn, { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) subscribers.publish(SESSION, journal) @@ -318,7 +321,7 @@ describe('AgentSessionSubscribers', () => { await journal.appendItem( { provider: 'orca', clientMessageId: 'after-background-fence' }, { kind: 'status', text: 'After background state' }, - { fence: 2 } + { fence: 2, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) subscribers.publish(SESSION, journal) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index b04a9b3d820..4594c8c38bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // The lifetime of a provider child, against the real host rather than a double. // // Two leaks meet here: a chat that closes without stopping its app-server, and a launch that @@ -121,7 +122,8 @@ function envelope(method: string, fields: Record): AgentSession function emitTurnLifecycle(state: 'running' | 'completed', ordinal: number): void { sink?.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal }, - { kind: 'status', text: state, turnLifecycle: { turnId: 'turn-1', state } } + { kind: 'status', text: state, turnLifecycle: { turnId: 'turn-1', state } }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } @@ -157,7 +159,8 @@ async function failJournalSinkUntilReleased(): Promise { vi.spyOn(session!.journal, 'appendItem').mockRejectedValueOnce(new Error('disk unavailable')) sink?.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'lost write' }] } + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'lost write' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await vi.waitFor(() => { expect(closeSession).toHaveBeenCalledWith(SESSION) @@ -469,7 +472,8 @@ describe('a session closed and started again', () => { }) sink?.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 1 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'back again' }] } + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'back again' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) sink?.publish() await host.flushStreamedEvents(SESSION) @@ -797,7 +801,8 @@ describe('an unexpected provider exit', () => { expect(statuses).toContainEqual({ kind: 'status', text: UNEXPECTED_PROVIDER_EXIT_OUTCOME, - failure: { kind: 'providerExited' } + failure: { kind: 'providerExited' }, + tone: 'error' }) expect(statuses.map((status) => status.text).join('\n')).not.toContain('provider exited') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index f490eb2e9cd..9fddf6c7b34 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -57,7 +58,7 @@ function appendGoalRow( return journal.appendItem( { provider: 'orca', clientMessageId: `goal-row:${journal.snapshot().items.length}` }, { kind: 'status', text: 'Goal', threadGoal: { state: 'set', goal: { ...GOAL, ...overrides } } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index 639daf6ab30..a779ba51abe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -70,7 +70,8 @@ export async function performThreadGoalChange( blocks: [{ type: 'text', text: change.objective }], sentAs: 'goal' }, - { fence: ctx.fence } + // Accepting a goal is delivering it, so it joins whatever turn runs now. + { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() } ) } const withdrawObjective = async (): Promise => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts index 93d4a54cdc9..1c2ff9b9e53 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.test.ts @@ -344,6 +344,39 @@ describe('StructuredAgentSessionTurnCompletionFeed', () => { h.feed.observe('session-unknown') expect(emit).not.toHaveBeenCalled() }) + + it('announces no /compact turn and keeps its mark on the last real turn (B6)', () => { + const h = harness() + h.listen() + const real = [userEntry('m1', 1), turnItem(turn('t1', 'completed', 'success'), 2)] + const accepted = sent('m1', { dispatchState: 'accepted' }) + h.setJournal(real, [accepted]) + h.observe() + const command: AgentJournalRenderItem = { + ...userEntry('c1', 3), + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + } + } + const commandTurn = (lifecycle: AgentJournalTurnLifecycle): AgentJournalRenderItem => ({ + ...turnItem({ ...lifecycle, userItemId: command.itemId }, 4), + itemId: 'orca:command-turn:c1' + }) + h.setJournal( + [...real, command, commandTurn(turn('compact:c1', 'running'))], + [accepted, pending('c1')] + ) + h.observe() + h.setJournal( + [...real, command, commandTurn(turn('compact:c1', 'completed', 'success'))], + [accepted, sent('c1', { dispatchState: 'accepted' })] + ) + h.observe() + expect(h.events).toEqual([]) + }) }) describe('a request the agent or its start refused', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts new file mode 100644 index 00000000000..4bd5b412300 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -0,0 +1,125 @@ +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionCancelResult } from '../../../shared/agent-session-wire' +import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' +import type { AgentSessionCancelOutcome } from './structured-agent-session-adapter' +import { + isStructuredAgentSessionCommandTurnId, + structuredAgentSessionCommandWasStopped, + structuredAgentSessionStopNoteIdentity +} from './structured-agent-session-command-turn' +import { validatePendingPrompt } from './structured-agent-session-prompt-state' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +export async function performCancel( + ctx: AgentSessionTurnContext, + input: { + clientOperationId: string + /** Absent: whatever the conversation has in flight; present: only while that turn is current. */ + turnId?: string + scope?: 'background-tasks' + taskId?: string + prompt?: { itemId: string; expectedRevision: number } + /** Ends the provider child, for a running command the provider did not take the Stop on. */ + stopChild?: () => Promise + } +): Promise> { + if (input.prompt) { + const validated = validatePendingPrompt(ctx, input.prompt) + if (!validated.ok) { + return validated + } + } + let cancelled = false + let note: AgentJournalStatusItem | null = { kind: 'status', text: 'Cancellation requested.' } + // The turn the Stop names, read before the cancel settles it: the note reports on that turn. + const turnScope = ctx.journal.liveTurnScope() + // Only the provider's end or the child's ends a command. A command the provider has not opened a + // turn for, would not interrupt, or was already asked to stop, ends with its child; that child's + // dead-generation settlement writes the command's verdict. + const liveTurnId = ctx.journal.activeTurnId() + const runningCommand = + input.stopChild !== undefined && + liveTurnId !== null && + isStructuredAgentSessionCommandTurnId(liveTurnId) && + (input.turnId === undefined || input.turnId === liveTurnId) + const stoppedBefore = + runningCommand && structuredAgentSessionCommandWasStopped(ctx.journal, liveTurnId) + try { + const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) + const outcome: AgentSessionCancelOutcome = stoppedBefore + ? { cancelled: false } + : input.scope + ? { + cancelled: + ( + await ctx.adapter.stopBackgroundTasks?.({ + sessionId: ctx.sessionId, + fence: ctx.fence, + ...(input.taskId ? { taskId: input.taskId } : {}) + }) + )?.cancelled === true + } + : await ctx.adapter.cancelTurn({ + sessionId: ctx.sessionId, + ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), + fence: ctx.fence, + // The journal is what the client read to name a turn, so it is what judges the request. + resolveLiveTurnId: () => ctx.journal.activeTurnId(), + ...(dispatchStatus ? { dispatchStatus } : {}), + ...(input.prompt ? { prompt: { itemId: input.prompt.itemId } } : {}) + }) + cancelled = outcome.cancelled + if (!cancelled && input.turnId !== undefined) { + note = { kind: 'status', text: 'The provider had already finished this turn.' } + } else if (!cancelled && input.prompt) { + note = null + } else if (!cancelled && outcome.unconfirmed) { + note = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { + surface: 'row' + }) + } + } else if (!cancelled) { + // Sent only while the chat reads working, so a Stop that ended nothing must say why. + const detail = outcome.refusal?.detail + note = { + kind: 'status', + ...agentSessionFailureWords( + agentSessionFailureFact('stopRefused', detail ? { detail } : {}), + { ...ctx.failureTextContext, surface: 'row' } + ) + } + } + } catch (error) { + if (input.prompt) { + throw error + } + // The adapter's error is Orca's; the row says only that the stop is unconfirmed. + note = { + kind: 'status', + ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) + } + } + if (runningCommand && !cancelled) { + await input.stopChild?.() + cancelled = true + note = { kind: 'status', text: 'Cancellation requested.' } + } + if (cancelled && input.prompt) { + await ctx.flushStreamedEvents() + } + const value = { ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), cancelled } + if (input.scope || note === null) { + return { ok: true, value } + } + // Keyed by the operation id so a replayed cancel upserts one item, not two. + await ctx.journal.appendItem( + structuredAgentSessionStopNoteIdentity(input.clientOperationId), + note, + { fence: ctx.fence, turnScope } + ) + return { ok: true, value } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index f10b64be1db..370dd12500d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -113,9 +113,8 @@ export async function performPrompt( committed.item = await ctx.journal.appendItem( identity, { ...prompt, resolution }, - { - fence: ctx.fence - } + // A revision: the prompt keeps the turn it was raised in. + { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() } ) } }) @@ -138,7 +137,7 @@ export async function performPrompt( surface: 'row' }) }, - { fence: ctx.fence } + { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() } ) } const appended = committed.item diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 576743ebac4..302f6dd812f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -43,7 +44,7 @@ describe('performCancel', () => { text: 'Agent is working…', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const cancelTurn = vi.fn(async () => ({ cancelled: true })) const ctx: AgentSessionTurnContext = { @@ -91,7 +92,7 @@ describe('performCancel', () => { text: 'Agent is working…', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) let resolveLiveTurnId: (() => string | null) | undefined const cancelTurn = vi.fn( @@ -123,7 +124,7 @@ describe('performCancel', () => { text: 'Done.', turnLifecycle: { turnId: 'turn-1', state: 'completed' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) expect(resolveLiveTurnId?.()).toBeNull() }) @@ -143,7 +144,7 @@ describe('performCancel', () => { text: 'Agent is working…', turnLifecycle: { turnId: 'turn-1', state: 'running' } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const ctx: AgentSessionTurnContext = { sessionId: 'session-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 46c4638dc4e..61d47b4f408 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -13,30 +13,31 @@ import { } from '../../../shared/agent-session-failure-words' import type { AgentJournalMessageItem, - AgentJournalStatusItem, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { refuse, - type AgentSessionCancelResult, type AgentSessionRefusalReason, type AgentSessionSendResult, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' import type { - AgentSessionCancelOutcome, AgentSessionDispatchOutcome, StructuredAgentSessionAdapter, StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' -import { validatePendingPrompt } from './structured-agent-session-prompt-state' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + handOverStructuredAgentSessionCommand, + structuredAgentSessionHandoverOrigin, + type StructuredAgentSessionCommandHandoverContext +} from './structured-agent-session-command-turn' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' +export { performCancel } from './structured-agent-session-turns-cancel' export type AgentSessionTurnContext = { sessionId: string @@ -99,14 +100,6 @@ async function dispatchSafely( } } -async function appendStatus( - ctx: AgentSessionTurnContext, - clientMessageId: string, - body: AgentJournalStatusItem -): Promise { - await ctx.journal.appendItem({ provider: 'orca', clientMessageId }, body, { fence: ctx.fence }) -} - /** * One id, one delivery. A submission that already exists replays its recorded * outcome and NEVER goes back on the wire, whatever state it is in and whatever @@ -154,13 +147,7 @@ export async function performSend( } } -export type AgentSessionHandoverContext = Pick< - AgentSessionTurnContext, - 'sessionId' | 'journal' | 'fence' | 'adapter' | 'providerChildPhase' -> & { - /** Who a start failure met at dispatch names, as the start's own row does. */ - failureTextContext?: AgentSessionFailureWordsContext -} +export type AgentSessionHandoverContext = StructuredAgentSessionCommandHandoverContext /** * Hands one queued submission to the provider. The `dispatch{pending}` row goes first: a crash @@ -181,10 +168,25 @@ export async function handOverSubmission( }) return } - await ctx.journal.resolveDispatch({ clientMessageId, state: 'pending', fence: ctx.fence }) - // The row written at acceptance is the send's instant on the host clock; the turn this - // dispatch opens records it so the live counter never re-anchors at turn-open. - const outcome = await dispatchSafely(ctx, clientMessageId, body, submission.submittedAt) + if (body.command) { + await handOverStructuredAgentSessionCommand(ctx, submission, body) + return + } + // The message joins the turn running at handover, a steer, or opens its own. + await ctx.journal.resolveDispatch({ + clientMessageId, + state: 'pending', + fence: ctx.fence, + turnScope: ctx.journal.liveTurnScope() + }) + // The handover row's instant on the host clock; the turn this dispatch opens records it so the + // live counter never re-anchors at turn-open. + const outcome = await dispatchSafely( + ctx, + clientMessageId, + body, + structuredAgentSessionHandoverOrigin(ctx.journal, submission) + ) // An admission needs no dispatch row: the submission is already pending. if (outcome.state === 'admitted') { return @@ -237,89 +239,3 @@ function requireSubmission( } return submission } - -export async function performCancel( - ctx: AgentSessionTurnContext, - input: { - clientOperationId: string - /** Absent: whatever the conversation has in flight; present: only while that turn is current. */ - turnId?: string - scope?: 'background-tasks' - taskId?: string - prompt?: { itemId: string; expectedRevision: number } - } -): Promise> { - if (input.prompt) { - const validated = validatePendingPrompt(ctx, input.prompt) - if (!validated.ok) { - return validated - } - } - let cancelled = false - let note: AgentJournalStatusItem | null = { kind: 'status', text: 'Cancellation requested.' } - try { - const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) - const outcome: AgentSessionCancelOutcome = input.scope - ? { - cancelled: - ( - await ctx.adapter.stopBackgroundTasks?.({ - sessionId: ctx.sessionId, - fence: ctx.fence, - ...(input.taskId ? { taskId: input.taskId } : {}) - }) - )?.cancelled === true - } - : await ctx.adapter.cancelTurn({ - sessionId: ctx.sessionId, - ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), - fence: ctx.fence, - // The journal is what the client read to name a turn, so it is what judges the request. - resolveLiveTurnId: () => ctx.journal.activeTurnId(), - ...(dispatchStatus ? { dispatchStatus } : {}), - ...(input.prompt ? { prompt: { itemId: input.prompt.itemId } } : {}) - }) - cancelled = outcome.cancelled - if (!cancelled && input.turnId !== undefined) { - note = { kind: 'status', text: 'The provider had already finished this turn.' } - } else if (!cancelled && input.prompt) { - note = null - } else if (!cancelled && outcome.unconfirmed) { - note = { - kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { - surface: 'row' - }) - } - } else if (!cancelled) { - // Sent only while the chat reads working, so a Stop that ended nothing must say why. - const detail = outcome.refusal?.detail - note = { - kind: 'status', - ...agentSessionFailureWords( - agentSessionFailureFact('stopRefused', detail ? { detail } : {}), - { ...ctx.failureTextContext, surface: 'row' } - ) - } - } - } catch (error) { - if (input.prompt) { - throw error - } - // The adapter's error is Orca's; the row says only that the stop is unconfirmed. - note = { - kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), { surface: 'row' }) - } - } - if (cancelled && input.prompt) { - await ctx.flushStreamedEvents() - } - const value = { ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), cancelled } - if (input.scope || note === null) { - return { ok: true, value } - } - // Keyed by the operation id so a replayed cancel upserts one item, not two. - await appendStatus(ctx, input.clientOperationId, note) - return { ok: true, value } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts index 451629f7b45..2cb38a46634 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts @@ -93,6 +93,7 @@ describe('provider-exit settlement', () => { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, snapshot: () => ({ items: [lifecycleItem('turn-1', 1, { state: 'running', startedAt: 1_000 })] }), @@ -165,6 +166,7 @@ describe('provider-exit settlement', () => { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []) @@ -223,7 +225,18 @@ describe('provider-exit settlement', () => { body: { kind: 'status', text: exitOutcome('The agent'), - failure: { kind: 'providerExited' } + failure: { kind: 'providerExited' }, + tone: 'error' + }, + // The exit belongs to the turn it ended. + turnScope: { + kind: 'turn', + turnItemId: agentJournalItemKey({ + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-2', + ordinal: 0 + }) } }, { @@ -235,7 +248,8 @@ describe('provider-exit settlement', () => { state: 'interrupted', startedAt: 30, completedAt: 1_234 - } + }, + turnScope: { kind: 'thread' } } ] }) @@ -271,6 +285,7 @@ describe('provider-exit settlement', () => { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, snapshot: () => ({ items }), appendLifecycleBatch, markPendingSubmissionsUnknown: vi.fn(async () => []), @@ -313,7 +328,8 @@ describe('provider-exit settlement', () => { body: { kind: 'status', text: exitOutcome('Claude'), - failure: { kind: 'providerExited' } + failure: { kind: 'providerExited' }, + tone: 'error' } }) ]) @@ -327,6 +343,7 @@ describe('provider-exit settlement', () => { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, snapshot: () => ({ items: [] }), appendLifecycleBatch: vi.fn(async () => ({ epoch: 'epoch-1', sequence: 1 })), markPendingSubmissionsUnknown, @@ -364,7 +381,8 @@ describe('provider-exit settlement', () => { body: { kind: 'status', text: exitOutcome('Claude'), - failure: { kind: 'providerExited' } + failure: { kind: 'providerExited' }, + tone: 'error' } }) ] @@ -377,6 +395,7 @@ describe('provider-exit settlement', () => { child: { generation: GENERATION, fence: 7, phase: 'ready' }, journal: { cursor: () => ({ epoch: 'epoch-1', sequence: 0 }), + itemBody: () => null, markPendingSubmissionsUnknown: vi.fn(async () => []), rejectPendingSubmissions: vi.fn(async () => []), snapshot: () => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 5025c5a6f82..3f758f81607 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -25,7 +25,7 @@ type UnexpectedExitLifecycleEvent = StructuredAgentSessionEndedEvent & { export type StructuredAgentSessionUnexpectedExitSession = Pick< StructuredAgentSessionHostSession, 'child' | 'lastEndedChild' -> & { journal: DeadGenerationJournal & Pick } +> & { journal: DeadGenerationJournal & Pick } export type StructuredAgentSessionUnexpectedExitContext< TSession extends StructuredAgentSessionUnexpectedExitSession = StructuredAgentSessionHostSession @@ -106,7 +106,7 @@ export async function settleUnexpectedStructuredAgentSessionExit< stableSettlementId, verdict: { state: 'interrupted', completedAt: observedAt }, exitedDuringStartup, - failureTextContext: structuredAgentSessionFailureWordsContext(record), + failureTextContext: structuredAgentSessionFailureWordsContext(record, session.journal), // A failed start always says why: no response was running to carry the reason. showUnexpectedExitOutcome: exitedDuringStartup || diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index a492b0b65ce..05c57a5941c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' // The profiles already shipped into a dead end. // // Every record here is a shape taken from a real wedged store: a lease that no acquisition, no @@ -213,7 +214,7 @@ async function seedRunningTurn(provider: 'codex' | 'claude' = 'codex'): Promise< ? { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } : { provider: 'claude', sessionId: 'provider-session-alpha-1', uuid: 'uuid-running' }, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: SEEDED_TURN_STARTED_AT }, - { fence: 13 } + { fence: 13, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() } @@ -582,7 +583,7 @@ describe('already-wedged profiles become usable on load', () => { await restoredJournal().appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 0 }, { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: NOW }, - { fence } + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) // A reconnecting client replays its attach; the same operation admits the live owner. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts index 585cbd4fbbd..cb9f4285d73 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts @@ -9,7 +9,10 @@ import type { import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' import { mobileE2EETextPayloadAdmissionBytes } from '../../runtime/rpc/mobile-e2ee-outbound-admission' -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION +} from '../../../shared/agent-session-journal-types' import { openJournalDatabase } from '../agent-session-journal/journal-database' import { journalDatabaseFile } from '../agent-session-journal/journal-paths' import { insertJournalRow } from '../agent-session-journal/journal-row-table' @@ -39,7 +42,10 @@ beforeEach(async () => { journalDir: root }) for (let ordinal = 1; ordinal <= 20; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { fence: 1 }) + await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index 366ce1cf948..c3186d00daf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -16,7 +16,7 @@ import { agentSessionProviderHandleChainHead, agentSessionProviderHandleRoot } from '../../../shared/agent-session-provider-handle' -import { latestStructuredAgentSessionUserItem } from '../../../shared/structured-agent-session-projection' +import { latestStructuredAgentSessionUserItem } from '../../../shared/structured-agent-session-latest-request' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { @@ -38,6 +38,12 @@ import { type AgentSessionRestartTask } from '../../../shared/agent-session-restart-activity' import { isLiveChildWork } from '../../../shared/agent-status-child-work-liveness' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { + isStructuredAgentSessionCommandEntry, + isStructuredAgentSessionCommandTurn +} from '../../../shared/structured-agent-session-command-entry' import { activeStructuredAgentSessionTurnId, newestStructuredAgentSessionTurn @@ -84,12 +90,31 @@ function structuredAgentSessionResumeWork( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[] ): AgentSessionResumeWork | null { + const bodies = new Map(items.map((item) => [item.itemId, item.body])) + const bodyOf = (itemId: string) => bodies.get(itemId) + // A conversation command in flight is nothing to resume: the user ran it, not the agent. + const newest = newestStructuredAgentSessionTurn(items) + const inFlightSubmission = pendingSubmissionInFlight(submissions) + if ( + (newest?.state === 'running' && isStructuredAgentSessionCommandTurn(newest, bodyOf)) || + (inFlightSubmission && + isStructuredAgentSessionCommandEntry( + bodyOf(agentJournalSubmissionKey(inFlightSubmission.clientMessageId)) + )) + ) { + return null + } const inFlight = structuredAgentSessionWorkInFlight(items, submissions) if (inFlight) { return inFlight } - const newest = newestStructuredAgentSessionTurn(items) - return newest ? { kind: 'turn', id: newest.turnId } : null + // A settled lead whose children were the work anchors on its last real turn. + const lastRequest = items.findLast((item) => { + const turn = readAgentJournalTurn(item.body) + return turn !== null && !isStructuredAgentSessionCommandTurn(turn, bodyOf) + }) + const turn = readAgentJournalTurn(lastRequest?.body) + return turn ? { kind: 'turn', id: turn.turnId } : null } function boundedLabel(text: string | undefined): string { diff --git a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.test.ts b/src/main/native-chat/agent-session-wire/structured-compaction-recovery.test.ts deleted file mode 100644 index 4c565bcf4e2..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { settleInterruptedCompaction } from './structured-compaction-recovery' - -describe('settleInterruptedCompaction', () => { - it("records the unconfirmed compaction in its fact's own words, on the row and the command", async () => { - const command = { - command: 'compact' as const, - state: 'unknown' as const, - phase: 'prepared' as const, - operationId: 'op-1', - callerKey: 'client-1', - runtimeFence: 1 - } - const setConversationCommand = vi.fn(async () => undefined) - const store = { - getRecord: () => ({ conversationCommand: command }), - setConversationCommand, - recordOperationOutcome: vi.fn(async () => undefined) - } - const appendItem = vi.fn(async () => ({ cursor: { epoch: 'e', sequence: 1 } })) - - await settleInterruptedCompaction( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the function reads only the members stubbed above. - store as unknown as AgentSessionRecordStore, - 'session-1', - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the function calls only `appendItem`. - { appendItem } as unknown as AgentSessionJournal, - 2 - ) - - const words = { - text: 'Compaction completion is unconfirmed.', - failure: { kind: 'compactionUnconfirmed' } - } - expect(appendItem).toHaveBeenCalledWith( - { provider: 'orca', clientMessageId: 'compact:op-1' }, - { kind: 'status', ...words }, - { fence: 2 } - ) - expect(setConversationCommand).toHaveBeenCalledWith( - 'session-1', - 2, - expect.objectContaining({ error: words.text, failure: words.failure, state: 'unknown' }) - ) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts b/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts deleted file mode 100644 index b581d746f62..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-compaction-recovery.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' - -/** A newly acquired owner cannot still be executing the previous owner's command. */ -export async function recoverInterruptedCompaction( - store: AgentSessionRecordStore, - sessionId: string, - journal: AgentSessionJournal, - fence: number -): Promise { - const command = store.getRecord(sessionId)?.conversationCommand - if (command?.runtimeFence === undefined || command.runtimeFence === fence) { - return - } - await settleInterruptedCompaction(store, sessionId, journal, fence) -} - -/** A compaction still prepared whose child can no longer finish it: its outcome is unknown. */ -export async function settleInterruptedCompaction( - store: AgentSessionRecordStore, - sessionId: string, - journal: AgentSessionJournal, - fence: number -): Promise { - const command = store.getRecord(sessionId)?.conversationCommand - if (command?.command !== 'compact' || command.phase !== 'prepared') { - return - } - const words = agentSessionFailureWords(agentSessionFailureFact('compactionUnconfirmed'), { - surface: 'row' - }) - await journal.appendItem( - { provider: 'orca', clientMessageId: `compact:${command.operationId}` }, - { kind: 'status', ...words }, - { fence } - ) - const recovered = { - ...command, - phase: 'committed' as const, - state: 'unknown' as const, - error: words.text, - failure: words.failure - } - await store.setConversationCommand(sessionId, fence, recovered) - await store.recordOperationOutcome({ - callerKey: command.callerKey, - operationId: command.operationId, - outcome: { status: 'succeeded', sessionId, conversationCommand: recovered } - }) -} diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts index fe3e443c495..be1002016b6 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.test.ts @@ -68,3 +68,40 @@ describe('conversationCommandBlocked background tasks', () => { ) }) }) + +describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () => { + const staleTurn = { + items: [{ itemId: 'turn-1', body: { kind: 'turn', turnId: 'turn-1', state: 'running' } }] + } + + it("does not refuse over a dead generation's running turn, which the start sweeps", () => { + const ctx = contextWith(null) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only each item's body. + ctx.journal.snapshot = () => staleTurn as never + expect(conversationCommandBlocked(ctx, RECORD, 'at-rest')).toBeNull() + expect(conversationCommandBlocked(ctx, RECORD)).toMatchObject({ + details: { reason: 'turnActive' }, + message: 'Wait for the current turn to finish before using this command.' + }) + }) + + it("ignores an older build's compaction record", () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the lease and the command record. + const record = { + lease: {}, + conversationCommand: { command: 'compact', phase: 'prepared', state: 'unknown' } + } as unknown as AgentSessionRecord + expect(conversationCommandBlocked(contextWith(null), record)).toBeNull() + }) + + it('at handover, lets the command itself and messages queued behind it wait', () => { + const ctx = contextWith(null) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only the dispatch fields. + const queued = [ + { clientMessageId: 'command', dispatchState: 'pending', handoverRecorded: true }, + { clientMessageId: 'behind', dispatchState: 'pending', handoverRecorded: true } + ] as never + ctx.journal.submissions = () => queued + expect(conversationCommandBlocked(ctx, RECORD, 'handover')).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts index cecdbbf0b13..06e6cadc537 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-admission.ts @@ -1,4 +1,5 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { @@ -14,9 +15,17 @@ function blocked( return refuse('agent_session_operation_invalid', { reason }, message) } +/** + * Why a conversation command may not run now; null when it may. + * + * `at-rest`: a command accepted with no child running. A running turn on record then belongs to a + * dead generation, which the start before handover sweeps, so it refuses nothing yet. + * `handover`: the command is the oldest queued message, and those queued behind it wait for it. + */ export function conversationCommandBlocked( - ctx: AgentSessionTurnContext, - record: AgentSessionRecord + ctx: Pick, + record: AgentSessionRecord, + admission?: 'at-rest' | 'handover' ): AgentSessionWireRefusal | null { const items = ctx.journal.snapshot().items if (record.rewind?.phase === 'prepared' || record.rewind?.phase === 'provider-succeeded') { @@ -32,8 +41,10 @@ export function conversationCommandBlocked( 'This conversation has been cleared. Open the current conversation to continue.' ) } + // An older build's compaction record belongs to a child this host no longer runs. if ( - record.conversationCommand?.state === 'unknown' && + record.conversationCommand?.command === 'clear' && + record.conversationCommand.state === 'unknown' && record.conversationCommand.phase === 'prepared' ) { return blocked( @@ -44,7 +55,7 @@ export function conversationCommandBlocked( if (record.lease.handoffStage || record.lease.handoffOperationId) { return blocked('handoffInFlight', 'Wait for the session handoff to finish.') } - if (activeStructuredAgentSessionTurnId(items)) { + if (admission !== 'at-rest' && activeStructuredAgentSessionTurnId(items)) { return blocked('turnActive', 'Wait for the current turn to finish before using this command.') } if ( @@ -74,7 +85,8 @@ export function conversationCommandBlocked( if ( ctx.journal.submissions().some( (entry) => - entry.dispatchState === 'pending' || + (entry.dispatchState === 'pending' && + !(admission === 'handover' && isQueuedAgentJournalSubmission(entry))) || // Doubt left by an earlier child is not this one's work in flight. (entry.dispatchState === 'unknown' && entry.recovered !== true && entry.fence === ctx.fence) ) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts index 662ec70e8dd..b0c4dd0f92e 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-controller.ts @@ -4,15 +4,21 @@ import { runStructuredConversationCommand, type ConversationCommandParams } from './structured-conversation-command' +import { runStructuredCompaction } from './structured-conversation-compaction' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import type { StructuredAgentSessionHost } from './structured-agent-session-host' export class StructuredConversationCommandController { + /** Held only by a clear, which replaces the conversation a send would land in. A compaction is + * a queued message, and sends accepted behind it wait for it in the queue. */ readonly pending = new Map() constructor( private readonly context: () => StructuredAgentSessionMutationContext, - private readonly host: Pick + private readonly host: Pick< + StructuredAgentSessionHost, + 'attach' | 'flushStreamedEvents' | 'waitForSendSettlement' + > ) {} send = ( caller: StructuredAgentSessionCaller, @@ -30,6 +36,9 @@ export class StructuredConversationCommandController { : sendStructuredAgentSessionTurn(this.context(), caller, params) run = (caller: StructuredAgentSessionCaller, params: ConversationCommandParams) => { + if (params.command === 'compact') { + return runStructuredCompaction(this.context(), this.host, caller, params) + } const key = JSON.stringify([caller.callerKey, params.envelope.clientOperationId]) const pending = this.pending.get(params.envelope.sessionId) if (pending && pending.key !== key) { diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.test.ts new file mode 100644 index 00000000000..a2c0e9aef6b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { structuredCompactionOutcome } from './structured-conversation-command-outcome' + +describe('structuredCompactionOutcome', () => { + it('is a success only when the provider reported the compaction', () => { + expect(structuredCompactionOutcome({ compacted: true, interruptRequested: true })).toEqual({ + outcome: 'success' + }) + }) + + it("reads no compaction after Orca's interrupt as the user's cancellation", () => { + expect( + structuredCompactionOutcome({ + compacted: false, + interruptRequested: true, + failed: { detail: { text: 'API Error: Request was aborted.', audience: 'person' } } + }) + ).toEqual({ outcome: 'cancellation' }) + }) + + it("reads a failure the provider reported as a failed compaction, keeping the provider's words", () => { + expect( + structuredCompactionOutcome({ + compacted: false, + interruptRequested: false, + failed: { detail: { text: 'Not enough messages to compact.', audience: 'person' } } + }) + ).toEqual({ + outcome: 'failure', + failure: { + kind: 'compactionFailed', + detail: { text: 'Not enough messages to compact.', audience: 'person' } + } + }) + expect( + structuredCompactionOutcome({ compacted: false, interruptRequested: false, failed: {} }) + ).toEqual({ outcome: 'failure', failure: { kind: 'compactionFailed' } }) + }) + + it('reads a compaction the provider never reported as a failure it did not confirm', () => { + expect(structuredCompactionOutcome({ compacted: false, interruptRequested: false })).toEqual({ + outcome: 'failure', + failure: { kind: 'compactionUnconfirmed' } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.ts new file mode 100644 index 00000000000..cc9513a6ad1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command-outcome.ts @@ -0,0 +1,44 @@ +// How a conversation command the provider ran ended, read the same way for every provider. + +import { + agentSessionFailureFact, + type AgentSessionFailureFact, + type ProviderDiagnostic +} from '../../../shared/agent-session-failure' + +export type StructuredConversationCommandOutcome = { + outcome: 'success' | 'failure' | 'cancellation' + /** On a failure: what its row reports. */ + failure?: AgentSessionFailureFact +} + +/** What the provider showed of one compaction while it ran. */ +export type StructuredCompactionEvidence = { + /** The provider reported the conversation compacted: Claude's boundary, Codex's item. */ + compacted: boolean + /** Orca asked the provider to stop the command. */ + interruptRequested: boolean + /** The provider said the compaction failed, with its words for a person when it gave any. */ + failed?: { detail?: ProviderDiagnostic } | null +} + +/** Only a compaction the provider reported doing is a success: Claude answers a stopped `/compact` + * with the same success result as a finished one, so the result's own verdict cannot decide. With + * none, one Orca asked to stop is the user's cancellation; anything else failed — reported as the + * provider's failure when it said so, and otherwise as a compaction it never confirmed. */ +export function structuredCompactionOutcome( + evidence: StructuredCompactionEvidence +): StructuredConversationCommandOutcome { + if (evidence.compacted) { + return { outcome: 'success' } + } + if (evidence.interruptRequested) { + return { outcome: 'cancellation' } + } + return { + outcome: 'failure', + failure: evidence.failed + ? agentSessionFailureFact('compactionFailed', { detail: evidence.failed.detail }) + : agentSessionFailureFact('compactionUnconfirmed') + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 445326f60b0..12e3c706208 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -10,7 +10,6 @@ import { AgentSessionAcquisitionRefusal, type StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import type { StructuredSessionCompactionResult } from './structured-session-compaction' import { HOST_TEST_NOW, HOST_TEST_SESSION, @@ -47,7 +46,7 @@ function commandParams(command: AgentSessionConversationCommand) { beforeEach(async () => { resetHostTestOperationIds() acquisitions = 0 - compact.mockReset().mockResolvedValue({ outcome: 'compacted' }) + compact.mockReset().mockResolvedValue({ state: 'accepted', providerIdentity: null }) directory = await mkdtemp(join(tmpdir(), 'orca-conversation-command-')) store = await AgentSessionRecordStore.open({ directory: join(directory, 'store'), @@ -109,84 +108,6 @@ afterEach(async () => { }) describe('host conversation commands', () => { - it('compacts once without an ordinary message submission and replays its receipt', async () => { - const params = commandParams('compact') - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: true, - value: { state: 'completed' } - }) - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: true, - replayed: true - }) - expect(compact).toHaveBeenCalledTimes(1) - expect(adapter.dispatch).not.toHaveBeenCalled() - const history = await host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) - expect(history.page.submissions).toEqual([]) - expect( - history.page.items.some( - (item) => item.body.kind === 'status' && item.body.turnLifecycle?.state === 'running' - ) - ).toBe(false) - }) - - it('reports provider compaction failure without a stuck lifecycle', async () => { - const detail = { text: 'Not enough messages to compact.', audience: 'person' as const } - compact.mockResolvedValue({ outcome: 'failed', detail }) - const failure = { kind: 'compactionFailed', detail } - expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ - ok: true, - value: { - state: 'completed', - error: 'Compaction failed: Not enough messages to compact.', - failure - } - }) - expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand?.state).toBe('completed') - const rows = await host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) - expect(rows.ok && rows.page.items.map((item) => item.body)).toContainEqual({ - kind: 'status', - text: 'Compaction failed: Not enough messages to compact.', - failure - }) - }) - - it('says only that compaction failed when the provider gave no words', async () => { - compact.mockResolvedValue({ outcome: 'failed' }) - expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ - ok: true, - value: { error: 'Compaction failed.', failure: { kind: 'compactionFailed' } } - }) - }) - - it('records a compaction the provider never confirmed as unconfirmed, not failed', async () => { - compact.mockResolvedValue({ outcome: 'unconfirmed' }) - const failure = { kind: 'compactionUnconfirmed' } - expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ - ok: true, - value: { error: 'Compaction completion is unconfirmed.', failure } - }) - const rows = await host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) - expect(rows.ok && rows.page.items.map((item) => item.body)).toContainEqual({ - kind: 'status', - text: 'Compaction completion is unconfirmed.', - failure - }) - }) - - it('keeps an unknown compaction from being executed again', async () => { - compact.mockRejectedValue(new Error('connection lost')) - const params = commandParams('compact') - await expect(host.conversationCommand(caller, params)).rejects.toThrow('connection lost') - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_operation_unknown' } - }) - expect(compact).toHaveBeenCalledTimes(1) - }) - - /** The replacement seeds from what the provider reports now, not from what the - * retired record happened to store — the same rule acquire and handoff apply. */ it('adopts the reported Fast preference into the replacement record', async () => { adapter.readOptions = async () => ({ models: [], @@ -319,41 +240,8 @@ describe('host conversation commands', () => { const params = commandParams('compact') params.envelope.expectedRuntimeFence++ expect(await host.conversationCommand(caller, params)).toMatchObject({ ok: true }) - expect(compact).toHaveBeenCalledTimes(1) + await vi.waitFor(() => expect(compact).toHaveBeenCalledTimes(1)) }) - it('allows cancellation while compaction is awaiting completion and refuses a second client', async () => { - let finish!: (value: StructuredSessionCompactionResult) => void - compact.mockImplementation( - () => - new Promise((resolve) => { - finish = resolve - }) - ) - const params = commandParams('compact') - const running = host.conversationCommand(caller, params) - await vi.waitFor(() => expect(compact).toHaveBeenCalled()) - expect( - await host.conversationCommand({ callerKey: 'mobile' }, commandParams('clear')) - ).toMatchObject({ ok: false }) - const turnId = `compact:${params.envelope.clientOperationId}` - const cancel = await host.cancel(caller, { - turnId, - envelope: { - ...params.envelope, - clientOperationId: hostTestOperationId(), - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.cancel', - sessionId: HOST_TEST_SESSION, - fields: { turnId } - }) - } - }) - expect(cancel).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(adapter.cancelTurn).toHaveBeenCalled() - finish({ outcome: 'compacted' }) - await running - }) - it('reconstructs a committed replacement after the ledger settlement is lost', async () => { const persist = store.recordOperationOutcome.bind(store) vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { @@ -372,19 +260,6 @@ describe('host conversation commands', () => { expect(acquisitions).toBe(2) }) - it('repairs an unknown receipt when the provider completes late', async () => { - compact.mockRejectedValue(new Error('connection lost')) - const params = commandParams('compact') - await expect(host.conversationCommand(caller, params)).rejects.toThrow() - await compact.mock.calls[0]![0].onLateResult?.({ outcome: 'compacted' }) - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: true, - replayed: true, - value: { state: 'completed' } - }) - expect(compact).toHaveBeenCalledTimes(1) - }) - it('keeps explicitly revealed history and closed replacement tabs out of automatic restoration', async () => { const result = await host.conversationCommand(caller, commandParams('clear')) if (!result.ok) { @@ -397,26 +272,4 @@ describe('host conversation commands', () => { await host.setSessionTabVisibility(result.value.replacementSessionId!, false) expect(host.conversationReplacements()).toEqual([]) }) - it('keeps the old compact outcome unknown but restores usability after verified reacquisition', async () => { - compact.mockRejectedValue(new Error('lost response')) - const params = commandParams('compact') - await expect(host.conversationCommand(caller, params)).rejects.toThrow() - await host.close(HOST_TEST_SESSION) - const fence = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence - expect(await host.attach(caller, hostTestAttachParams(fence))).toMatchObject({ ok: true }) - expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand).toMatchObject({ - phase: 'committed', - state: 'unknown' - }) - expect(await host.conversationCommand(caller, params)).toMatchObject({ - ok: true, - replayed: true, - value: { state: 'unknown' } - }) - compact.mockResolvedValue({ outcome: 'compacted' }) - expect(await host.conversationCommand(caller, commandParams('compact'))).toMatchObject({ - ok: true, - value: { state: 'completed' } - }) - }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 2075c24d7bb..af115cfff9e 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -23,39 +23,15 @@ import { import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' import type { StructuredAgentSessionHost } from './structured-agent-session-host' import { conversationCommandBlocked } from './structured-conversation-command-admission' -import { - agentSessionFailureFact, - type AgentSessionFailureFact -} from '../../../shared/agent-session-failure' +import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords, type AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' import { structuredAgentSessionStartFailureFact } from './structured-agent-session-failure-text' -import type { StructuredSessionCompactionResult } from './structured-session-compaction' - -/** A compaction that did not succeed, keeping only what the provider wrote for a person. */ -function compactionFailure( - result: StructuredSessionCompactionResult -): AgentSessionFailureFact | undefined { - switch (result.outcome) { - case 'compacted': - return undefined - case 'unconfirmed': - return agentSessionFailureFact('compactionUnconfirmed') - case 'failed': - return agentSessionFailureFact('compactionFailed', { detail: result.detail }) - } -} - -function compactionStatusBody(failure: AgentSessionFailureFact | undefined) { - return failure - ? { kind: 'status' as const, ...agentSessionFailureWords(failure, { surface: 'row' }) } - : { kind: 'status' as const, text: 'Conversation compacted.' } -} /** A command's `error` is the sentence its row shows. */ -function conversationCommandFailure( +export function conversationCommandFailure( failure: AgentSessionFailureFact | undefined, context: AgentSessionFailureWordsContext = {} ) { @@ -114,19 +90,7 @@ export function runStructuredConversationCommand( return outcome.conversationCommand } const prior = matching() - if (prior?.phase === 'committed') { - return prior - } - if (command === 'compact' && prior && outcome.status !== 'unknown') { - return { - command, - state: 'unknown', - error: 'Compaction completion is unconfirmed; it was not run again.' - } - } - return outcome.status === 'succeeded' && command === 'compact' - ? { command, state: 'completed' } - : null + return prior?.phase === 'committed' ? prior : null }, rerunWhenReplayMissing: () => command === 'clear' && matching()?.phase === 'prepared', run: async (ctx) => { @@ -158,7 +122,6 @@ export function runStructuredConversationCommand( ...(replacementSessionId ? { replacementSessionId } : {}) } await store.setConversationCommand(sessionId, ctx.fence, prepared) - let failure: AgentSessionFailureFact | undefined if (command === 'clear' && replacementSessionId) { const attach: AgentSessionAttachParams = { envelope: { @@ -211,78 +174,11 @@ export function runStructuredConversationCommand( await store.setConversationCommand(sessionId, ctx.fence, failed) return { ok: true, value: failed } } - } else { - if (!ctx.adapter.compact) { - throw new Error('Compaction is unavailable for this provider.') - } - const identity = { - provider: 'orca' as const, - clientMessageId: `compact:${clientOperationId}` - } - await ctx.journal.appendItem( - identity, - { - kind: 'status', - text: 'Compacting conversation…', - turnLifecycle: { turnId: `compact:${clientOperationId}`, state: 'running' } - }, - { fence: ctx.fence } - ) - try { - failure = compactionFailure( - await ctx.adapter.compact({ - turnId: `compact:${clientOperationId}`, - sessionId, - fence: ctx.fence, - onLateResult: (result) => - context.serialize(sessionId, async () => { - if ( - matching()?.phase !== 'prepared' || - context.sessions.get(sessionId)?.journal !== ctx.journal - ) { - return - } - await host.flushStreamedEvents(sessionId) - const late = compactionFailure(result) - await ctx.journal.appendItem(identity, compactionStatusBody(late), { - fence: ctx.fence - }) - await store.setConversationCommand(sessionId, ctx.fence, { - ...prepared, - phase: 'committed', - state: 'completed', - ...conversationCommandFailure(late) - }) - await store.recordOperationOutcome({ - callerKey: caller.callerKey, - operationId: clientOperationId, - outcome: { - status: 'succeeded', - sessionId, - conversationCommand: matching()! - } - }) - }) - }) - ) - await host.flushStreamedEvents(sessionId) - } catch (cause) { - await ctx.journal.appendItem( - identity, - compactionStatusBody(agentSessionFailureFact('compactionUnconfirmed')), - { fence: ctx.fence } - ) - throw cause - } - await ctx.journal.appendItem(identity, compactionStatusBody(failure), { - fence: ctx.fence - }) } const completed = { ...prepared, phase: 'committed' as const, - state: 'completed' as const, - ...conversationCommandFailure(failure) + state: 'completed' as const } await store.setConversationCommand(sessionId, ctx.fence, completed) return { ok: true, value: completed } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts new file mode 100644 index 00000000000..28eae2dff68 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -0,0 +1,751 @@ +// `/compact` travels the send path: accepted as the user's message, carried out by the delivery loop +// as a turn of its own, settled by re-reading the journal. Each case reads what a subscriber that +// was open before the command saw, or the journal a client would load. + +import { beforeEach, expect, it, vi, type Mock } from 'vitest' +import { + AgentJournalSubmissionSchema, + isAdmissibleAgentJournalItemBody +} from '../../../shared/agent-session-journal-schemas' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { structuredAgentSessionCommandTurn } from './structured-agent-session-command-turn' +import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' +import { + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage +} from './structured-agent-session-host-test-data' +import type { StructuredConversationCommandOutcome } from './structured-conversation-command-outcome' + +let state: ReturnType +let compact: Mock> +let closeSession: Mock> + +beforeEach(() => { + state = hostTestState() + // Codex's ack: the provider took the command, which its translator ends later. + compact = vi.fn(async () => ({ state: 'accepted' as const, providerIdentity: null })) + closeSession = vi.fn(async () => true) + Object.assign(state.host.deps.adapter, { compact, closeSession }) +}) + +/** What the child's journal translator writes when the provider ends the command: the command's + * one result row and its turn's end, in one batch. */ +function finish(result: StructuredConversationCommandOutcome): void { + const { command } = compact.mock.calls.at(-1)![0] + const events = state.acquire.mock.calls.at(-1)![0].events! + const turnScope = { kind: 'turn' as const, turnItemId: agentJournalItemKey(command.identity) } + const row: AgentJournalItemBody | null = + result.outcome === 'success' + ? { kind: 'status', text: 'Context compacted', presentation: 'compaction' } + : result.outcome === 'failure' + ? { + kind: 'status', + ...agentSessionFailureWords( + result.failure ?? agentSessionFailureFact('compactionUnconfirmed'), + { surface: 'row' } + ), + tone: 'error' + } + : null + events.appendLifecycleBatch!( + `turn-completed:${command.clientMessageId}`, + [ + ...(row + ? [{ kind: 'item' as const, identity: command.resultIdentity, body: row, turnScope }] + : []), + { + kind: 'item', + identity: command.identity, + body: { + ...command.running, + state: result.outcome === 'cancellation' ? 'interrupted' : 'completed', + outcome: result.outcome, + completedAt: HOST_TEST_NOW + }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ], + { lifecycle: true } + ) +} + +/** Claude's words for a compaction it refused, as its frames carry them. */ +const NOT_ENOUGH = { text: 'Not enough messages to compact.', audience: 'person' as const } + +function compactParams() { + return { + command: 'compact' as const, + envelope: envelope('agentSession.conversationCommand', { command: 'compact' }) + } +} + +function sendParams(text: string) { + const body = hostTestMessage(text) + return { envelope: envelope('agentSession.send', { body }), body } +} + +async function subscribe(): Promise { + const events: AgentSessionSubscribeEvent[] = [] + await state.host.subscribe({ + id: 'pane', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + return events +} + +/** One line per journal fact a subscriber received, in delivery order. */ +function frames(events: AgentSessionSubscribeEvent[]): string[] { + return events.flatMap((event) => + event.type === 'batch' + ? [ + ...event.batch.items.map(describeItem), + ...event.batch.submissions.map( + (entry) => `submission:${entry.dispatchState}${entry.handedOverAt ? ':handed' : ''}` + ) + ] + : [] + ) +} + +function describeItem(item: AgentJournalRenderItem): string { + const turn = readAgentJournalTurn(item.body) + if (turn) { + return `turn:${turn.state}${turn.outcome ? `:${turn.outcome}` : ''}` + } + return item.body.kind === 'status' ? `status:${item.body.text}` : item.body.kind +} + +async function journal() { + return state.host.journalSnapshot(SESSION) +} + +async function commandTurn(clientMessageId: string) { + const { itemId } = structuredAgentSessionCommandTurn(clientMessageId) + return (await journal()).items.find((item) => item.itemId === itemId) +} + +it('answers at handover, then journals its own entry, turn and result (B1, B16)', async () => { + await attach() + const events = await subscribe() + const params = compactParams() + const cmid = params.envelope.clientOperationId + + // The reply means "started"; the provider has not finished. + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + ok: true, + value: { command: 'compact', state: 'completed' } + }) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('completed') + ) + // A client saw the command working before it saw the command's end. + const facts = frames(events) + const running = facts.indexOf('turn:running') + const ended = facts.indexOf('turn:completed:success') + expect(running, facts.join('\n')).toBeGreaterThanOrEqual(0) + expect(ended).toBeGreaterThan(running) + expect(facts.indexOf('status:Context compacted')).toBeGreaterThan(running) + // No turn row was ever overwritten by another body kind. + const turnKey = structuredAgentSessionCommandTurn(cmid).itemId + for (const event of events) { + for (const item of event.type === 'batch' ? event.batch.items : []) { + expect(item.itemId !== turnKey || item.body.kind === 'turn').toBe(true) + } + } + const snapshot = await journal() + const entry = snapshot.items.find((item) => item.body.kind === 'message') + expect(entry?.body).toMatchObject({ command: { name: 'compact' } }) + expect(entry?.turnScope).toEqual(AGENT_JOURNAL_THREAD_SCOPE) + const result = snapshot.items.find( + (item) => item.body.kind === 'status' && item.body.presentation === 'compaction' + ) + expect(result?.turnScope).toEqual({ kind: 'turn', turnItemId: turnKey }) + const submission = snapshot.submissions.find((item) => item.clientMessageId === cmid) + expect(submission).toMatchObject({ dispatchState: 'accepted', providerItemId: null }) + // An older client's schemas take the accepted submission with no provider item. + expect(AgentJournalSubmissionSchema.safeParse(submission).success).toBe(true) + expect(isAdmissibleAgentJournalItemBody(entry!.body)).toBe(true) + expect(state.dispatch).not.toHaveBeenCalled() +}) + +it('replays the reply for the same operation without running it again (B16)', async () => { + await attach() + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true + }) + expect(compact).toHaveBeenCalledOnce() +}) + +it('holds messages sent during the command and delivers them after it, in order (B2)', async () => { + await attach() + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await expect(state.host.send(CALLER, sendParams('first'))).resolves.toMatchObject({ ok: true }) + await expect(state.host.send(CALLER, sendParams('second'))).resolves.toMatchObject({ ok: true }) + // Nothing is handed over while the command's turn runs. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(state.dispatch).not.toHaveBeenCalled() + + finish({ + outcome: 'failure', + failure: agentSessionFailureFact('compactionFailed', { detail: NOT_ENOUGH }) + }) + + // Delivered even though the command failed. + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledTimes(2)) + expect(state.dispatch.mock.calls.map(([input]) => input.body.blocks)).toEqual([ + [{ type: 'text', text: 'first' }], + [{ type: 'text', text: 'second' }] + ]) + const turn = await commandTurn(params.envelope.clientOperationId) + expect(readAgentJournalTurn(turn?.body)).toMatchObject({ state: 'completed', outcome: 'failure' }) + const error = (await journal()).items.find( + (item) => item.body.kind === 'status' && item.body.tone === 'error' + ) + expect(error?.body).toMatchObject({ + text: 'Compaction failed: Not enough messages to compact.', + failure: { kind: 'compactionFailed', detail: NOT_ENOUGH } + }) +}) + +it('hands over a message held behind the command when the command ends just as the loop stops for it', async () => { + await attach() + await state.host.conversationCommand(CALLER, compactParams()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const { journal: live } = state.host['sessions'].get(SESSION)! + const activeTurnId = live.activeTurnId + let ended = false + vi.spyOn(live, 'activeTurnId').mockImplementation(() => { + const read = activeTurnId() + // The provider's end lands the moment the loop's own step reads the command as running and + // stops; no other reader's view of it matters here. + if ( + !ended && + read?.startsWith('compact:') && + new Error('who reads').stack?.includes('StructuredAgentSessionDeliveryLoop.prepare') + ) { + ended = true + finish({ outcome: 'success' }) + } + return read + }) + + await expect(state.host.send(CALLER, sendParams('held'))).resolves.toMatchObject({ ok: true }) + + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(ended).toBe(true) +}) + +it('settles a command the provider refused as a failure with its reason, and moves on (B3)', async () => { + await attach() + compact.mockResolvedValue({ + state: 'rejected', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { detail: NOT_ENOUGH }), + { + surface: 'rejection' + } + ) + }) + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await state.host.send(CALLER, sendParams('after the refusal')) + + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)).toMatchObject({ + state: 'completed', + outcome: 'failure' + }) + const snapshot = await journal() + // The message was not sent, in the provider's words; the command's row says the compaction failed. + expect(snapshot.submissions.find((entry) => entry.clientMessageId === cmid)).toMatchObject({ + dispatchState: 'rejected', + reason: 'The provider did not accept this message: Not enough messages to compact.', + rejection: { kind: 'providerRejected', detail: NOT_ENOUGH } + }) + expect( + snapshot.items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + ).toEqual([ + expect.objectContaining({ + body: { + kind: 'status', + text: 'Compaction failed: Not enough messages to compact.', + failure: { kind: 'compactionFailed', detail: NOT_ENOUGH }, + tone: 'error' + }, + turnScope: { kind: 'turn', turnItemId: structuredAgentSessionCommandTurn(cmid).itemId } + }) + ]) +}) + +it('says only that the compaction failed when the provider refused it without words', async () => { + await attach() + compact.mockResolvedValue({ + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('writeFailed'), { surface: 'rejection' }) + }) + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + + await vi.waitFor(async () => + expect( + (await journal()).items + .filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + .map((item) => item.body) + ).toEqual([ + { + kind: 'status', + text: 'Compaction failed.', + failure: { kind: 'compactionFailed' }, + tone: 'error' + } + ]) + ) +}) + +it('refuses the command at handover when the provider opened a turn meanwhile (B3)', async () => { + await attach() + const events = state.acquire.mock.calls.at(-1)?.[0].events + // The provider starts a turn of its own after acceptance, before the command is handed over. + Object.assign(state.host.deps.adapter, { + awaitStarted: vi.fn(async () => { + events?.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'provider-turn', ordinal: 0 }, + { kind: 'turn', turnId: 'provider-turn', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } + ) + }) + }) + const params = compactParams() + + const refused = { + kind: 'commandRefused', + refusal: { code: 'agent_session_operation_invalid', details: { reason: 'turnActive' } } + } + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + ok: true, + value: { state: 'completed', error: "This command didn't run. Try it again.", failure: refused } + }) + expect(compact).not.toHaveBeenCalled() + expect(await commandTurn(params.envelope.clientOperationId)).toBeUndefined() + expect( + (await journal()).submissions.find( + (entry) => entry.clientMessageId === params.envelope.clientOperationId + ) + ).toMatchObject({ + dispatchState: 'rejected', + reason: "This command didn't run. Try it again.", + rejection: refused + }) +}) + +it('leaves a command whose start failed not sent, beside one start-failure row (B3)', async () => { + await attach() + await state.host.close(SESSION) + state.acquire.mockRejectedValue(new Error('not signed in')) + const params = compactParams() + + // The next step is the command again, not a message. + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ + ok: true, + value: { + state: 'completed', + error: "Codex couldn't restart. Run /compact again.", + failure: { kind: 'restartFailed' } + } + }) + const snapshot = await journal() + expect(snapshot.items.filter((item) => readAgentJournalTurn(item.body))).toEqual([]) + // The start's own row, in the words the command's message was refused with. + expect( + snapshot.items + .filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + .map((item) => item.body) + ).toEqual([ + { + kind: 'status', + text: "Codex couldn't restart. Run /compact again.", + failure: expect.objectContaining({ kind: 'restartFailed' }), + tone: 'error' + } + ]) + expect( + snapshot.submissions.find( + (entry) => entry.clientMessageId === params.envelope.clientOperationId + ) + ).toMatchObject({ + dispatchState: 'rejected', + reason: "Codex couldn't restart. Run /compact again." + }) + expect(compact).not.toHaveBeenCalled() +}) + +function stop(turnId: string) { + return state.host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId }), + turnId + }) +} + +it('leaves the command to the provider when it takes the Stop, and ends it as cancelled (B4)', async () => { + await attach() + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const { turnId } = structuredAgentSessionCommandTurn(cmid) + + await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + + // The interrupt was taken, not answered: the command runs until the provider ends it. + expect(state.cancelTurn).toHaveBeenCalledWith(expect.objectContaining({ turnId })) + expect(closeSession).not.toHaveBeenCalled() + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('running') + finish({ outcome: 'cancellation' }) + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + ) + // A cancellation writes no result row. + expect((await journal()).items.some((item) => item.itemId.includes('command-result'))).toBe(false) +}) + +it('ends the command by stopping the child at a second Stop the provider never answered (B4)', async () => { + await attach() + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const { turnId } = structuredAgentSessionCommandTurn(cmid) + + // The provider takes the interrupt and then never answers it. + await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + expect(closeSession).not.toHaveBeenCalled() + await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(state.cancelTurn).toHaveBeenCalledOnce() + expect(closeSession).toHaveBeenCalledOnce() + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') +}) + +it('ends the command by stopping the child when the provider cannot take the Stop (B4)', async () => { + await attach() + // Codex before it opened the command's turn, or Claude refusing the interrupt. + state.cancelTurn.mockResolvedValue({ cancelled: false }) + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + + await expect(stop(structuredAgentSessionCommandTurn(cmid).turnId)).resolves.toMatchObject({ + ok: true, + value: { cancelled: true } + }) + + expect(closeSession).toHaveBeenCalledOnce() + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') + const notes = (await journal()).items.filter((item) => item.body.kind === 'status') + expect(notes.map((item) => item.body.kind === 'status' && item.body.text)).toEqual([ + 'Cancellation requested.' + ]) + // The next message starts a child of its own. + await state.host.send(CALLER, sendParams('after the stop')) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(state.acquire).toHaveBeenCalledTimes(2) +}) + +it('does not stop the child for a Stop naming a command that already ended (B4)', async () => { + await attach() + state.cancelTurn.mockResolvedValue({ cancelled: false }) + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + const { itemId, turnId } = structuredAgentSessionCommandTurn(params.envelope.clientOperationId) + await vi.waitFor(async () => + expect( + readAgentJournalTurn((await journal()).items.find((item) => item.itemId === itemId)?.body) + ?.state + ).toBe('completed') + ) + + await expect(stop(turnId)).resolves.toMatchObject({ ok: true, value: { cancelled: false } }) + expect(closeSession).not.toHaveBeenCalled() +}) + +it("answers a refused command's message before ending its turn, so a crash between them leaves a turn the sweep settles (B4)", async () => { + await attach() + // The one command end the host still writes: the provider refused it. A provider's own end is + // one batch its translator writes, with nothing to split. + compact.mockResolvedValue({ + state: 'rejected', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { detail: NOT_ENOUGH }), + { + surface: 'rejection' + } + ) + }) + const { journal: live } = state.host['sessions'].get(SESSION)! + const appendLifecycleBatch = live.appendLifecycleBatch.bind(live) + const crash = vi + .spyOn(live, 'appendLifecycleBatch') + .mockImplementation((input) => + input.settlementId.startsWith('command-settled:') + ? Promise.reject(new Error('host crashed')) + : appendLifecycleBatch(input) + ) + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(crash).toHaveBeenCalled()) + crash.mockRestore() + + // Never an ended turn whose message still reads as in flight. + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('running') + expect( + (await journal()).submissions.find((entry) => entry.clientMessageId === cmid)?.dispatchState + ).toBe('rejected') + await settleStaleStructuredAgentSessionState({ + journal: live, + sessionId: SESSION, + fence: state.store.getRecord(SESSION)!.lease.runtimeFence, + acquisitionGeneration: null, + deathEvidence: null + }) + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('unverifiable') +}) + +it('counts a message held behind the command from its handover, not its send', async () => { + await attach() + await state.host.conversationCommand(CALLER, compactParams()) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const sent = await state.host.send(CALLER, sendParams('held')) + expect(sent.ok).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 20)) + finish({ outcome: 'success' }) + + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + const [dispatched] = state.dispatch.mock.calls[0]! + const submission = (await journal()).submissions.find( + (entry) => entry.clientMessageId === dispatched.clientMessageId + ) + expect(submission?.handedOverAt).toBeGreaterThan(submission!.submittedAt) + expect(dispatched.requestedAt).toBe(submission?.handedOverAt) +}) + +it('settles a command whose adapter call threw after the start as unknown (B4)', async () => { + await attach() + compact.mockImplementation(() => { + throw new Error('codex app-server session is not live') + }) + const params = compactParams() + const cmid = params.envelope.clientOperationId + + await expect(state.host.conversationCommand(CALLER, params)).resolves.toMatchObject({ ok: true }) + + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('unverifiable') + ) + expect( + (await journal()).submissions.find((entry) => entry.clientMessageId === cmid) + ).toMatchObject({ dispatchState: 'unknown', reason: 'codex app-server session is not live' }) +}) + +it('writes one exit row when the child dies mid-command, and the loop writes nothing but moves on (B4)', async () => { + state.acquire.mockImplementation(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: `generation-${fence}`, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + // The next child resumes the thread, as a real one does. + origin: state.store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: 1 + } + })) + await attach() + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + await expect(state.host.send(CALLER, sendParams('queued behind it'))).resolves.toMatchObject({ + ok: true + }) + + // The adapter never answers the command: the host's own record of the child's end is enough. + const fence = state.store.getRecord(SESSION)!.lease.runtimeFence + await state.host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence, + acquisitionGeneration: `generation-${fence}`, + reason: 'provider exited', + cause: 'unexpected-exit' + }) + + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') + ) + // Released from the command, the loop starts a child and delivers what waited behind it. + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(state.acquire).toHaveBeenCalledTimes(2) + const snapshot = await journal() + expect( + snapshot.items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + ).toHaveLength(1) + expect(snapshot.items.some((item) => item.itemId.includes('command-result'))).toBe(false) + // Codex acknowledged the command, so the message was delivered; only its turn was cut short. + expect(snapshot.submissions.find((entry) => entry.clientMessageId === cmid)?.dispatchState).toBe( + 'accepted' + ) +}) + +it('delivers the next message after a command whose child died and whose settlement could not be written', async () => { + state.acquire.mockImplementation(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + acquisitionGeneration: `generation-${fence}`, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: state.store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: 1 + } + })) + await attach() + const params = compactParams() + const cmid = params.envelope.clientOperationId + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + const { journal: live } = state.host['sessions'].get(SESSION)! + const appendLifecycleBatch = live.appendLifecycleBatch.bind(live) + vi.spyOn(live, 'appendLifecycleBatch').mockImplementation((input) => + input.settlementId.includes('provider-exit:') + ? Promise.reject(new Error('disk full')) + : appendLifecycleBatch(input) + ) + + const fence = state.store.getRecord(SESSION)!.lease.runtimeFence + await state.host.handleAdapterEvent({ + type: 'ended', + sessionId: SESSION, + fence, + acquisitionGeneration: `generation-${fence}`, + reason: 'provider exited', + cause: 'unexpected-exit' + }) + await vi.waitFor(() => expect(state.host['sessions'].get(SESSION)?.child).toBeNull()) + // The exit it recorded settles what the failed write left running, so the command holds nothing. + await vi.waitFor(async () => + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).toBe('interrupted') + ) + + await expect(state.host.send(CALLER, sendParams('after it'))).resolves.toMatchObject({ + ok: true + }) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + expect(state.acquire).toHaveBeenCalledTimes(2) + expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).not.toBe('running') +}) + +it("ignores an older build's unconfirmed compaction record, and answers its operation without rerunning it (B15)", async () => { + await attach() + const older = compactParams() + // An older build admitted this operation and died before recording its outcome. + await state.store.admitMutationOperation({ + callerKey: CALLER.callerKey, + envelope: older.envelope, + hostFingerprint: older.envelope.payloadFingerprint, + now: HOST_TEST_NOW + }) + await state.store.setConversationCommand( + SESSION, + state.store.getRecord(SESSION)!.lease.runtimeFence, + { + command: 'compact', + runtimeFence: state.store.getRecord(SESSION)!.lease.runtimeFence, + operationId: older.envelope.clientOperationId, + callerKey: CALLER.callerKey, + phase: 'prepared', + state: 'unknown' + } + ) + + await expect(state.host.send(CALLER, sendParams('still works'))).resolves.toMatchObject({ + ok: true + }) + await vi.waitFor(() => expect(state.dispatch).toHaveBeenCalledOnce()) + await expect(state.host.conversationCommand(CALLER, compactParams())).resolves.toMatchObject({ + ok: true, + value: { state: 'completed' } + }) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + await vi.waitFor(async () => + expect((await journal()).submissions.every((entry) => entry.dispatchState === 'accepted')).toBe( + true + ) + ) + + await expect(state.host.conversationCommand(CALLER, older)).resolves.toMatchObject({ + ok: true, + value: { + state: 'unknown', + error: 'Compaction completion is unconfirmed.', + failure: { kind: 'compactionUnconfirmed' } + } + }) + expect(compact).toHaveBeenCalledOnce() +}) + +it('never lets a provider echo alias the command entry', async () => { + await attach() + const params = compactParams() + await state.host.conversationCommand(CALLER, params) + await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce()) + finish({ outcome: 'success' }) + const events = state.acquire.mock.calls.at(-1)?.[0].events + const echo = { provider: 'codex' as const, threadId: THREAD, turnId: 'later', ordinal: 0 } + events?.appendItem( + echo, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: '/compact' }] }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await state.host.flushStreamedEvents(SESSION) + expect((await journal()).items.some((item) => item.itemId === agentJournalItemKey(echo))).toBe( + true + ) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts new file mode 100644 index 00000000000..352497fe482 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts @@ -0,0 +1,156 @@ +// `/compact` sent through the command RPC: accepted into the conversation as the user's message, +// and answered once the delivery loop hands it over. + +import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command' +import { + agentSessionFailureFact, + readAgentSessionFailureFact +} from '../../../shared/agent-session-failure' +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { + AgentSessionMutationEnvelope, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + mutateStructuredAgentSession, + type StructuredAgentSessionMutationContext +} from './structured-agent-session-host-mutations' +import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' +import { + conversationCommandPlan, + type ConversationCommandAcceptance +} from './structured-agent-session-mutation-plans' +import { + sendPreparation, + structuredAgentSessionFailureWordsContext, + structuredAgentSessionSendBlock +} from './structured-agent-session-send-preparation' +import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from './structured-agent-session-send-settlement' +import { + conversationCommandFailure, + type ConversationCommandParams +} from './structured-conversation-command' +import { conversationCommandBlocked } from './structured-conversation-command-admission' + +/** + * `/compact` from a client that asks through the command RPC: accepted into the conversation like + * any message, and answered once it is handed over — the command has started, not finished. Its + * end reaches the chat as its own turn and result row. + */ +export async function runStructuredCompaction( + context: StructuredAgentSessionMutationContext, + host: Pick, + caller: StructuredAgentSessionCaller, + params: ConversationCommandParams +): Promise> { + const { sessionId, clientOperationId } = params.envelope + // An older build ran this operation id and recorded it on the session: answered, never rerun. + const priorRecord = (): AgentSessionConversationCommandResult | null => { + const prior = context.deps.store.getRecord(sessionId)?.conversationCommand + if (prior?.operationId !== clientOperationId || prior.callerKey !== caller.callerKey) { + return null + } + return prior.phase === 'committed' + ? prior + : { + command: 'compact', + state: 'unknown', + ...conversationCommandFailure(agentSessionFailureFact('compactionUnconfirmed')) + } + } + const accepted = await acceptStructuredConversationCommand(context, caller, params, priorRecord) + if (!accepted.ok) { + return accepted + } + if ('recorded' in accepted.value) { + return { ...accepted, value: accepted.value.recorded } + } + const settled = await host.waitForSendSettlement(sessionId, accepted.value.clientMessageId, { + until: 'handed-over', + budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS + }) + return { + ...accepted, + ...(settled ? { cursor: settled.cursor } : {}), + value: compactionReply(settled?.value.submission, { + ...structuredAgentSessionFailureWordsContext(context.deps.store.getRecord(sessionId)), + command: 'compact' + }) + } +} + +/** The reply shape clients already read: `completed` is now "started". */ +function compactionReply( + submission: AgentJournalSubmission | undefined, + context: AgentSessionFailureWordsContext +): AgentSessionConversationCommandResult { + const error = (reason: string | null, fallback: string) => (reason ?? fallback).slice(0, 4096) + if (!submission || isQueuedAgentJournalSubmission(submission)) { + return { command: 'compact', state: 'unknown', error: 'The command has not started yet.' } + } + if (submission.dispatchState === 'rejected') { + // The message's own fact, in the words its row shows; a row from an older host keeps its reason. + const rejection = readAgentSessionFailureFact(submission.rejection) + return rejection + ? { + command: 'compact', + state: 'completed', + ...conversationCommandFailure(rejection, context) + } + : { + command: 'compact', + state: 'completed', + error: error(submission.reason, 'The command was not run.') + } + } + return submission.dispatchState === 'unknown' + ? { + command: 'compact', + state: 'unknown', + error: error(submission.reason, 'The command may not have run.') + } + : { command: 'compact', state: 'completed' } +} + +/** A conversation command, accepted into the queue as the user's message. */ +function acceptStructuredConversationCommand( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope }, + priorRecord: () => AgentSessionConversationCommandResult | null +): Promise> { + const plan = conversationCommandPlan({ envelope: params.envelope, priorRecord }) + return mutateStructuredAgentSession( + context, + caller, + params.envelope, + { + ...plan, + run: async (ctx) => { + const record = context.deps.store.getRecord(ctx.sessionId) + const refusal = + record && + conversationCommandBlocked( + ctx, + record, + context.sessions.get(ctx.sessionId)?.child ? undefined : 'at-rest' + ) + const blocked = + structuredAgentSessionSendBlock(record) ?? + (refusal ? { ok: false as const, refusal } : null) + if (blocked) { + return blocked + } + const accepted = await plan.run(ctx) + if (accepted.ok) { + context.wakeDelivery(ctx.sessionId) + } + return accepted + } + }, + sendPreparation(context, params.envelope) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts index 42b6e8dd91d..a1d7e41100d 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-recovery.ts @@ -1,5 +1,7 @@ -import { restoreRewindJournalBody } from './structured-rewind-journal-body' -import { mergeRetainedHostLifecycleRows } from './structured-rewind-retained-host-rows' +import { + mergeRetainedHostLifecycleRows, + retainedRowReplacement +} from './structured-rewind-retained-host-rows' import { isDeepStrictEqual } from 'node:util' import { agentJournalItemKey, @@ -141,13 +143,7 @@ export async function recoverStructuredRewind( if (rewind.phase !== 'provider-succeeded') { return } - const replacement = rewind.retained.map((item) => { - const identity = parseAgentJournalItemKey(item.itemId) - if (!identity) { - throw new Error('agent_session_rewind:invalid-retained-identity') - } - return { identity, body: restoreRewindJournalBody(item.body), observedAt: item.observedAt } - }) + const replacement = rewind.retained.map(retainedRowReplacement) // A crash after the journal transaction must settle its existing epoch, not replace it twice. const alreadyReplaced = journal.cursor().epoch !== rewind.expectedEpoch if ( diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts new file mode 100644 index 00000000000..5b47be7f9f8 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { + AgentSessionRewindRecordSchema, + type AgentSessionRewindRecord +} from '../../../shared/agent-session-rewind' +import { + mergeRetainedHostLifecycleRows, + retainedRowReplacement +} from './structured-rewind-retained-host-rows' +import { restoreRewindJournalBody } from './structured-rewind-journal-body' +import type * as RewindJournalBody from './structured-rewind-journal-body' + +vi.mock('./structured-rewind-journal-body', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, restoreRewindJournalBody: vi.fn(actual.restoreRewindJournalBody) } +}) + +type Retained = AgentSessionRewindRecord['retained'][number] + +const codexKey = (turnId: string, ordinal: number) => + agentJournalItemKey({ provider: 'codex', threadId: 'thread-1', turnId, ordinal }) +const orcaKey = (clientMessageId: string) => + agentJournalItemKey({ provider: 'orca', clientMessageId }) + +function prose(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +function retained(itemId: string, body: AgentJournalItemBody, extra: Partial = {}) { + return { itemId, body, observedAt: 1, ...extra } +} + +/** Provider history carries no scope or producer of its own. */ +function providerItem(itemId: string, body: AgentJournalItemBody): Retained { + return { itemId, body, observedAt: 2 } +} + +const TURN_RECORD = orcaKey('turn-a') +const inTurnA = { kind: 'turn', turnItemId: TURN_RECORD } + +describe('rewind keeps each retained row attributed', () => { + it("keeps a held row's scope and producer, a subagent's row staying the subagent's", () => { + const own = codexKey('a', 1) + const child = codexKey('a', 2) + const reference = [ + retained(TURN_RECORD, { kind: 'turn', turnId: 'a', state: 'completed' }), + retained(own, prose('own'), { turnScope: inTurnA }), + retained(child, prose('child'), { + turnScope: inTurnA, + agentId: 'child-thread', + parentAgentId: 'root', + producerKind: 'agent' + }) + ] + const merged = mergeRetainedHostLifecycleRows(reference, [ + providerItem(own, prose('own')), + providerItem(child, prose('child')) + ]) + expect(merged.find((item) => item.itemId === own)).toMatchObject({ turnScope: inTurnA }) + expect(merged.find((item) => item.itemId === own)?.agentId).toBeUndefined() + expect(merged.find((item) => item.itemId === child)).toMatchObject({ + turnScope: inTurnA, + agentId: 'child-thread', + parentAgentId: 'root', + producerKind: 'agent', + observedAt: 2 + }) + }) + + it("splices a command's turn, result and entry back, since provider history holds none", () => { + const before = codexKey('a', 1) + const commandTurn = orcaKey('command-turn:cmd-1') + const commandResult = orcaKey('command-result:cmd-1') + const entry = orcaKey('cmd-1') + const reference = [ + retained(before, prose('before')), + retained(entry, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + retained(commandTurn, { kind: 'turn', turnId: 'compact:cmd-1', state: 'completed' }), + retained( + commandResult, + { kind: 'status', text: 'Conversation compacted.', presentation: 'compaction' }, + { turnScope: { kind: 'turn', turnItemId: commandTurn } } + ) + ] + const merged = mergeRetainedHostLifecycleRows(reference, [ + providerItem(before, prose('before')) + ]) + expect(merged.map((item) => item.itemId)).toEqual([before, entry, commandTurn, commandResult]) + expect(merged.at(-1)?.turnScope).toEqual({ kind: 'turn', turnItemId: commandTurn }) + }) + + it('places a provider item the old epoch never held in the turn record for its provider turn', () => { + const commandTurn = orcaKey('command-turn:cmd-1') + const reference = [ + retained(TURN_RECORD, { kind: 'turn', turnId: 'a', state: 'completed' }), + retained(commandTurn, { + kind: 'turn', + turnId: 'compact:cmd-1', + state: 'completed', + providerTurnId: 'b' + }) + ] + const merged = mergeRetainedHostLifecycleRows(reference, [ + providerItem(codexKey('a', 1), prose('in a')), + providerItem(codexKey('b', 1), prose('claimed by the command')), + providerItem(codexKey('c', 1), prose('no record')) + ]) + const scopeOf = (itemId: string) => merged.find((item) => item.itemId === itemId)?.turnScope + expect(scopeOf(codexKey('a', 1))).toEqual(inTurnA) + expect(scopeOf(codexKey('b', 1))).toEqual({ kind: 'turn', turnItemId: commandTurn }) + // No record to join: the rebuilt epoch places it by position. + expect(scopeOf(codexKey('c', 1))).toBeUndefined() + }) +}) + +describe('the rebuilt epoch item for a retained row', () => { + it('passes a placeable scope and a known producer through', () => { + expect( + retainedRowReplacement( + retained(codexKey('a', 1), prose('x'), { + turnScope: inTurnA, + agentId: 'child', + producerKind: 'agent', + attempt: 2 + }) + ) + ).toMatchObject({ turnScope: inTurnA, agentId: 'child', producerKind: 'agent', attempt: 2 }) + }) + + it('drops a scope and a producer kind this build cannot place', () => { + const replacement = retainedRowReplacement( + retained(codexKey('a', 1), prose('x'), { + turnScope: { kind: 'future-kind' }, + producerKind: 'future-producer' + }) + ) + expect(replacement.turnScope).toBeUndefined() + expect(replacement.producerKind).toBeUndefined() + expect( + retainedRowReplacement( + retained(codexKey('a', 1), prose('x'), { turnScope: { kind: 'turn' } }) + ).turnScope + ).toBeUndefined() + }) + + it('leaves a row from an older record unscoped, for the rebuild to derive', () => { + const replacement = retainedRowReplacement(retained(codexKey('a', 1), prose('x'))) + expect(replacement).not.toHaveProperty('turnScope') + expect(replacement).not.toHaveProperty('agentId') + }) + + it('still reads a persisted record written before rows carried scope or producer', () => { + const older = { + operationId: 'op-1', + callerKey: 'desktop', + itemId: codexKey('b', 0), + expectedEpoch: 'epoch-1', + phase: 'prepared', + retained: [{ itemId: codexKey('a', 1), body: prose('x'), observedAt: 1 }] + } + expect(AgentSessionRewindRecordSchema.safeParse(older).success).toBe(true) + }) +}) + +describe('rewind merge cost', () => { + it('reads each merged row once to place the provider items it never held', () => { + const turns = 400 + const providerItems = Array.from({ length: turns }, (_, index) => + providerItem(codexKey(`t${index}`, 1), prose(`answer ${index}`)) + ) + const reference = Array.from({ length: turns }, (_, index) => + retained(orcaKey(`turn-${index}`), { + kind: 'turn', + turnId: `t${index}`, + state: 'completed' + }) + ) + vi.mocked(restoreRewindJournalBody).mockClear() + + const merged = mergeRetainedHostLifecycleRows(reference, providerItems) + + expect(merged.find((item) => item.itemId === codexKey(`t${turns - 1}`, 1))).toMatchObject({ + turnScope: { kind: 'turn', turnItemId: orcaKey(`turn-${turns - 1}`) } + }) + // A scan per provider item reads every merged row for each one: turns² reads. + expect(vi.mocked(restoreRewindJournalBody).mock.calls.length).toBeLessThanOrEqual(merged.length) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts index 35307566599..6496518b50b 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts @@ -1,18 +1,30 @@ // Provider preflight returns provider items only. The host's lifecycle rows are its own record, so // a rewind that takes the provider list as the new epoch must splice those rows back beside the -// provider item each one followed. +// provider item each one followed. Provider items carry neither turn scope nor producer, so each +// keeps the ones its retained row held. import { parseCodexGoalJournalItemId } from '../../codex/codex-goal-journal-identity' -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalProducerLinkage, + AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { restoreRewindJournalBody } from './structured-rewind-journal-body' type RetainedRow = AgentSessionRewindRecord['retained'][number] export function isRetainedHostLifecycleRow(item: RetainedRow): boolean { + const identity = parseAgentJournalItemKey(item.itemId) return ( readAgentJournalTurn(item.body as AgentJournalItemBody) !== null || - parseCodexGoalJournalItemId(item.itemId) !== null + parseCodexGoalJournalItemId(item.itemId) !== null || + // A conversation command's entry and result: the provider's history holds neither. + (item.body.kind === 'message' && item.body.command !== undefined) || + (identity?.provider === 'orca' && identity.clientMessageId.startsWith('command-')) ) } @@ -22,6 +34,7 @@ export function mergeRetainedHostLifecycleRows( providerItems: readonly RetainedRow[] ): RetainedRow[] { const spineIndex = new Map(providerItems.map((item, index) => [item.itemId, index])) + const held = new Map(reference.map((item) => [item.itemId, item])) const rowsAfter = new Map() let anchor = -1 for (const item of reference) { @@ -32,6 +45,77 @@ export function mergeRetainedHostLifecycleRows( } } const merged = [...(rowsAfter.get(-1) ?? [])] - providerItems.forEach((item, index) => merged.push(item, ...(rowsAfter.get(index) ?? []))) - return merged + providerItems.forEach((item, index) => + merged.push(withHeldAttribution(item, held.get(item.itemId)), ...(rowsAfter.get(index) ?? [])) + ) + const turnRecords = turnRecordsByProviderTurn(merged) + return merged.map((item) => + held.has(item.itemId) || item.turnScope ? item : withProviderTurnScope(item, turnRecords) + ) +} + +/** The rebuilt epoch's item for one retained row, with the scope and producer it was written with. */ +export function retainedRowReplacement(row: RetainedRow): AgentJournalProducerLinkage & { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + observedAt: number + turnScope?: AgentJournalTurnScope +} { + const identity = parseAgentJournalItemKey(row.itemId) + if (!identity) { + throw new Error('agent_session_rewind:invalid-retained-identity') + } + const { agentId, parentAgentId, providerParentRef, producerKind, attempt, turnScope } = row + const scope = + turnScope?.kind === 'turn' && turnScope.turnItemId + ? { kind: 'turn' as const, turnItemId: turnScope.turnItemId } + : turnScope?.kind === 'thread' + ? { kind: 'thread' as const } + : undefined + return { + identity, + body: restoreRewindJournalBody(row.body), + observedAt: row.observedAt, + ...(scope ? { turnScope: scope } : {}), + ...(agentId === undefined ? {} : { agentId }), + ...(parentAgentId === undefined ? {} : { parentAgentId }), + ...(providerParentRef === undefined ? {} : { providerParentRef }), + ...(producerKind === 'agent' || producerKind === 'background' ? { producerKind } : {}), + ...(attempt === undefined ? {} : { attempt }) + } +} + +function withHeldAttribution(item: RetainedRow, held: RetainedRow | undefined): RetainedRow { + if (!held) { + return item + } + const { itemId: _itemId, body: _body, observedAt: _observedAt, ...attribution } = held + return { ...item, ...attribution } +} + +/** Provider turn id → the item id of its turn record: the turn's own, or the command turn that + * claimed it. The first record wins, as a scan from the top would find it. */ +function turnRecordsByProviderTurn(merged: readonly RetainedRow[]): ReadonlyMap { + const records = new Map() + for (const candidate of merged) { + const turn = readAgentJournalTurn(restoreRewindJournalBody(candidate.body)) + for (const turnId of [turn?.turnId, turn?.providerTurnId]) { + if (turnId !== undefined && !records.has(turnId)) { + records.set(turnId, candidate.itemId) + } + } + } + return records +} + +/** A provider item the old epoch never held joins the turn record for its provider turn. + * Otherwise the rebuild places it. */ +function withProviderTurnScope( + item: RetainedRow, + turnRecords: ReadonlyMap +): RetainedRow { + const identity = parseAgentJournalItemKey(item.itemId) + const turnItemId = + identity?.provider === 'codex' && identity.turnId ? turnRecords.get(identity.turnId) : undefined + return turnItemId ? { ...item, turnScope: { kind: 'turn', turnItemId } } : item } diff --git a/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts deleted file mode 100644 index 8936a4a28d0..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { StructuredSessionCompaction } from './structured-session-compaction' - -describe('structured compaction lifecycle', () => { - it('waits beyond the Codex acknowledgment and ignores other threads', async () => { - const tracker = new StructuredSessionCompaction() - const finished = vi.fn() - const result = tracker - .run('session', 'thread', async () => undefined) - .then((value) => { - finished() - return value - }) - await Promise.resolve() - tracker.codex('session', 'turn/started', { threadId: 'other', turn: { id: 'foreign' } }) - tracker.codex('session', 'turn/completed', { - threadId: 'other', - turn: { id: 'foreign', status: 'completed' } - }) - expect(finished).not.toHaveBeenCalled() - tracker.codex('session', 'turn/started', { threadId: 'thread', turn: { id: 'compact-turn' } }) - tracker.codex('session', 'item/completed', { - threadId: 'thread', - item: { type: 'contextCompaction' } - }) - expect(finished).not.toHaveBeenCalled() - tracker.codex('session', 'turn/completed', { - threadId: 'thread', - turn: { id: 'compact-turn', status: 'completed' } - }) - await expect(result).resolves.toEqual({ outcome: 'compacted' }) - }) - - it('observes notifications arriving before the request acknowledgment', async () => { - const tracker = new StructuredSessionCompaction() - await expect( - tracker.run('s', 't', async () => { - tracker.codex('s', 'turn/started', { threadId: 't', turn: { id: 'c' } }) - tracker.codex('s', 'turn/completed', { - threadId: 't', - turn: { id: 'c', status: 'failed', error: { message: 'Unavailable' } } - }) - }) - ).resolves.toEqual({ - outcome: 'failed', - detail: { text: 'Unavailable', audience: 'person' } - }) - }) - - it.each(['success', 'failed'])( - 'uses Claude compact_result %s rather than result subtype', - async (state) => { - const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 'provider', async () => {}) - tracker.claude('s', { - type: 'system', - subtype: 'status', - session_id: 'provider', - compact_result: state, - compact_error: 'Not enough messages to compact.' - }) - tracker.claude('s', { - type: 'result', - subtype: 'success', - session_id: 'provider', - result: '' - }) - await expect(result).resolves.toEqual( - state === 'success' - ? { outcome: 'compacted' } - : { - outcome: 'failed', - detail: { text: 'Not enough messages to compact.', audience: 'person' } - } - ) - } - ) - - it('cleans up on provider exit and permits another operation', async () => { - const tracker = new StructuredSessionCompaction() - const pending = tracker.run('s', 'p', async () => {}) - tracker.ended('s') - await expect(pending).resolves.toEqual({ outcome: 'unconfirmed' }) - const next = tracker.run('s', 'p', async () => { - tracker.claude('s', { type: 'system', subtype: 'compact_boundary', session_id: 'p' }) - tracker.claude('s', { type: 'result', subtype: 'success', session_id: 'p' }) - }) - await expect(next).resolves.toEqual({ outcome: 'compacted' }) - }) - it('reconciles a terminal frame after timeout without repeating the provider request', async () => { - vi.useFakeTimers() - try { - const tracker = new StructuredSessionCompaction(10) - const late = vi.fn(async () => {}) - const invoke = vi.fn(async () => undefined) - const result = tracker.run('s', 'p', invoke, late) - const rejected = expect(result).rejects.toThrow('unconfirmed') - await vi.advanceTimersByTimeAsync(11) - await rejected - tracker.claude('s', { type: 'system', subtype: 'compact_boundary', session_id: 'p' }) - tracker.claude('s', { type: 'result', subtype: 'success', session_id: 'p' }) - expect(late).toHaveBeenCalledWith({ outcome: 'compacted' }) - expect(invoke).toHaveBeenCalledTimes(1) - } finally { - vi.useRealTimers() - } - }) - - it('does not mistake an unrelated completed turn for compaction', async () => { - const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 't', async () => undefined) - tracker.codex('s', 'turn/started', { threadId: 't', turn: { id: 'c' } }) - tracker.codex('s', 'turn/completed', { threadId: 't', turn: { id: 'c', status: 'completed' } }) - await expect(result).resolves.toEqual({ outcome: 'unconfirmed' }) - }) - - it('fails, not merely unconfirmed, a Codex compaction turn that did not complete', async () => { - const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 't', async () => undefined) - tracker.codex('s', 'turn/started', { threadId: 't', turn: { id: 'c' } }) - tracker.codex('s', 'turn/completed', { - threadId: 't', - turn: { id: 'c', status: 'interrupted' } - }) - await expect(result).resolves.toEqual({ outcome: 'failed' }) - }) - - it('leaves a Claude turn that ended without confirming compaction unconfirmed', async () => { - const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 'p', async () => {}) - tracker.claude('s', { type: 'result', subtype: 'success', session_id: 'p' }) - await expect(result).resolves.toEqual({ outcome: 'unconfirmed' }) - }) - - it('fails a Claude compaction whose turn ended in error', async () => { - const tracker = new StructuredSessionCompaction() - const result = tracker.run('s', 'p', async () => {}) - tracker.claude('s', { type: 'result', subtype: 'error_during_execution', session_id: 'p' }) - await expect(result).resolves.toEqual({ outcome: 'failed' }) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts b/src/main/native-chat/agent-session-wire/structured-session-compaction.ts deleted file mode 100644 index 5f5c88d0b07..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-session-compaction.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { providerDiagnostic, type ProviderDiagnostic } from '../../../shared/agent-session-failure' - -/** How a compaction ended. `unconfirmed`: the provider never said whether it compacted, so it may - * have. `detail` is the provider's own words for a failure, when it gave any. */ -export type StructuredSessionCompactionResult = - | { outcome: 'compacted' } - | { outcome: 'failed'; detail?: ProviderDiagnostic } - | { outcome: 'unconfirmed' } - -/** A compaction that never began: refused before it was sent, or by the provider at the request. */ -export type StructuredSessionCompactionRefusal = Extract< - StructuredSessionCompactionResult, - { outcome: 'failed' } -> - -function compactionFailed( - detail: ProviderDiagnostic | undefined -): StructuredSessionCompactionRefusal { - return { outcome: 'failed', ...(detail ? { detail } : {}) } -} - -type PendingCompaction = { - identity: string - commandTurnId?: string - turnId?: string - /** The provider reported the compaction failed, with its words when it gave any. */ - failed?: { detail?: ProviderDiagnostic } - compacted: boolean - finish: (result: StructuredSessionCompactionResult) => void -} - -function record(value: unknown): Record { - return value && typeof value === 'object' ? (value as Record) : {} -} - -export function isCodexCompactionComplete(method: string, params: unknown): boolean { - return ( - method === 'thread/compacted' || - (method === 'item/completed' && record(record(params).item).type === 'contextCompaction') - ) -} - -/** A receipt is not completion; keep listening through the provider's terminal frame. */ -export class StructuredSessionCompaction { - private readonly pending = new Map() - constructor(private readonly timeoutMs = 180_000) {} - - async run( - sessionId: string, - identity: string, - invoke: () => Promise, - onLateResult?: (result: StructuredSessionCompactionResult) => Promise, - commandTurnId?: string - ): Promise { - if (this.pending.has(sessionId)) { - throw new Error('Compaction is already running.') - } - let timer: ReturnType - let expired = false - const completion = new Promise((resolve, reject) => { - const finish = (result: StructuredSessionCompactionResult) => { - this.pending.delete(sessionId) - if (expired && onLateResult) { - void onLateResult(result).catch((error) => - console.warn('Could not persist late compaction completion', error) - ) - } - resolve(result) - } - this.pending.set(sessionId, { - identity, - commandTurnId, - compacted: false, - finish - }) - timer = setTimeout(() => { - expired = true - reject(new Error('Compaction completion is unconfirmed.')) - }, this.timeoutMs) - timer.unref?.() - }) - // Observe rejection even while invoke is waiting for its own receipt. - void completion.catch(() => {}) - try { - const refusal = await invoke() - if (refusal) { - this.pending.get(sessionId)?.finish(compactionFailed(refusal.detail)) - } - return await completion - } catch (error) { - expired = this.pending.has(sessionId) - throw error - } finally { - clearTimeout(timer!) - if (!expired) { - this.pending.delete(sessionId) - } - } - } - - hasPending(sessionId: string): boolean { - return this.pending.has(sessionId) - } - - ownsTurn(sessionId: string, turnId: string): boolean { - return this.pending.get(sessionId)?.commandTurnId === turnId - } - - providerTurnId(sessionId: string, turnId: string): string | undefined { - return this.ownsTurn(sessionId, turnId) ? this.pending.get(sessionId)?.turnId : turnId - } - - ended(sessionId: string): void { - // It may have compacted before it exited. - this.pending.get(sessionId)?.finish({ outcome: 'unconfirmed' }) - } - - codex(sessionId: string, method: string, value: unknown): void { - const pending = this.pending.get(sessionId) - const params = record(value) - if (!pending || params.threadId !== pending.identity) { - return - } - const turn = record(params.turn) - if (method === 'turn/started' && typeof turn.id === 'string') { - pending.turnId = turn.id - } - if (isCodexCompactionComplete(method, params)) { - pending.compacted = true - } - if (method === 'turn/completed' && turn.id === pending.turnId) { - const error = record(turn.error).message - pending.finish( - turn.status !== 'completed' - ? compactionFailed( - typeof error === 'string' ? providerDiagnostic(error, 'person') : undefined - ) - : { outcome: pending.compacted ? 'compacted' : 'unconfirmed' } - ) - } - } - - claude(sessionId: string, message: Record): void { - const pending = this.pending.get(sessionId) - if (!pending || message.session_id !== pending.identity) { - return - } - if (message.compact_result === 'failed') { - pending.failed = { - detail: - typeof message.compact_error === 'string' - ? providerDiagnostic(message.compact_error, 'person') - : undefined - } - } - if (message.compact_result === 'success' || message.subtype === 'compact_boundary') { - pending.compacted = true - } - if (message.type === 'result') { - if ( - message.is_error === true || - (typeof message.subtype === 'string' && message.subtype.startsWith('error')) - ) { - pending.failed ??= {} - } - pending.finish( - pending.failed - ? compactionFailed(pending.failed.detail) - : { outcome: pending.compacted ? 'compacted' : 'unconfirmed' } - ) - } - } -} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 1cc5050d803..63d24030c70 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../../shared/agent-session-journal-types' // A chat at rest, through the RPC surface a client actually calls: opening it starts nothing, what // it can answer without an agent it answers, and the first send is what starts one. @@ -158,7 +159,8 @@ describe('the accessor', () => { .at(-1)?.[0] .events?.appendItem( { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'turn-1', ordinal: 1 }, - hostTestMessage('from the provider') + hostTestMessage('from the provider'), + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await rig.host.flushStreamedEvents(SESSION) await rig.restart() @@ -421,7 +423,8 @@ describe('an agent exit', () => { .at(-1)?.[0] .events?.appendItem( { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'working', ordinal: 50 }, - { kind: 'turn', turnId: 'working', state: 'running' } + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await rig.host.flushStreamedEvents(SESSION) // The exit's own write and the retry recording the exit queues are both refused. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 9a0f3902496..85d3e2a5b4b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -191,6 +191,7 @@ export function hostStub(): StructuredAgentSessionHost { current: { model: 'gpt-live' } })), history: vi.fn(() => ({ ok: true, page: { items: [] } })), + sessionAgent: vi.fn(() => null), journalSnapshot: vi.fn((sessionId: string) => ({ sessionId, cursor: { epoch: 'epoch-a', sequence: 0 }, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts index 9ae846df6fc..4b3bd989c87 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.test.ts @@ -34,7 +34,7 @@ describe('agentSession.send reply timing', () => { await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) expect(hostCalls.waitForSendSettlement).toHaveBeenCalledWith(SESSION, 'client-1', { - until: 'handed-over', + until: 'handed-over-or-behind-command', budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts index 5fde5e0f6e4..c7c8d2cc4ce 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts @@ -9,8 +9,10 @@ import { requireStructuredHost, structuredCallerFor } from './structured-agent-s /** * A send answers once the host accepts it. A client that predates that answer cannot show a - * message rejected after it, so its reply is held until the message is handed over or rejected; - * one that predates pending replies at all waits, as before, for the provider's answer. + * message rejected after it, so its reply is held until the message is handed over or rejected — + * or queued behind a running command such as `/compact`, which could outlast the client's own + * request timeout; one that predates pending replies at all waits, as before, for the provider's + * answer. */ export async function sendStructuredAgentSessionForClient( params: Parameters[1], @@ -33,7 +35,7 @@ export async function sendStructuredAgentSessionForClient( result.value.clientMessageId, { until: capabilities.includes(AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY) - ? 'handed-over' + ? 'handed-over-or-behind-command' : 'answered', budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS, ...(context.signal ? { signal: context.signal } : {}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts b/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts index f0694811a9a..c6984552399 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-turn-item-capability.ts @@ -24,7 +24,10 @@ function readsTurnItems(ctx: TurnItemReader): boolean { ) } -function projectItems(items: AgentJournalRenderItem[]): AgentJournalRenderItem[] { +function projectItems( + items: AgentJournalRenderItem[], + sessionAgent: string | null +): AgentJournalRenderItem[] { if (!items.some((item) => item.body.kind === 'turn')) { return items } @@ -33,39 +36,45 @@ function projectItems(items: AgentJournalRenderItem[]): AgentJournalRenderItem[] return item } const { kind: _kind, ...turn } = item.body - return { ...item, body: legacyAgentJournalTurnStatusBody(turn, item.itemId) } + return { ...item, body: legacyAgentJournalTurnStatusBody(turn, item.itemId, sessionAgent) } }) } -function projectPage(page: AgentSessionHistoryPage): AgentSessionHistoryPage { - const items = projectItems(page.items) +function projectPage( + page: AgentSessionHistoryPage, + sessionAgent: string | null +): AgentSessionHistoryPage { + const items = projectItems(page.items, sessionAgent) return items === page.items ? page : { ...page, items } } +/** `sessionAgent` names the agent on a turn whose key does not, such as a command's. */ export function projectTurnItemHistory( result: AgentSessionHistoryResult, - ctx: TurnItemReader + ctx: TurnItemReader, + sessionAgent: string | null = null ): AgentSessionHistoryResult { if (readsTurnItems(ctx)) { return result } - const page = projectPage(result.page) + const page = projectPage(result.page, sessionAgent) return page === result.page ? result : { ...result, page } } export function projectTurnItemEvent( event: AgentSessionSubscribeEvent, - ctx: TurnItemReader + ctx: TurnItemReader, + sessionAgent: string | null = null ): AgentSessionSubscribeEvent { if (readsTurnItems(ctx)) { return event } if (event.type === 'batch') { - const items = projectItems(event.batch.items) + const items = projectItems(event.batch.items, sessionAgent) return items === event.batch.items ? event : { ...event, batch: { ...event.batch, items } } } if (event.type === 'snapshot' || event.type === 'reset') { - const page = projectPage(event.page) + const page = projectPage(event.page, sessionAgent) return page === event.page ? event : { ...event, page } } return event diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index dc01a2fee8f..dd7c9ee7115 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -254,11 +254,14 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ defineMethod({ name: 'agentSession.history', params: HistoryParams, - handler: async (params, ctx) => - projectTurnItemHistory( - projectBackgroundTaskHistory(await (await requireInstalledHost(ctx)).history(params), ctx), - ctx + handler: async (params, ctx) => { + const host = await requireInstalledHost(ctx) + return projectTurnItemHistory( + projectBackgroundTaskHistory(await host.history(params), ctx), + ctx, + host.sessionAgent(params.sessionId) ) + } }), defineStreamingMethod({ name: 'agentSession.subscribe', @@ -277,7 +280,14 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ dispose = await host.subscribe({ id: subscriptionId, sessionId: params.sessionId, - emit: (event) => emit(projectTurnItemEvent(projectBackgroundTaskEvent(event, ctx), ctx)), + emit: (event) => + emit( + projectTurnItemEvent( + projectBackgroundTaskEvent(event, ctx), + ctx, + host.sessionAgent(params.sessionId) + ) + ), ...(params.cursor ? { cursor: params.cursor } : {}) }) if (stream.isClosed()) { diff --git a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts index 7b22a45d09d..f4baa90da20 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -164,7 +165,7 @@ describe('a request that failed reads as failed through the feed, the ingest and outcome: 'cancellation', completedAt: 5 }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const { status, ps } = ingest(publishedSummary(journal)) @@ -180,7 +181,7 @@ describe('a request that failed reads as failed through the feed, the ingest and await journal.appendItem( TURN_IDENTITY, { kind: 'turn', turnId: 'turn-1', state: 'completed', outcome: 'failure', completedAt: 5 }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const summary: AgentSessionStatusSummary = { ...publishedSummary(journal), diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts index a1373f43c71..5b081d1e5df 100644 --- a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -1,3 +1,4 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources' import { mkdtemp, rm } from 'node:fs/promises' @@ -71,7 +72,7 @@ async function awaitingApproval() { await journal.appendItem( { ...IDENTITY, ordinal: 1 }, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'rm the branch' }] }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.appendItem( { ...IDENTITY, ordinal: 2 }, @@ -82,7 +83,7 @@ async function awaitingApproval() { options: [{ id: 'allow', label: 'Allow' }], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 1 } + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) const sessions = new Map([ [ diff --git a/src/main/runtime/structured-agent-session-codex-compact-exit.test.ts b/src/main/runtime/structured-agent-session-codex-compact-exit.test.ts new file mode 100644 index 00000000000..ee3876301df --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-compact-exit.test.ts @@ -0,0 +1,154 @@ +// A Codex child that exits, or that Stop ends, while `/compact` runs, observed the way the shipping +// adapter observes it: the app-server connection's own exit, not a hand-fed compaction result. + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../ipc/desktop-renderer-runtime-capabilities' +import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' +import { ensureStructuredAgentSessionHost } from './structured-agent-session-runtime' +import { + openStructuredCodexRpcHarness, + SESSION, + type FakeCodexConnection, + type StructuredCodexRpcHarness +} from './structured-codex-session-rpc-test-harness' + +let harness: StructuredCodexRpcHarness + +beforeEach(async () => { + // The desktop's own client: a send answers at acceptance, so one queued behind the command + // returns while the command still runs. + harness = await openStructuredCodexRpcHarness(DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES) +}) + +afterEach(async () => { + await harness.dispose() +}) + +async function snapshot() { + return (await ensureStructuredAgentSessionHost(harness.hostConfig())).journalSnapshot(SESSION) +} + +/** The connections that received `method`, once per call. */ +function calls(method: string): FakeCodexConnection[] { + return harness.codex.connections.flatMap((connection) => + connection.calls.filter((entry) => entry.method === method).map(() => connection) + ) +} + +/** The child serving the thread; a model-list probe opens connections of its own. */ +function threadChild(): FakeCodexConnection { + const child = harness.codex.connections.findLast((connection) => + connection.calls.some( + (entry) => entry.method === 'thread/start' || entry.method === 'thread/resume' + ) + ) + if (!child) { + throw new Error('no codex child opened the thread') + } + return child +} + +async function startCompact(fence: number): Promise { + const params = { + command: 'compact', + envelope: harness.envelope('agentSession.conversationCommand', { command: 'compact' }, fence) + } + await harness.ok('agentSession.conversationCommand', params) + return params.envelope.clientOperationId +} + +async function send(text: string): Promise { + const body = { kind: 'message' as const, role: 'user' as const, blocks: [{ type: 'text', text }] } + await harness.ok('agentSession.send', { + envelope: harness.envelope('agentSession.send', { body }, null), + body + }) +} + +function exitChild(): FakeCodexConnection { + const child = threadChild() + child.handlers.onExit?.(new Error('codex app-server exited')) + return child +} + +it('delivers what waited behind the command once its child dies mid-command, with one exit row', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + const command = await startCompact(created.fence) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(1)) + await send('after the exit') + + const dead = exitChild() + + await vi.waitFor(() => expect(calls('turn/start')).toHaveLength(1)) + expect(calls('turn/start')[0]).not.toBe(dead) + const settled = await snapshot() + const turn = settled.items.find( + (item) => item.itemId === structuredAgentSessionCommandTurn(command).itemId + ) + expect(readAgentJournalTurn(turn?.body)?.state).toBe('interrupted') + expect( + settled.items.filter((item) => item.body.kind === 'status' && item.body.tone === 'error') + ).toHaveLength(1) +}) + +it('runs a later command after Stop named the one whose child died', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + const command = await startCompact(created.fence) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(1)) + + const dead = exitChild() + await vi.waitFor(async () => { + const turn = (await snapshot()).items.find( + (item) => item.itemId === structuredAgentSessionCommandTurn(command).itemId + ) + expect(readAgentJournalTurn(turn?.body)?.state).toBe('interrupted') + }) + const { turnId } = structuredAgentSessionCommandTurn(command) + await harness.ok('agentSession.cancel', { + envelope: harness.envelope('agentSession.cancel', { turnId }, null), + turnId + }) + + // Nothing the dead command left behind holds the queue or refuses the next one. + const later = await startCompact(created.fence) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(2)) + expect(calls('thread/compact/start')[1]).not.toBe(dead) + const turn = (await snapshot()).items.find( + (item) => item.itemId === structuredAgentSessionCommandTurn(later).itemId + ) + expect(readAgentJournalTurn(turn?.body)?.state).toBe('running') +}) + +it('ends a command Codex has not opened a turn for by stopping its child, and the next send gets a fresh one', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + const command = await startCompact(created.fence) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(1)) + const first = threadChild() + const { itemId, turnId } = structuredAgentSessionCommandTurn(command) + + // No `turn/started` yet, so there is no provider turn to interrupt. + await expect( + harness.ok('agentSession.cancel', { + envelope: harness.envelope('agentSession.cancel', { turnId }, null), + turnId + }) + ).resolves.toMatchObject({ cancelled: true }) + + expect(first.closed).toBe(true) + expect(first.calls.some((entry) => entry.method === 'turn/interrupt')).toBe(false) + const turn = (await snapshot()).items.find((item) => item.itemId === itemId) + expect(readAgentJournalTurn(turn?.body)?.state).toBe('interrupted') + await send('after the stop') + await vi.waitFor(() => expect(calls('turn/start')).toHaveLength(1)) + expect(calls('turn/start')[0]).not.toBe(first) +}) diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index a4e2500ffc4..29dbdf59302 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -6,234 +6,27 @@ // that ship. The fake app-server answers the same JSON-RPC calls the real one // does and pushes the same notifications and blocking requests back. -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - CodexAppServerConnection, - CodexAppServerConnectionHandlers, - openCodexAppServerConnection -} from '../codex/codex-app-server-connection' import type { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' -import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from '../../shared/protocol-version' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' -import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' -import type { OrcaRuntimeService } from './orca-runtime' -import type { RpcRequest, RpcResponse } from './rpc/core' -import { RpcDispatcher } from './rpc/dispatcher' -import { STRUCTURED_AGENT_SESSION_METHODS } from './rpc/methods/structured-agent-session' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { + openStructuredCodexRpcHarness, + SESSION, + THREAD, + TURN, + type StructuredCodexRpcHarness +} from './structured-codex-session-rpc-test-harness' const journals = createTrackedJournalOpener() - -const SESSION = 'session-integration-1' -const THREAD = 'thread-integration' -const TURN = 'turn-1' const WORKSPACE = 'workspace-1' -// The capability set the desktop renderer advertises. Without the pending-send -// one the host holds the reply until the send settles, which is a shim for -// clients too old to render a pending bubble — not what this suite models. -const CLIENT = { - clientId: 'device-a', - clientKind: 'runtime' as const, - clientCapabilities: [ - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ] -} -// ─── the fake `codex app-server` ──────────────────────────────────────────── - -type CodexScript = { - connections: FakeConnection[] - openConnection: typeof openCodexAppServerConnection - live: () => FakeConnection - notify: (method: string, params: unknown) => void - ask: (id: number, method: string, params: unknown) => void -} - -// `closed` is readonly on the real connection; the fake flips it so the test can -// see the takeover reap the previous child. -type FakeConnection = Omit & { - closed: boolean - handlers: CodexAppServerConnectionHandlers - calls: { method: string; params?: Record }[] - replies: { id: number | string; result?: unknown; code?: number }[] - resumedThreadId: string | null - launch: Parameters[0] -} - -function fakeCodex(): CodexScript { - const connections: FakeConnection[] = [] - const openConnection = (async (launch, handlers = {}) => { - const connection: FakeConnection = { - launch, - handlers, - calls: [], - replies: [], - resumedThreadId: null, - pid: 4321, - closed: false, - request: async (method, params) => { - connection.calls.push({ method, params }) - if (method === 'thread/start') { - return { thread: { id: THREAD, path: '/rollouts/integration.jsonl' } } - } - if (method === 'thread/resume') { - connection.resumedThreadId = (params as { threadId: string }).threadId - return { thread: { id: connection.resumedThreadId } } - } - if (method === 'turn/start') { - return { turn: { id: TURN } } - } - if (method === 'model/list') { - return { - data: [ - { - model: 'gpt-live', - displayName: 'GPT Live', - hidden: false, - supportedReasoningEfforts: [ - { reasoningEffort: 'medium', description: 'Balanced' }, - { reasoningEffort: 'high', description: 'Deep reasoning' } - ], - defaultReasoningEffort: 'medium', - isDefault: true - } - ], - nextCursor: null - } - } - return {} - }, - notify: () => {}, - respond: (id, result) => connection.replies.push({ id, result }), - respondWithError: (id, code) => connection.replies.push({ id, code }), - close: async () => { - connection.closed = true - return true - } - } - connections.push(connection) - return connection - }) as typeof openCodexAppServerConnection - const live = (): FakeConnection => { - const connection = connections.at(-1) - if (!connection) { - throw new Error('no codex app-server has been opened') - } - return connection - } - return { - connections, - openConnection, - live, - notify: (method, params) => live().handlers.onNotification?.(method, params), - ask: (id, method, params) => live().handlers.onServerRequest?.({ id, method, params }) - } -} - -// ─── the RPC client ───────────────────────────────────────────────────────── - -let operations = 0 - -/** `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. Real - * time, not a frozen constant: the runtime under test stamps the ledger with - * its own clock and refuses a future-dated id. */ -function operationId(): string { - operations += 1 - return `${Date.now()}-${operations.toString(16).padStart(32, '0')}` -} - -function envelope(method: string, fields: Record, fence: number | null) { - return { - sessionId: SESSION, - clientOperationId: operationId(), - expectedRuntimeFence: fence, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method, - sessionId: SESSION, - fields - }) - } -} - -function attachParams(fence: number | null) { - const params = { - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: WORKSPACE, - workspaceKind: 'git-worktree' as const - }, - provider: 'codex' as const, - agent: 'codex', - accountHome: { variable: 'CODEX_HOME' as const, path: '/home/dev/.codex' }, - runtimeKind: 'native' as const, - providerHandle: { kind: 'codex' as const, threadId: THREAD } - } - const envelope = { - sessionId: SESSION, - clientOperationId: operationId(), - expectedRuntimeFence: fence, - payloadFingerprint: '' - } - return { - ...params, - envelope: { - ...envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: SESSION, - fields: attachFingerprintFields({ ...params, envelope } as never) - }) - } - } -} - -function createIntentParams() { - const worktree = `id:${WORKSPACE}` - const fields = { worktree, agent: 'codex' } - return { envelope: envelope('agentSession.create', fields, null), ...fields } -} - -let codex: CodexScript -let root: string -let dispatcher: RpcDispatcher -let bootEnvironmentReads: number -let codexOverrideReads: number -let configuredCodexProfile: string - -/** Runs a one-shot method and returns its decoded reply. */ -async function call(method: string, params: unknown): Promise { - const replies: RpcResponse[] = [] - const request: RpcRequest = { id: `req-${operations}`, authToken: 'token', method, params } - await dispatcher.dispatchStreaming(request, (raw) => replies.push(JSON.parse(raw)), CLIENT) - const first = replies[0] - if (!first) { - throw new Error(`no reply for ${method}`) - } - return first -} - -/** Asserts success and unwraps the host's `{ok:true, value}` mutation result. */ -async function ok(method: string, params: unknown): Promise { - const response = await call(method, params) - expect(response, `${method} failed: ${JSON.stringify(response)}`).toMatchObject({ ok: true }) - const result = (response as { result: { ok: boolean; value?: T; refusal?: unknown } }).result - expect(result, `${method} refused: ${JSON.stringify(result.refusal)}`).toMatchObject({ ok: true }) - return result.value as T -} +let harness: StructuredCodexRpcHarness function textOf(item: AgentJournalRenderItem): string { const body = item.body @@ -243,69 +36,20 @@ function textOf(item: AgentJournalRenderItem): string { } beforeEach(async () => { - operations = 0 - root = await mkdtemp(join(tmpdir(), 'orca-structured-integration-')) - codex = fakeCodex() - bootEnvironmentReads = 0 - codexOverrideReads = 0 - configuredCodexProfile = 'configured' - const runtime = { - getRuntimeId: () => 'runtime-1', - getClientSettings: () => ({ experimentalStructuredNativeChat: true }), - getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), - resolveStructuredAgentSessionCreateIntent: async () => { - const { - envelope: _envelope, - providerHandle: _providerHandle, - ...resolved - } = attachParams(null) - return resolved - }, - publishStructuredAgentSessionTab: () => {}, - ensureStructuredAgentSessionHost: () => - ensureStructuredAgentSessionHost({ - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, - resolveCodexCommand: () => '/usr/local/bin/codex', - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), - resolveEnvironment: async () => { - bootEnvironmentReads += 1 - return { - PATH: '/shell/bin:/usr/bin', - EXAMPLE_GATEWAY_TOKEN: 'shell-exported', - CODEX_HOME: '/shell/home' - } - }, - resolveCodexOverrides: () => { - codexOverrideReads += 1 - return { CODEX_PROFILE: configuredCodexProfile } - }, - openCodexConnection: codex.openConnection, - readProcessStartTime: async () => 1_700_000_000_000 - }).then(() => undefined), - registerOwnedSubscriptionCleanup: vi.fn((_id: string, dispose: () => void) => { - return { - releaseIfCurrent: dispose - } - }) - } - dispatcher = new RpcDispatcher({ - runtime: runtime as unknown as OrcaRuntimeService, - methods: STRUCTURED_AGENT_SESSION_METHODS - }) + harness = await openStructuredCodexRpcHarness() }) afterEach(async () => { await journals.closeAll() - await stopStructuredAgentSessionRuntime() - await rm(root, { recursive: true, force: true }) + await harness.dispose() }) describe('a structured codex session over agentSession.*', () => { it('replays a durable image send without dispatching it twice', async () => { - const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) const path = '/tmp/orca-paste-image.png' const body = { kind: 'message' as const, @@ -313,31 +57,23 @@ describe('a structured codex session over agentSession.*', () => { blocks: [{ type: 'image-ref' as const, path }] } const params = { - envelope: envelope('agentSession.send', { body }, created.fence), + envelope: harness.envelope('agentSession.send', { body }, created.fence), body } - const turnStarts = () => codex.live().calls.filter((entry) => entry.method === 'turn/start') - await ok('agentSession.send', params) + const turnStarts = () => + harness.codex.live().calls.filter((entry) => entry.method === 'turn/start') + await harness.ok('agentSession.send', params) // Accepted first; the delivery loop hands it over once. await vi.waitFor(() => expect(turnStarts()).toHaveLength(1)) - const replay = await call('agentSession.send', params) + const replay = await harness.call('agentSession.send', params) expect(replay).toMatchObject({ ok: true, result: { ok: true, replayed: true } }) expect(turnStarts()).toHaveLength(1) }) it('joins an acquired attach through journal bind before draining final rows', async () => { - const host = await ensureStructuredAgentSessionHost({ - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, - resolveCodexCommand: () => '/usr/local/bin/codex', - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), - openCodexConnection: codex.openConnection, - readProcessStartTime: async () => 1_700_000_000_000 - }) + const host = await ensureStructuredAgentSessionHost(harness.hostConfig()) const adapter = (host as unknown as { deps: { adapter: CodexStructuredSessionAdapter } }).deps .adapter const historyEntered = Promise.withResolvers() @@ -349,15 +85,18 @@ describe('a structured codex session over agentSession.*', () => { return originalHistoryFilePath(input) }) - const creating = ok<{ fence: number }>('agentSession.create', createIntentParams()) + const creating = harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) await historyEntered.promise - codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) - codex.notify('item/started', { + harness.codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) + harness.codex.notify('item/started', { threadId: THREAD, turnId: TURN, item: { type: 'agentMessage', id: 'item-bind-window', text: '' } }) - codex.notify('item/agentMessage/delta', { + harness.codex.notify('item/agentMessage/delta', { threadId: THREAD, turnId: TURN, itemId: 'item-bind-window', @@ -384,7 +123,7 @@ describe('a structured codex session over agentSession.*', () => { } const reopened = await journals.open({ identity, - journalDir: journalDirectoryFor(root, identity) + journalDir: journalDirectoryFor(harness.root, identity) }) expect(reopened.snapshot().items.map(textOf)).toContain('Buffered while the journal opens.') expect( diff --git a/src/main/runtime/structured-agent-session-legacy-send-behind-command.test.ts b/src/main/runtime/structured-agent-session-legacy-send-behind-command.test.ts new file mode 100644 index 00000000000..fb820891ba5 --- /dev/null +++ b/src/main/runtime/structured-agent-session-legacy-send-behind-command.test.ts @@ -0,0 +1,106 @@ +// A client that predates accepted-send replies (every phone build) has its send reply held until +// handover. A message queued behind `/compact` is handed over only when the compaction ends, so +// that reply answers once the message waits behind the running command instead. + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' +import { ensureStructuredAgentSessionHost } from './structured-agent-session-runtime' +import { + openStructuredCodexRpcHarness, + SESSION, + THREAD, + type FakeCodexConnection, + type StructuredCodexRpcHarness +} from './structured-codex-session-rpc-test-harness' + +const COMPACTION_TURN = 'compaction-turn' +// Far under the phone's 15 s send timeout, and far over a handover that is already due. +const PROMPT_REPLY_MS = 2_000 + +let harness: StructuredCodexRpcHarness + +beforeEach(async () => { + // The harness's default client is the phone's: pending replies, but not accepted-send. + harness = await openStructuredCodexRpcHarness() +}) + +afterEach(async () => { + await harness.dispose() +}) + +function calls(method: string): FakeCodexConnection[] { + return harness.codex.connections.flatMap((connection) => + connection.calls.filter((entry) => entry.method === method).map(() => connection) + ) +} + +function send(text: string): Promise<{ submission: AgentJournalSubmission }> { + const body = { kind: 'message' as const, role: 'user' as const, blocks: [{ type: 'text', text }] } + return harness.ok('agentSession.send', { + envelope: harness.envelope('agentSession.send', { body }, null), + body + }) +} + +function promptly(reply: Promise): Promise { + return Promise.race([ + reply, + new Promise((_, reject) => + setTimeout(() => reject(new Error('the send reply waited out the command')), PROMPT_REPLY_MS) + ) + ]) +} + +it('answers a send queued behind /compact at once, and delivers it once after the compaction', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + await harness.ok('agentSession.conversationCommand', { + command: 'compact', + envelope: harness.envelope( + 'agentSession.conversationCommand', + { command: 'compact' }, + created.fence + ) + }) + await vi.waitFor(() => expect(calls('thread/compact/start')).toHaveLength(1)) + + const { submission } = await promptly(send('after the compaction')) + + expect(submission).toMatchObject({ dispatchState: 'pending' }) + expect(submission.handedOverAt).toBeUndefined() + expect(calls('turn/start')).toHaveLength(0) + + harness.codex.notify('turn/started', { threadId: THREAD, turn: { id: COMPACTION_TURN } }) + harness.codex.notify('item/completed', { + threadId: THREAD, + turnId: COMPACTION_TURN, + item: { type: 'contextCompaction', id: 'compaction' } + }) + harness.codex.notify('turn/completed', { + threadId: THREAD, + turn: { id: COMPACTION_TURN, status: 'completed' } + }) + + await vi.waitFor(() => expect(calls('turn/start')).toHaveLength(1)) + const journal = await ( + await ensureStructuredAgentSessionHost(harness.hostConfig()) + ).journalSnapshot(SESSION) + expect( + journal.submissions.filter((entry) => entry.clientMessageId === submission.clientMessageId) + ).toEqual([expect.objectContaining({ handedOverAt: expect.any(Number) })]) + // Nothing re-sent it: the host delivered the one message once. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(calls('turn/start')).toHaveLength(1) +}) + +it('still holds the reply of a send that no command is holding until it is handed over', async () => { + await harness.ok('agentSession.create', harness.createIntentParams()) + + // The first send starts the child; the reply waits out that start and the handover. + const { submission } = await promptly(send('first message')) + + expect(submission.handedOverAt).toEqual(expect.any(Number)) + await vi.waitFor(() => expect(calls('turn/start')).toHaveLength(1)) +}) diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 4160b81c4de..9e4c9aca2c7 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -64,6 +64,8 @@ export function structuredClaudeLifecycleEvent( cause: event.cause, fence: event.fence, acquisitionGeneration: event.acquisitionGeneration, + // The instant the translator ended the open turn at; the host reads the exit's turn by it. + ...(event.observedAt === undefined ? {} : { observedAt: event.observedAt }), ...(event.startupUnproven ? { startupUnproven: event.startupUnproven } : {}) } } diff --git a/src/main/runtime/structured-codex-session-rpc-test-harness.ts b/src/main/runtime/structured-codex-session-rpc-test-harness.ts new file mode 100644 index 00000000000..6b9024ba029 --- /dev/null +++ b/src/main/runtime/structured-codex-session-rpc-test-harness.ts @@ -0,0 +1,278 @@ +// One structured Codex session driven over `agentSession.*`, with nothing stubbed but the Codex +// child: the RPC dispatcher, schemas, record store, journal, lease, Codex adapter and translation +// are the ones that ship. The fake app-server answers the JSON-RPC calls the real one does, and a +// test pushes notifications, blocking requests and the child's exit back through its handlers. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, vi } from 'vitest' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../shared/protocol-version' +import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import type { OrcaRuntimeService } from './orca-runtime' +import type { RpcRequest, RpcResponse } from './rpc/core' +import { RpcDispatcher } from './rpc/dispatcher' +import { STRUCTURED_AGENT_SESSION_METHODS } from './rpc/methods/structured-agent-session' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +export const SESSION = 'session-integration-1' +export const THREAD = 'thread-integration' +export const TURN = 'turn-1' +const WORKSPACE = 'workspace-1' +// Without the pending-send capability the host holds the reply until the send settles, which is a +// shim for clients too old to render a pending bubble — not what these suites model. +const DEFAULT_CLIENT_CAPABILITIES: readonly RuntimeCapability[] = [ + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +] + +// `closed` is readonly on the real connection; the fake flips it so a test can +// see a takeover reap the previous child. +export type FakeCodexConnection = Omit & { + closed: boolean + handlers: CodexAppServerConnectionHandlers + calls: { method: string; params?: Record }[] + replies: { id: number | string; result?: unknown; code?: number }[] + resumedThreadId: string | null + launch: Parameters[0] +} + +export type FakeCodex = { + connections: FakeCodexConnection[] + openConnection: typeof openCodexAppServerConnection + live: () => FakeCodexConnection + notify: (method: string, params: unknown) => void + ask: (id: number, method: string, params: unknown) => void +} + +function fakeCodex(): FakeCodex { + const connections: FakeCodexConnection[] = [] + const openConnection: typeof openCodexAppServerConnection = async (launch, handlers = {}) => { + const connection: FakeCodexConnection = { + launch, + handlers, + calls: [], + replies: [], + resumedThreadId: null, + pid: 4321, + closed: false, + request: async (method, params) => { + connection.calls.push({ method, params }) + if (method === 'thread/start') { + return { thread: { id: THREAD, path: '/rollouts/integration.jsonl' } } + } + if (method === 'thread/resume') { + connection.resumedThreadId = (params as { threadId: string }).threadId + return { thread: { id: connection.resumedThreadId } } + } + if (method === 'turn/start') { + return { turn: { id: TURN } } + } + if (method === 'model/list') { + return { + data: [ + { + model: 'gpt-live', + displayName: 'GPT Live', + hidden: false, + supportedReasoningEfforts: [ + { reasoningEffort: 'medium', description: 'Balanced' }, + { reasoningEffort: 'high', description: 'Deep reasoning' } + ], + defaultReasoningEffort: 'medium', + isDefault: true + } + ], + nextCursor: null + } + } + return {} + }, + notify: () => {}, + respond: (id, result) => connection.replies.push({ id, result }), + respondWithError: (id, code) => connection.replies.push({ id, code }), + close: async () => { + connection.closed = true + return true + } + } + connections.push(connection) + return connection + } + const live = (): FakeCodexConnection => { + const connection = connections.at(-1) + if (!connection) { + throw new Error('no codex app-server has been opened') + } + return connection + } + return { + connections, + openConnection, + live, + notify: (method, params) => live().handlers.onNotification?.(method, params), + ask: (id, method, params) => live().handlers.onServerRequest?.({ id, method, params }) + } +} + +function attachParams(operationId: () => string, fence: number | null) { + const params = { + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' as const + }, + provider: 'codex' as const, + agent: 'codex', + accountHome: { variable: 'CODEX_HOME' as const, path: '/home/dev/.codex' }, + runtimeKind: 'native' as const, + providerHandle: { kind: 'codex' as const, threadId: THREAD } + } + const envelope = { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: '' + } + return { + ...params, + envelope: { + ...envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields({ ...params, envelope } as never) + }) + } + } +} + +export type StructuredCodexRpcHarness = { + codex: FakeCodex + root: string + hostConfig: () => Parameters[0] + envelope: ( + method: string, + fields: Record, + fence: number | null + ) => { sessionId: string; clientOperationId: string; expectedRuntimeFence: number | null } + createIntentParams: () => Record + /** Runs a one-shot method and returns its decoded reply. */ + call: (method: string, params: unknown) => Promise + /** Asserts success and unwraps the host's `{ok:true, value}` mutation result. */ + ok: (method: string, params: unknown) => Promise + dispose: () => Promise +} + +export async function openStructuredCodexRpcHarness( + clientCapabilities: readonly RuntimeCapability[] = DEFAULT_CLIENT_CAPABILITIES +): Promise { + const client = { clientId: 'device-a', clientKind: 'runtime' as const, clientCapabilities } + let operations = 0 + const root = await mkdtemp(join(tmpdir(), 'orca-structured-integration-')) + const codex = fakeCodex() + // `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. Real time, not a frozen + // constant: the runtime under test stamps the ledger with its own clock and refuses a future id. + const operationId = (): string => { + operations += 1 + return `${Date.now()}-${operations.toString(16).padStart(32, '0')}` + } + const envelope: StructuredCodexRpcHarness['envelope'] = (method, fields, fence) => ({ + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + }) + const hostConfig = (): Parameters[0] => ({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, + resolveCodexCommand: () => '/usr/local/bin/codex', + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveEnvironment: async () => ({ + PATH: '/shell/bin:/usr/bin', + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + CODEX_HOME: '/shell/home' + }), + resolveCodexOverrides: () => ({ CODEX_PROFILE: 'configured' }), + openCodexConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const runtime = { + getRuntimeId: () => 'runtime-1', + getClientSettings: () => ({ experimentalStructuredNativeChat: true }), + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + resolveStructuredAgentSessionCreateIntent: async () => { + const { + envelope: _envelope, + providerHandle: _providerHandle, + ...resolved + } = attachParams(operationId, null) + return resolved + }, + publishStructuredAgentSessionTab: () => {}, + ensureStructuredAgentSessionHost: () => + ensureStructuredAgentSessionHost(hostConfig()).then(() => undefined), + registerOwnedSubscriptionCleanup: vi.fn((_id: string, dispose: () => void) => ({ + releaseIfCurrent: dispose + })) + } + const dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_METHODS + }) + const call: StructuredCodexRpcHarness['call'] = async (method, params) => { + const replies: RpcResponse[] = [] + const request: RpcRequest = { id: `req-${operations}`, authToken: 'token', method, params } + await dispatcher.dispatchStreaming(request, (raw) => replies.push(JSON.parse(raw)), client) + const first = replies[0] + if (!first) { + throw new Error(`no reply for ${method}`) + } + return first + } + return { + codex, + root, + hostConfig, + envelope, + createIntentParams: () => { + const worktree = `id:${WORKSPACE}` + const fields = { worktree, agent: 'codex' } + return { envelope: envelope('agentSession.create', fields, null), ...fields } + }, + call, + ok: async (method: string, params: unknown): Promise => { + const response = await call(method, params) + expect(response, `${method} failed: ${JSON.stringify(response)}`).toMatchObject({ ok: true }) + const result = (response as { result: { ok: boolean; value?: T; refusal?: unknown } }).result + expect(result, `${method} refused: ${JSON.stringify(result.refusal)}`).toMatchObject({ + ok: true + }) + return result.value as T + }, + dispose: async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) + } + } +} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.queued-turn-order.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.queued-turn-order.test.tsx new file mode 100644 index 00000000000..d1da02b713c --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.queued-turn-order.test.tsx @@ -0,0 +1,180 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatLiveSession } from './use-native-chat-live-session' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const text = ( + id: string, + role: NativeChatMessage['role'], + body: string, + timestamp: number +): NativeChatMessage => ({ + id, + role, + blocks: [{ type: 'text', text: body }], + timestamp, + source: 'transcript' +}) +const toolRun = (id: string, timestamp: number): NativeChatMessage => ({ + id, + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'sleep 5' }, state: 'completed' }], + timestamp, + source: 'transcript' +}) + +// Claude runs A; B is sent mid-turn and Claude answers it after A. The journal writes B when it is +// sent, so A's remaining tool run and its answer follow B there, and B's turn opens after them. +const messages = [ + text('user-a', 'user', 'Run the first job', 1), + toolRun('a-tool-1', 2), + text('user-b', 'user', 'Then run the second job', 3), + toolRun('a-tool-2', 4), + text('a-answer', 'assistant', 'FIRST DONE', 5), + text('b-answer', 'assistant', 'SECOND DONE', 6) +] + +/** With `statesScope` false the rows carry no turn scope, as a host older than turn scopes writes. */ +function journal(bState: 'running' | 'completed', statesScope: boolean): AgentJournalRenderItem[] { + const thread: AgentJournalTurnScope = { kind: 'thread' } + const inTurn = (turnItemId: string): AgentJournalTurnScope => ({ kind: 'turn', turnItemId }) + const row = ( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope, + sequence: number + ): AgentJournalRenderItem => ({ + itemId, + revision: 0, + sequence, + observedAt: sequence, + body, + ...(statesScope ? { turnScope } : {}) + }) + const said = (role: 'user' | 'assistant'): AgentJournalItemBody => ({ + kind: 'message', + role, + blocks: [] + }) + return [ + row('user-a', said('user'), thread, 1), + row( + 'turn-a', + { kind: 'turn', turnId: 'turn-a', state: 'completed', userItemId: 'user-a' }, + thread, + 2 + ), + row('a-tool-1', said('assistant'), inTurn('turn-a'), 3), + // Handed over while A runs, so the host scopes it to A's turn until its own opens. + row('user-b', said('user'), inTurn('turn-a'), 4), + row('a-tool-2', said('assistant'), inTurn('turn-a'), 5), + row('a-answer', said('assistant'), inTurn('turn-a'), 6), + row( + 'turn-b', + { kind: 'turn', turnId: 'turn-b', state: bState, userItemId: 'user-b' }, + thread, + 7 + ), + row('b-answer', said('assistant'), inTurn('turn-b'), 8) + ] +} + +const session = (status: NativeChatLiveSession['status']): NativeChatLiveSession => ({ + messages, + status, + sessionId: 'session-1', + agent: 'claude', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' +}) + +function expectInOrder(...nodes: Element[]): void { + for (const [index, node] of nodes.slice(1).entries()) { + expect(nodes[index]!.compareDocumentPosition(node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + } +} + +const bar = (label: string | RegExp): Element => + screen.getByText(label).closest('[data-native-chat-turn-status]')! + +describe.each([ + ['states each row’s turn', true], + ['states no scope', false] +])( + 'a message the provider answered after the running turn, on a host that %s', + (_host, statesScope) => { + it("draws A's remaining rows and answer under A's bar, then B's bubble and turn", () => { + render( + + ) + + expectInOrder( + screen.getByText('Run the first job'), + bar('Worked for 17s'), + screen.getByText('FIRST DONE'), + screen.getByText('Then run the second job'), + bar(/Working for/), + screen.getByText('SECOND DONE') + ) + }) + + it('keeps that order once both turns settle', () => { + render( + + ) + + expectInOrder( + screen.getByText('Run the first job'), + bar('Worked for 17s'), + screen.getByText('FIRST DONE'), + screen.getByText('Then run the second job'), + bar('Worked for 51s'), + screen.getByText('SECOND DONE') + ) + }) + } +) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 6e0c29d9d71..6138d662974 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -19,14 +19,19 @@ import { NativeChatDisclosureContext, useNativeChatDisclosures } from './native-chat-disclosure-store' -import { NativeChatTranscriptItems } from './NativeChatTranscriptItems' +import { + NativeChatTranscriptItems, + NativeChatWaitingTranscriptItems +} from './NativeChatTranscriptItems' import type { NativeChatTranscriptRowContext } from './NativeChatTranscriptRow' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' import { buildNativeChatTranscriptSlots, + splitNativeChatSlotsWaitingBehindLiveTurn, nativeChatSlotIndexOf } from './native-chat-transcript-slots' import { useNativeChatTranscriptWindow } from './use-native-chat-transcript-window' +import { useNativeChatTurnMembership } from './use-native-chat-turn-membership' import { useNativeChatTranscriptScroll } from './use-native-chat-transcript-scroll' import { useNativeChatOlderHistoryAutoload } from './use-native-chat-older-history-autoload' import { NativeChatOlderHistoryRow } from './NativeChatOlderHistoryRow' @@ -39,14 +44,13 @@ import type { import { useNativeChatRailHistoryJump } from './use-native-chat-rail-history-jump' import { nativeChatReaderScrollInputHandlers } from './native-chat-reader-scroll-input' -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import { isStructuredAgentSessionThinking } from '../../../../shared/structured-agent-session-live-turn' import { nativeChatSubagentLabels } from '../../../../shared/native-chat-subagent-attribution' -import { - selectNativeChatActiveTurnKey, - type NativeChatSettledTurns -} from '../../../../shared/native-chat-turn-status' -import { nativeChatRowTurnKeys } from '../../../../shared/native-chat-turn-grouping' +import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status' import { nativeChatTurnDiffs, type NativeChatDiffReveal, @@ -69,6 +73,7 @@ type NativeChatNavigationRequest = export function NativeChatMessageList({ session, journalItems, + journalSubmissions, railOutline = null, isVisible = true, isWorking, @@ -78,8 +83,6 @@ export function NativeChatMessageList({ allowFileUriLinks = false, workingStartedAt, settledTurns, - activeTurnOpenedBy, - turnKeysByItemId = null, deliveryNotices, awaitingInput = null, turnActivity, @@ -87,6 +90,8 @@ export function NativeChatMessageList({ }: { session: NativeChatLiveSession journalItems?: readonly AgentJournalRenderItem[] + /** With the items, what places each row in its turn (structured lane). */ + journalSubmissions?: readonly AgentJournalSubmission[] /** User messages older than the loaded window, from the host's outline. */ railOutline?: readonly NativeChatRailOutlineEntry[] | null isVisible?: boolean @@ -99,11 +104,6 @@ export function NativeChatMessageList({ /** Recorded turn durations keyed by user message id (the host's, or the transcript's). * A turn missing here shows the duration this list observed, if it saw the turn run. */ settledTurns?: NativeChatSettledTurns - /** The key the host says anchors the running turn's bar (structured lane). */ - activeTurnOpenedBy?: string | null - /** Host-attributed turn ownership per journal item id (structured lane). - * Rows it does not name keep positional preceding-user grouping. */ - turnKeysByItemId?: ReadonlyMap | null onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean deliveryNotices?: ReadonlyMap @@ -163,18 +163,16 @@ export function NativeChatMessageList({ const subagentLabels = useMemo(() => nativeChatSubagentLabels(messages), [messages]) const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(messages), [messages]) const taskListState = useMemo(() => nativeChatTaskListState(messages), [messages]) - // Resolve each row's owning turn once. Prefix slice/findLast in the render - // loop becomes quadratic for long transcripts. - const turnKeys = useMemo( - () => nativeChatRowTurnKeys(messages, turnKeysByItemId), - [messages, turnKeysByItemId] - ) + // Each row's turn, which turn is live, and the order the rows draw in, resolved once. + const { + messages: rows, + turnKeys, + liveTurnKey + } = useNativeChatTurnMembership(messages, journalItems, journalSubmissions) const turnDiffs = useMemo( () => - journalItems - ? nativeChatTurnDiffs(messages, turnKeys) - : new Map(), - [journalItems, messages, turnKeys] + journalItems ? nativeChatTurnDiffs(rows, turnKeys) : new Map(), + [journalItems, rows, turnKeys] ) // "Thinking" is real reasoning content at the tail of the turn, not the absence // of output — the latter reports thinking while the request is merely in flight. @@ -182,10 +180,9 @@ export function NativeChatMessageList({ () => (journalItems ? isStructuredAgentSessionThinking(journalItems) : false), [journalItems] ) - const activeTurnKey = selectNativeChatActiveTurnKey(messages, activeTurnOpenedBy) const turnStatuses = useNativeChatTurnStatus({ - messages, - activeTurnKey, + turnKeys, + liveTurnKey, isWorking, workingStartedAt, settledTurns, @@ -200,12 +197,12 @@ export function NativeChatMessageList({ ? 'activity' : null const lifecycleWorking = session.transcriptLifecycle?.state === 'working' - const slots = useMemo( + const allSlots = useMemo( () => buildNativeChatTranscriptSlots({ - messages, + messages: rows, turnKeys, - activeTurnKey, + liveTurnKey, receipts, turnStatuses, turnDiffs, @@ -215,18 +212,23 @@ export function NativeChatMessageList({ subagentLabels }), [ - activeTurnKey, + liveTurnKey, expandedTurnIds, isWorking, lifecycleWorking, - messages, receipts, + rows, subagentLabels, turnDiffs, turnKeys, turnStatuses ] ) + // A message waiting behind the live turn draws after that turn's live activity, not inside it. + const { slots, waitingSlots } = useMemo( + () => splitNativeChatSlotsWaitingBehindLiveTurn(allSlots, journalItems), + [allSlots, journalItems] + ) const transcriptWindow = useNativeChatTranscriptWindow({ scrollRef, slots, @@ -408,6 +410,7 @@ export function NativeChatMessageList({ ) : tailRow === 'awaiting-input' ? ( ) : null} + diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx index 635eef94c35..7c62a9efe3e 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-fold.test.tsx @@ -4,6 +4,11 @@ import '@testing-library/jest-dom/vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' import type { NativeChatLiveSession } from './use-native-chat-live-session' import { NativeChatMessageList } from './NativeChatMessageList' import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' @@ -72,6 +77,40 @@ function session(startedAt: number): NativeChatLiveSession { } } +/** The journal behind `messages`: one turn `user-1` opened, holding every later row. With + * `statesScope` false, the rows carry no turn scope, as an older host writes them. */ +function journalOf( + messages: readonly NativeChatMessage[], + statesScope: boolean +): AgentJournalRenderItem[] { + const scope = (turnScope: AgentJournalTurnScope) => (statesScope ? { turnScope } : {}) + const [opener, ...rest] = messages + const entry = (message: NativeChatMessage, sequence: number): AgentJournalRenderItem => ({ + itemId: message.id, + revision: 0, + sequence, + observedAt: sequence, + body: { + kind: 'message', + role: message.role === 'user' ? 'user' : 'assistant', + blocks: [{ type: 'text', text: message.id }] + }, + ...scope({ kind: 'turn', turnItemId: 'turn-1' }) + }) + return [ + { ...entry(opener!, 1), ...scope({ kind: 'thread' }) }, + { + itemId: 'turn-1', + revision: 0, + sequence: 2, + observedAt: 2, + body: { kind: 'turn', turnId: 'turn-1', state: 'completed', userItemId: opener!.id }, + ...scope({ kind: 'thread' }) + }, + ...rest.map((message, index) => entry(message, index + 3)) + ] +} + // A finished turn is the transcript's resting state, and it should read as the // answer to the prompt — not as the transcript of the work that produced it. describe('NativeChatMessageList settled turn fold', () => { @@ -143,40 +182,46 @@ describe('NativeChatMessageList settled turn fold', () => { // The #23621 shape: the host folded a mid-turn send into the running turn, so // the rows after it are still the opener's. One bar under the opener folds // them all; the steered bubble stays visible and never grows a bar or group. - it("folds the rows after a mid-turn send behind the opener's bar", () => { - const startedAt = Date.now() - 3000 - const base = session(startedAt) - const steer: (typeof base.messages)[number] = { - id: 'mid-b', - role: 'user', - blocks: [{ type: 'text', text: 'Also check the tests.' }], - timestamp: startedAt + 2, - source: 'transcript' + it.each([ + ['states each row’s turn', true], + ['states no scope', false] + ])( + "folds the rows after a mid-turn send behind the opener's bar on a host that %s", + (_host, statesScope) => { + const startedAt = Date.now() - 3000 + const base = session(startedAt) + const steer: (typeof base.messages)[number] = { + id: 'mid-b', + role: 'user', + blocks: [{ type: 'text', text: 'Also check the tests.' }], + timestamp: startedAt + 2, + source: 'transcript' + } + const messages = [...base.messages.slice(0, 3), steer, ...base.messages.slice(3)] + render( + + ) + + expect(screen.getByText('Also check the tests.')).toBeInTheDocument() + expect(screen.queryByText(NARRATION)).toBeNull() + expect(screen.queryByText(MORE_NARRATION)).toBeNull() + const toggles = screen.getAllByRole('button', { name: 'Toggle turn details' }) + expect(toggles).toHaveLength(1) + + fireEvent.click(toggles[0]!) + expect(screen.getByText(NARRATION)).toBeInTheDocument() + expect(screen.getByText(MORE_NARRATION)).toBeInTheDocument() } - const messages = [...base.messages.slice(0, 3), steer, ...base.messages.slice(3)] - const turnKeysByItemId = new Map(messages.map((message) => [message.id, 'user-1'])) - render( - - ) - - expect(screen.getByText('Also check the tests.')).toBeInTheDocument() - expect(screen.queryByText(NARRATION)).toBeNull() - expect(screen.queryByText(MORE_NARRATION)).toBeNull() - const toggles = screen.getAllByRole('button', { name: 'Toggle turn details' }) - expect(toggles).toHaveLength(1) - - fireEvent.click(toggles[0]!) - expect(screen.getByText(NARRATION)).toBeInTheDocument() - expect(screen.getByText(MORE_NARRATION)).toBeInTheDocument() - }) + ) // The answer is the LAST prose the agent produced. A turn whose final output // is a tool run still answers with the prose before it. diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx index e8d714fad1a..300a4f01fb9 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx @@ -9,7 +9,8 @@ import { NativeChatMessageList } from './NativeChatMessageList' import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' import type { AgentJournalItemBody, - AgentJournalRenderItem + AgentJournalRenderItem, + AgentJournalTurnScope } from '../../../../shared/agent-session-journal-types' // The turn record this host writes, and the legacy status row an older host sends. @@ -649,92 +650,144 @@ describe('NativeChatMessageList turn indicator', () => { expect(screen.getByText('Working for 0s')).toBeInTheDocument() }) - it('keeps the live bar under the prompt that opened the running turn, not a message sent mid-turn', () => { - const prompt = (id: string, text: string): NativeChatLiveSession['messages'][number] => ({ - id, - role: 'user', - blocks: [{ type: 'text', text }], - timestamp: 1, - source: 'transcript' - }) - const messages = [ - prompt('user-a', 'Run four sleeps'), - { - id: 'tool-a', - role: 'assistant' as const, - blocks: [ - { - type: 'tool-call' as const, - name: 'Bash', - input: { command: 'sleep 15' }, - state: 'running' as const - } - ], - timestamp: 2, - source: 'transcript' as const - }, - prompt('user-b', 'Also say banana') - ] - const list = (props: Partial>) => ( - - ) - // B was sent while A's turn runs: the host still names A as the running turn's opener. - const { rerender } = render( - list({ - workingStartedAt: Date.now() - 5000, - settledTurns: new Map(), - activeTurnOpenedBy: 'user-a' + it.each([ + ["a host that states each row's turn", true], + ['a host that states none', false] + ])( + 'keeps the live bar under the prompt that opened the running turn, not a message sent mid-turn, on %s', + (_, scoped) => { + const prompt = (id: string, text: string): NativeChatLiveSession['messages'][number] => ({ + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: 1, + source: 'transcript' }) - ) - const barA = screen.getByText('Working for 5s').closest('[data-native-chat-turn-status]') - expect(screen.getAllByText(/Working for/)).toHaveLength(1) - expect(screen.getByText('Run four sleeps').compareDocumentPosition(barA!)).toBe( - Node.DOCUMENT_POSITION_FOLLOWING - ) - expect(barA!.compareDocumentPosition(screen.getByText('Also say banana'))).toBe( - Node.DOCUMENT_POSITION_FOLLOWING - ) + const messages = [ + prompt('user-a', 'Run four sleeps'), + { + id: 'tool-a', + role: 'assistant' as const, + blocks: [ + { + type: 'tool-call' as const, + name: 'Bash', + input: { command: 'sleep 15' }, + state: 'running' as const + } + ], + timestamp: 2, + source: 'transcript' as const + }, + prompt('user-b', 'Also say banana') + ] + // B was handed over while A's turn runs, so a host that states scopes puts it in that turn. + const t1: AgentJournalTurnScope = { kind: 'turn', turnItemId: 't1' } + const thread: AgentJournalTurnScope = { kind: 'thread' } + const row = ( + itemId: string, + sequence: number, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope + ): AgentJournalRenderItem => ({ + itemId, + revision: 1, + sequence, + observedAt: sequence, + body, + ...(scoped ? { turnScope } : {}) + }) + const journal = (t1State: 'running' | 'completed', t2State?: 'running' | 'completed') => [ + row('user-a', 1, { kind: 'message', role: 'user', blocks: [] }, thread), + row('t1', 2, { kind: 'turn', turnId: 't1', state: t1State, userItemId: 'user-a' }, thread), + row('tool-a', 3, { kind: 'message', role: 'assistant', blocks: [] }, t1), + row('user-b', 4, { kind: 'message', role: 'user', blocks: [] }, t1), + ...(t2State + ? [ + row( + 't2', + 5, + { kind: 'turn', turnId: 't2', state: t2State, userItemId: 'user-b' }, + thread + ) + ] + : []) + ] + const list = (props: Partial>) => ( + + ) + // B was sent while A's turn runs: the host's record still names A as the running turn's opener. + const { rerender } = render( + list({ + workingStartedAt: Date.now() - 5000, + settledTurns: new Map(), + journalItems: journal('running') + }) + ) + const barA = screen.getByText('Working for 5s').closest('[data-native-chat-turn-status]') + expect(screen.getAllByText(/Working for/)).toHaveLength(1) + expect(screen.getByText('Run four sleeps').compareDocumentPosition(barA!)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + expect(barA!.compareDocumentPosition(screen.getByText('Also say banana'))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) - // B's own turn opens: A settles in place and B counts from A's end. - rerender( - list({ - workingStartedAt: Date.now() - 1000, - settledTurns: new Map([['user-a', { startedAt: 1, workedSeconds: 17 }]]), - activeTurnOpenedBy: 'user-b' - }) - ) - expect(screen.getByText('Worked for 17s').closest('[data-native-chat-turn-status]')).toBe(barA) - const barB = screen.getByText('Working for 1s').closest('[data-native-chat-turn-status]') - expect(screen.getByText('Also say banana').compareDocumentPosition(barB!)).toBe( - Node.DOCUMENT_POSITION_FOLLOWING - ) + // A settles in place: under its prompt, above B. A's tool work is A's on either host (read + // from its scope, or from journal order), so the settled bar becomes that work's fold toggle. + const expectSettledUnderA = () => { + const settledA = screen + .getByText('Worked for 17s') + .closest('[data-native-chat-turn-status]') + expect(screen.getByText('Run four sleeps').compareDocumentPosition(settledA!)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + expect(settledA!.compareDocumentPosition(screen.getByText('Also say banana'))).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + } - rerender( - list({ - isWorking: false, - session: { ...session, status: 'ready', messages }, - workingStartedAt: null, - settledTurns: new Map([ - ['user-a', { startedAt: 1, workedSeconds: 17 }], - ['user-b', { startedAt: 2, workedSeconds: 51 }] - ]), - activeTurnOpenedBy: null - }) - ) - expect(screen.getByText('Worked for 17s').closest('[data-native-chat-turn-status]')).toBe(barA) - expect( - screen - .getByText('Also say banana') - .compareDocumentPosition(screen.getByText('Worked for 51s')) - ).toBe(Node.DOCUMENT_POSITION_FOLLOWING) - expect(screen.queryByText(/Working for/)).toBeNull() - }) + // B's own turn opens: A settles in place and B counts from A's end. + rerender( + list({ + workingStartedAt: Date.now() - 1000, + settledTurns: new Map([['user-a', { startedAt: 1, workedSeconds: 17 }]]), + journalItems: journal('completed', 'running') + }) + ) + expectSettledUnderA() + const barB = screen.getByText('Working for 1s').closest('[data-native-chat-turn-status]') + expect(screen.getByText('Also say banana').compareDocumentPosition(barB!)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING + ) + + rerender( + list({ + isWorking: false, + session: { ...session, status: 'ready', messages }, + workingStartedAt: null, + settledTurns: new Map([ + ['user-a', { startedAt: 1, workedSeconds: 17 }], + ['user-b', { startedAt: 2, workedSeconds: 51 }] + ]), + journalItems: journal('completed', 'completed') + }) + ) + expectSettledUnderA() + expect( + screen + .getByText('Also say banana') + .compareDocumentPosition(screen.getByText('Worked for 51s')) + ).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + expect(screen.queryByText(/Working for/)).toBeNull() + } + ) it("uses the completed caret to expand that turn's tool details", () => { const startedAt = Date.now() - 3000 diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx new file mode 100644 index 00000000000..ec6ca5b3734 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.turn-membership.test.tsx @@ -0,0 +1,432 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' +import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' +import { selectStructuredAgentSettledTurns } from '../../../../shared/structured-agent-session-turn-timing' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const THREAD: AgentJournalTurnScope = { kind: 'thread' } +const inTurn = (turnItemId: string): AgentJournalTurnScope => ({ kind: 'turn', turnItemId }) +let sequence = 0 + +function item( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope = THREAD +): AgentJournalRenderItem { + sequence += 1 + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } +} +const say = ( + itemId: string, + role: 'user' | 'assistant', + text: string, + scope: AgentJournalTurnScope = THREAD +) => item(itemId, { kind: 'message', role, blocks: [{ type: 'text', text }] }, scope) +const work = (itemId: string, scope: AgentJournalTurnScope) => + item( + itemId, + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'completed' }] + }, + scope + ) +const settledTurn = (itemId: string, userItemId: string, seconds: number, start: number) => + item(itemId, { + kind: 'turn', + turnId: itemId, + state: 'completed', + outcome: 'success', + userItemId, + startedAt: start, + completedAt: start + seconds * 1000 + }) + +/** "List three fruits", answered and settled in 4s. */ +function fruitTurn(): AgentJournalRenderItem[] { + return [ + say('u1', 'user', 'List three fruits'), + settledTurn('t1', 'u1', 4, 1_000), + say('t1-narration', 'assistant', 'Thinking about fruit.', inTurn('t1')), + work('t1-work', inTurn('t1')), + say('t1-answer', 'assistant', 'Apple, banana, cherry.', inTurn('t1')) + ] +} + +function journalList( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[] = [], + { + isWorking = false, + workingStartedAt = null, + hostTiming = true + }: { isWorking?: boolean; workingStartedAt?: number | null; hostTiming?: boolean } = {} +): React.JSX.Element { + return ( + + ) +} + +function renderJournal( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[] = [], + isWorking = false +): void { + render(journalList(items, submissions, { isWorking })) +} + +function follows(later: HTMLElement, earlier: HTMLElement): boolean { + return Boolean(earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING) +} + +describe('NativeChatMessageList turns from the turn record', () => { + it('gives /compact its own turn and result, and leaves the turn before it untouched', () => { + const compactEntry = agentJournalSubmissionKey('cmd-1') + const commandTurn = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' + }) + const items = [ + ...fruitTurn(), + item(compactEntry, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + settledTurn(commandTurn, compactEntry, 2, 10_000), + item( + 'command-result', + { kind: 'status', text: 'Conversation compacted.', presentation: 'compaction' }, + inTurn(commandTurn) + ) + ] + renderJournal(items, [ + { + clientMessageId: 'cmd-1', + fence: 1, + payloadFingerprint: 'f', + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 9_000, + resolvedAt: 12_000 + } + ]) + + expect(screen.getByText('Apple, banana, cherry.')).toBeInTheDocument() + expect(screen.queryByText('Thinking about fruit.')).toBeNull() + const compact = screen.getByText('/compact') + const previousStatus = screen.getByText('Worked for 4s') + const ownStatus = screen.getByText('Worked for 2s') + expect(follows(compact, previousStatus)).toBe(true) + expect(follows(ownStatus, compact)).toBe(true) + expect(screen.getByRole('separator', { name: 'Context compacted' })).toBeInTheDocument() + }) + + it('draws a message waiting behind /compact after the compaction, never above it', () => { + const compactEntry = agentJournalSubmissionKey('cmd-1') + const commandTurn = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' + }) + const held = agentJournalSubmissionKey('held') + const items = [ + ...fruitTurn(), + item(compactEntry, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + settledTurn(commandTurn, compactEntry, 2, 10_000), + // Accepted while the command ran, so written before the command's result. + say(held, 'user', 'Say DONE'), + item( + 'command-result', + { kind: 'status', text: 'Conversation compacted.', presentation: 'compaction' }, + inTurn(commandTurn) + ) + ] + const submission = ( + clientMessageId: string, + handedOverAt?: number + ): AgentJournalSubmission => ({ + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: + handedOverAt === undefined && clientMessageId === 'held' ? 'pending' : 'accepted', + providerItemId: null, + reason: null, + submittedAt: 9_000, + resolvedAt: null, + handoverRecorded: true, + ...(handedOverAt !== undefined ? { handedOverAt } : {}) + }) + // The command settled; the loop has not yet handed the held message over. + renderJournal(items, [submission('cmd-1', 9_500), submission('held')]) + + expect( + follows( + screen.getByText('Say DONE'), + screen.getByRole('separator', { name: 'Context compacted' }) + ) + ).toBe(true) + }) + + it('keeps the row that says why a turn stopped visible in its folded turn', () => { + const items = [ + ...fruitTurn(), + item( + 'exit', + { kind: 'status', text: 'The agent exited unexpectedly.', tone: 'error' }, + inTurn('t1') + ) + ] + renderJournal(items) + + expect(screen.queryByText('Thinking about fruit.')).toBeNull() + expect(screen.getByText('The agent exited unexpectedly.')).toBeInTheDocument() + }) + + it('folds a turn the provider resumed on its own under its own status', () => { + const wake = agentJournalItemKey({ + provider: 'legacy', + agent: 'claude', + sessionId: 'session-1', + recordId: 'turn-lifecycle:wake' + }) + const items = [ + ...fruitTurn(), + // Claude keys a resumed turn's record on itself: no message opened it. + settledTurn(wake, wake, 9, 20_000), + say('wake-narration', 'assistant', 'The background build finished.', inTurn(wake)), + work('wake-work', inTurn(wake)), + say('wake-answer', 'assistant', 'All tests pass now.', inTurn(wake)) + ] + renderJournal(items) + + const previousStatus = screen.getByText('Worked for 4s') + const ownStatus = screen.getByText('Worked for 9s') + expect(follows(ownStatus, screen.getByText('Apple, banana, cherry.'))).toBe(true) + expect(follows(ownStatus, previousStatus)).toBe(true) + expect(screen.queryByText('The background build finished.')).toBeNull() + expect(screen.getByText('All tests pass now.')).toBeInTheDocument() + expect(screen.getAllByRole('button', { name: 'Toggle turn details' })).toHaveLength(2) + }) + + it("keeps the settled turn's duration while a turn the provider opened runs after it", () => { + const wake = agentJournalItemKey({ + provider: 'legacy', + agent: 'claude', + sessionId: 'session-1', + recordId: 'turn-lifecycle:wake' + }) + const items = [ + ...fruitTurn(), + item(wake, { + kind: 'turn', + turnId: wake, + state: 'running', + userItemId: wake, + startedAt: 20_000 + }), + say('wake-answer', 'assistant', 'Checking the background build.', inTurn(wake)) + ] + renderJournal(items, [], true) + + expect(screen.getByText('Worked for 4s')).toBeInTheDocument() + expect(screen.getByText('Checking the background build.')).toBeInTheDocument() + }) + + it('clocks a turn the provider opened under its own key, not the user turn before it', () => { + const wake = 'wake' + const ask = say('u1', 'user', 'List three fruits') + const answer = (state: 'running' | 'completed') => [ + ask, + item('t1', { kind: 'turn', turnId: 't1', state, userItemId: 'u1' }), + say('t1-answer', 'assistant', 'Apple, banana, cherry.', inTurn('t1')) + ] + // No host durations: this client's own clock is all the rows have. + const local = { hostTiming: false } + const { rerender } = render( + journalList(answer('running'), [], { + ...local, + isWorking: true, + workingStartedAt: Date.now() - 7_000 + }) + ) + rerender(journalList(answer('completed'), [], local)) + expect(screen.getByText('Worked for 7s')).toBeInTheDocument() + + const woken = [ + ...answer('completed'), + item(wake, { kind: 'turn', turnId: wake, state: 'running', userItemId: 'claude:wake' }), + say('wake-answer', 'assistant', 'Checking the background build.', inTurn(wake)) + ] + rerender(journalList(woken, [], { ...local, isWorking: true, workingStartedAt: Date.now() })) + expect(screen.getByText('Worked for 7s')).toBeInTheDocument() + }) + + it('draws a message waiting behind a running /compact after its live activity', () => { + const compactEntry = agentJournalSubmissionKey('cmd-1') + const commandTurn = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' + }) + const held = agentJournalSubmissionKey('held') + const items = [ + ...fruitTurn(), + item(compactEntry, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + item(commandTurn, { + kind: 'turn', + turnId: 'compact:cmd-1', + state: 'running', + userItemId: compactEntry, + startedAt: 10_000 + }), + say(held, 'user', 'Say DONE') + ] + const submission = ( + clientMessageId: string, + handedOverAt?: number + ): AgentJournalSubmission => ({ + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: handedOverAt === undefined ? 'pending' : 'accepted', + providerItemId: null, + reason: null, + submittedAt: 9_000, + resolvedAt: null, + handoverRecorded: true, + ...(handedOverAt !== undefined ? { handedOverAt } : {}) + }) + const { container } = render( + journalList(items, [submission('cmd-1', 9_500), submission('held')], { + isWorking: true, + workingStartedAt: 10_000 + }) + ) + + const activity = container.querySelector('[data-native-chat-turn-activity]') + expect(activity).not.toBeNull() + expect(follows(activity!, screen.getByText('/compact'))).toBe(true) + expect(follows(screen.getByText('Say DONE'), activity!)).toBe(true) + }) + + it("keeps a steer on its way into an ordinary running turn above that turn's activity", () => { + const steer = agentJournalSubmissionKey('steer') + const items = [ + say('u1', 'user', 'List three fruits'), + item('t1', { + kind: 'turn', + turnId: 't1', + state: 'running', + userItemId: 'u1', + startedAt: 10_000 + }), + say('t1-narration', 'assistant', 'Thinking about fruit.', inTurn('t1')), + say(steer, 'user', 'Make it four') + ] + const { container } = render( + journalList( + items, + [ + { + clientMessageId: 'steer', + fence: 1, + payloadFingerprint: 'steer', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 10_500, + resolvedAt: null, + handoverRecorded: true + } + ], + { isWorking: true, workingStartedAt: 10_000 } + ) + ) + + // Handed over within moments, where it already is: it must not cross the activity line to wait. + const activity = container.querySelector('[data-native-chat-turn-activity]') + expect(activity).not.toBeNull() + expect(follows(activity!, screen.getByText('Make it four'))).toBe(true) + }) + + it('keeps a message whose own start is pending above the activity that start reports', () => { + const waiting = agentJournalSubmissionKey('first') + const { container } = render( + journalList( + [say(waiting, 'user', 'Hello there')], + [ + { + clientMessageId: 'first', + fence: 1, + payloadFingerprint: 'first', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 9_000, + resolvedAt: null, + handoverRecorded: true + } + ], + { isWorking: true, workingStartedAt: 9_000 } + ) + ) + + const activity = container.querySelector('[data-native-chat-turn-activity]') + expect(activity).not.toBeNull() + expect(follows(activity!, screen.getByText('Hello there'))).toBe(true) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 1f416bcc070..935d247309a 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -273,6 +273,7 @@ export function NativeChatStructuredSession( ) } + +/** Rows drawn after the live turn's activity, outside the window: few, and the newest there are. */ +export function NativeChatWaitingTranscriptItems({ + slots, + context +}: { + slots: readonly NativeChatTranscriptSlot[] + context: NativeChatTranscriptRowContext +}): React.JSX.Element { + return ( + <> + {slots.map((slot) => ( + + ))} + + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts b/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts index 3a08c6a503e..a541c601966 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-rail-items.test.ts @@ -10,7 +10,6 @@ import { NATIVE_CHAT_RAIL_MAX_TICKS, type NativeChatRailItem } from './native-chat-message-rail-items' -import { selectNativeChatActiveTurnKey } from '../../../../shared/native-chat-turn-status' function text(id: string, body: string, role: NativeChatMessage['role'] = 'assistant') { return { @@ -43,7 +42,7 @@ function slotsOf(messages: NativeChatMessage[]) { return buildNativeChatTranscriptSlots({ messages, turnKeys, - activeTurnKey: selectNativeChatActiveTurnKey(messages), + liveTurnKey: undefined, receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, turnDiffs: new Map(), diff --git a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts index b1efe034acd..f84a99ed2c0 100644 --- a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts @@ -18,7 +18,6 @@ import { omitNativeChatThreadGoalRows } from './native-chat-thread-goal-rows' import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' import type { NativeChatTurnDiff } from './native-chat-turn-diffs' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' -import { selectNativeChatActiveTurnKey } from '../../../../shared/native-chat-turn-status' function row(sequence: number, body: AgentJournalItemBody, itemId = `item-${sequence}`) { return { itemId, revision: 1, sequence, observedAt: 1_000 + sequence, body } @@ -82,7 +81,7 @@ function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJour const slots = buildNativeChatTranscriptSlots({ messages, turnKeys, - activeTurnKey: selectNativeChatActiveTurnKey(messages), + liveTurnKey: turn, receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, turnDiffs: new Map(), diff --git a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts index 674e14d6bf8..30f818d8d03 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts @@ -93,13 +93,13 @@ function supersedes(candidate: NativeChatMessage, existing: NativeChatMessage): // Why: the tail bubbles form fixed tiers that timestamps alone can't express. // The streaming preview (null timestamp) must follow real content but sit ahead -// of the optimistic composer echoes, which carry finite `sentAt` timestamps that -// would otherwise sort past it. Rank first, then timestamp within a tier. +// of the optimistic composer echoes and queued sends, which carry finite timestamps +// that would otherwise sort past it. Rank first, then timestamp within a tier. function messageSortRank(message: NativeChatMessage): number { if (message.id === NATIVE_CHAT_STREAMING_ID) { return 1 } - if (isPendingMessageId(message.id) || isLaunchPromptMessageId(message.id)) { + if (message.queued || isPendingMessageId(message.id) || isLaunchPromptMessageId(message.id)) { return 2 } return 0 diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index 4c908375576..4278f216e87 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { nativeChatTurnMembership } from '../../../../shared/native-chat-turn-membership' import { - selectNativeChatActiveTurnKey, selectNativeChatTurnStatuses, type NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status' @@ -39,7 +44,7 @@ function build( return buildNativeChatTranscriptSlots({ messages, turnKeys, - activeTurnKey: selectNativeChatActiveTurnKey(messages), + liveTurnKey: undefined, receipts: new Map(), turnStatuses: NO_STATUSES, turnDiffs: new Map(), @@ -111,7 +116,7 @@ describe('transcript slots', () => { it('keeps a message whose only content is a turn status under it', () => { const status: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: 4 } const slots = build([text('u', '', 'user')], { - activeTurnKey: 'u', + liveTurnKey: 'u', turnStatuses: { active: status, completedByTurn: {} } }) expect(slots).toHaveLength(1) @@ -141,7 +146,7 @@ describe('transcript slots', () => { it('puts the running turn bar under the prompt it answers', () => { const status: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: null } const slots = build([text('u', 'ask', 'user'), text('a', 'answer')], { - activeTurnKey: 'u', + liveTurnKey: 'u', turnStatuses: { active: status, completedByTurn: {} }, isWorking: true }) @@ -206,6 +211,217 @@ describe('a send the host rejected', () => { }) }) +describe('a turn no message opened', () => { + const settled = (workedSeconds: number): NativeChatTurnStatus => ({ + startedAt: 1, + thinking: false, + workedSeconds + }) + + it('draws its status at its first row and folds its work behind it', () => { + const messages = [ + text('u1', 'List three fruits', 'user'), + text('a1', 'Apple, banana, cherry.'), + toolRun('wake-tool'), + text('wake-answer', 'The background task finished.') + ] + const slots = build(messages, { + turnKeys: ['u1', 'u1', 'wake', 'wake'], + turnStatuses: { + active: settled(4), + completedByTurn: { u1: settled(4), wake: settled(9) } + } + }) + const statusOf = (id: string) => + slots.find((slot) => slot.message.id === id)?.status?.workedSeconds + expect(statusOf('u1')).toBe(4) + expect(statusOf('wake-tool')).toBe(9) + expect(statusOf('wake-answer')).toBeUndefined() + const toolSlot = slots.find((slot) => slot.message.id === 'wake-tool') + expect(toolSlot).toMatchObject({ folded: true, turnFolds: true, turnKey: 'wake' }) + }) + + it('keeps a row that reports its turn ending visible in a folded turn', () => { + const messages = [ + text('u1', 'go', 'user'), + toolRun('work'), + { + ...text('exit', 'The agent exited unexpectedly.', 'system'), + blocks: [{ type: 'text' as const, text: 'The agent exited unexpectedly.', tone: 'error' }] + } + ] + const slots = build(messages, { + turnStatuses: { active: settled(3), completedByTurn: { u1: settled(3) } } + }) + // The folded work takes no slot; the report of the end still does. + expect(slots.map((slot) => [slot.message.id, slot.folded])).toEqual([ + ['u1', false], + ['exit', false] + ]) + }) +}) + +describe('the live turn', () => { + const settled = (workedSeconds: number): NativeChatTurnStatus => ({ + startedAt: 1, + thinking: false, + workedSeconds + }) + const working: NativeChatTurnStatus = { startedAt: 1, thinking: false, workedSeconds: null } + const THREAD: AgentJournalTurnScope = { kind: 'thread' } + const inTurn = (turnItemId: string): AgentJournalTurnScope => ({ kind: 'turn', turnItemId }) + let sequence = 0 + const entry = ( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope = THREAD + ): AgentJournalRenderItem => { + sequence += 1 + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } + } + const record = (itemId: string, userItemId: string, state: 'running' | 'completed') => + entry(itemId, { kind: 'turn', turnId: itemId, state, userItemId }) + const row = (id: string, turnScope: AgentJournalTurnScope = THREAD) => + entry( + id, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: id }] }, + turnScope + ) + + /** "List three fruits", answered and settled; then a turn the provider opened on its own. */ + const messages = [ + text('u1', 'List three fruits', 'user'), + text('a1', 'Apple, banana, cherry.'), + toolRun('wake-tool'), + text('wake-note', 'Checking the background build.') + ] + function wakeJournal(state: 'running' | 'completed'): AgentJournalRenderItem[] { + return [ + entry('u1', { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }), + record('t1', 'u1', 'completed'), + row('a1', inTurn('t1')), + record('wake', 'claude:wake', state), + row('wake-tool', inTurn('wake')), + row('wake-note', inTurn('wake')) + ] + } + function buildLive( + rows: NativeChatMessage[], + journal: AgentJournalRenderItem[] | null, + overrides: Partial[0]> + ) { + const { turnKeys, liveTurnKey } = nativeChatTurnMembership( + rows, + journal ? { items: journal, submissions: [] } : null + ) + return build(rows, { turnKeys, liveTurnKey, ...overrides }) + } + const slotOf = (slots: ReturnType, id: string) => + slots.find((slot) => slot.message.id === id) + + it('leaves the settled user turn its duration while a turn the provider opened runs', () => { + const slots = buildLive(messages, wakeJournal('running'), { + isWorking: true, + turnStatuses: { active: working, completedByTurn: { u1: settled(4) } } + }) + expect(slotOf(slots, 'u1')?.status?.workedSeconds).toBe(4) + expect( + slots.filter((slot) => slot.status !== undefined).map((slot) => slot.message.id) + ).toEqual(['u1', 'wake-tool']) + }) + + it('draws a turn the provider opened at its first row, running and then settled', () => { + const running = buildLive(messages, wakeJournal('running'), { + isWorking: true, + turnStatuses: { active: working, completedByTurn: { u1: settled(4) } } + }) + expect(running.map((slot) => [slot.message.id, slot.status])).toEqual([ + ['u1', settled(4)], + ['a1', undefined], + ['wake-tool', working], + ['wake-note', undefined] + ]) + const ended = buildLive(messages, wakeJournal('completed'), { + turnStatuses: { active: settled(4), completedByTurn: { u1: settled(4), wake: settled(9) } } + }) + expect(slotOf(ended, 'u1')?.status?.workedSeconds).toBe(4) + expect(slotOf(ended, 'wake-tool')?.status?.workedSeconds).toBe(9) + }) + + it('keeps the running turn live, and the settled turn before it settled', () => { + const slots = buildLive(messages, wakeJournal('running'), { isWorking: true }) + expect(slots.map((slot) => [slot.message.id, slot.turnKey, slot.activeTurnIsWorking])).toEqual([ + ['u1', 'u1', false], + ['a1', 'u1', false], + ['wake-tool', 'wake', true], + ['wake-note', 'wake', true] + ]) + }) + + it('leaves the settled user turn alone while the running turn has drawn nothing yet', () => { + const journal = wakeJournal('running').slice(0, 4) + const slots = buildLive(messages.slice(0, 2), journal, { + isWorking: true, + turnStatuses: { active: working, completedByTurn: { u1: settled(4) } } + }) + expect( + slots.map((slot) => [slot.message.id, slot.status?.workedSeconds, slot.activeTurnIsWorking]) + ).toEqual([ + ['u1', 4, false], + ['a1', undefined, false] + ]) + }) + + it('still draws a running turn a message opened on that message, with its rows live', () => { + const journal = [ + entry('u1', { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }), + record('t1', 'u1', 'running'), + row('a1', inTurn('t1')) + ] + const rows = messages.slice(0, 2) + const live = buildLive(rows, journal, { isWorking: true }) + expect(live.map((slot) => [slot.turnKey, slot.activeTurnIsWorking])).toEqual([ + ['u1', true], + ['u1', true] + ]) + const stopped = buildLive(rows, journal, { + turnStatuses: { active: settled(3), completedByTurn: {} } + }) + expect(slotOf(stopped, 'u1')?.status?.workedSeconds).toBe(3) + }) + + it('reads a turn the provider opened by journal order where the host states no scope', () => { + const unscoped = wakeJournal('running').map(({ turnScope: _scope, ...rest }) => rest) + const slots = buildLive(messages, unscoped, { + isWorking: true, + turnStatuses: { active: working, completedByTurn: { u1: settled(4) } } + }) + expect( + slots.map((slot) => [slot.turnKey, slot.status?.workedSeconds, slot.activeTurnIsWorking]) + ).toEqual([ + ['u1', 4, false], + ['u1', undefined, false], + ['wake', null, true], + ['wake', undefined, true] + ]) + }) + + it('keeps the newest user row live with no journal', () => { + const slots = buildLive(messages, null, { + isWorking: true, + turnStatuses: { active: settled(2), completedByTurn: {} } + }) + expect( + slots.map((slot) => [slot.turnKey, slot.status?.workedSeconds, slot.activeTurnIsWorking]) + ).toEqual([ + ['u1', 2, true], + ['u1', undefined, true], + ['u1', undefined, true], + ['u1', undefined, true] + ]) + }) +}) + // Rows belong to the turn the host says owns them, not to the nearest preceding // user bubble. The owned turn keys reshape the fold, the bar and liveness. describe('turn-owned grouping', () => { @@ -257,7 +473,7 @@ describe('turn-owned grouping', () => { const messages = [text('u1', 'earlier', 'user'), toolRun('w1'), text('done', 'Woke up.')] const slots = build(messages, { turnKeys: ['u1', 'wake', 'wake'], - activeTurnKey: 'wake', + liveTurnKey: 'wake', turnStatuses: { active: settled, completedByTurn: {} }, isWorking: true }) @@ -302,7 +518,7 @@ describe('turn-owned grouping', () => { const messages = [text('A', 'go', 'user'), toolRun('t1'), text('C', 'next up', 'user')] const slots = build(messages, { turnKeys: ['A', 'A', 'C'], - activeTurnKey: 'A', + liveTurnKey: 'A', isWorking: true }) expect(slots.map((slot) => [slot.message.id, slot.activeTurnIsWorking])).toEqual([ diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index b3631fb8012..7ec4a81653d 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -7,6 +7,7 @@ // single place that answers "does this message take a slot?", and it answers it // with the same derivation the row itself renders from. +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import { isBackgroundTaskBlock, isSubagentGroupBlock, @@ -15,11 +16,9 @@ import { } from '../../../../shared/native-chat-types' import { agentJournalItemSubagentId } from '../../../../shared/agent-session-journal-producer' import { nativeChatSubagentLabel } from '../../../../shared/native-chat-subagent-attribution' -import { - NATIVE_CHAT_UNANCHORED_TURN_KEY, - type NativeChatTurnStatus -} from '../../../../shared/native-chat-turn-status' -import { nativeChatSelfAnchoredTurnRows } from '../../../../shared/native-chat-turn-grouping' +import type { NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status' +import { nativeChatMessagesWaitingBehindLiveTurn } from '../../../../shared/native-chat-turn-membership' +import { nativeChatTurnBarRows } from '../../../../shared/native-chat-turn-grouping' import { nativeChatTurnFold, type NativeChatTurnFoldRow @@ -67,8 +66,9 @@ export type NativeChatTranscriptSlot = { export type NativeChatTranscriptSlotsInput = { messages: readonly NativeChatMessage[] turnKeys: readonly (string | undefined)[] - /** The transcript key whose bar carries the live turn's status. */ - activeTurnKey: string + /** The live turn (`nativeChatTurnMembership`): its bar carries the running clock and its rows + * stay live. Undefined when no row has opened one. */ + liveTurnKey: string | undefined receipts: ReadonlyMap turnStatuses: { active: NativeChatTurnStatus | null @@ -90,7 +90,7 @@ export function buildNativeChatTranscriptSlots( const { messages, turnKeys, - activeTurnKey, + liveTurnKey, receipts, turnStatuses, turnDiffs, @@ -113,6 +113,10 @@ export function buildNativeChatTranscriptSlots( outlivesTurn: message.blocks.some( (block) => isSubagentGroupBlock(block) || isBackgroundTaskBlock(block) ), + reportsTurnOutcome: message.blocks.some( + (block) => + block.type === 'text' && (block.tone === 'error' || block.presentation === 'compaction') + ), ...(agentId === null ? {} : { agentId }) } }) @@ -148,8 +152,7 @@ export function buildNativeChatTranscriptSlots( settledTurnKeys, expandedTurnKeys }) - // A turn with no user bubble (provider-opened) anchors its bar at its first row. - const selfAnchors = nativeChatSelfAnchoredTurnRows(messages, turnKeys) + const bars = nativeChatTurnBarRows(messages, turnKeys) // A turn's rows need not be contiguous (another turn's prompt can land among // them), so its rollup goes under its last row, not every run boundary. const lastRowByTurn = new Map() @@ -162,19 +165,14 @@ export function buildNativeChatTranscriptSlots( for (const [index, message] of messages.entries()) { const turnKey = turnKeys[index] const receipt = receipts.get(message.id) - const anchorsTurnHere = turnKey !== undefined && selfAnchors.get(turnKey) === index - // Only the bubble that opened a turn carries its bar: a message the provider - // folded into a running turn shares the turn's key but not its bar. + const bar = turnKey === undefined ? undefined : bars.get(turnKey) + // A turn's bar draws at its first row, so a message folded into it (a steer) carries none. const candidateStatus = - message.role === 'user' && message.id === activeTurnKey - ? turnStatuses.active - : message.role === 'user' && turnKey === message.id - ? turnStatuses.completedByTurn[turnKey] - : anchorsTurnHere - ? turnKey === activeTurnKey - ? (turnStatuses.active ?? turnStatuses.completedByTurn[turnKey]) - : turnStatuses.completedByTurn[turnKey] - : undefined + turnKey === undefined || bar?.index !== index + ? undefined + : turnKey === liveTurnKey + ? turnStatuses.active + : turnStatuses.completedByTurn[turnKey] // The live turn's bar carries its running clock; it settles in place. const status = candidateStatus ?? undefined const turnDiff = @@ -195,13 +193,12 @@ export function buildNativeChatTranscriptSlots( // Liveness is the owning turn's, not the newest prompt's: a running turn's // rows stay live while a newer message waits behind it. activeTurnIsWorking: - (turnKey === activeTurnKey || - (turnKey === undefined && activeTurnKey === NATIVE_CHAT_UNANCHORED_TURN_KEY)) && + (liveTurnKey ? turnKey === liveTurnKey : turnKey === undefined) && (isWorking || lifecycleWorking), trailingRun: trailingRunIndexes.has(index), receipt, status: status ?? undefined, - statusAbove: anchorsTurnHere && status !== undefined, + statusAbove: bar?.above === true && status !== undefined, folded, turnFolds: turnKey !== undefined && foldableTurnKeys.has(turnKey), turnDiff, @@ -230,3 +227,19 @@ export function nativeChatSlotIndexOf( } return slots.findIndex((slot) => slot.message.id === messageId) } + +/** Splits off the slots of messages waiting behind the live turn: they draw after its live + * activity, not inside it. */ +export function splitNativeChatSlotsWaitingBehindLiveTurn( + slots: readonly NativeChatTranscriptSlot[], + journalItems: readonly AgentJournalRenderItem[] | undefined +): { slots: NativeChatTranscriptSlot[]; waitingSlots: NativeChatTranscriptSlot[] } { + const waiting = nativeChatMessagesWaitingBehindLiveTurn( + slots.map((slot) => slot.message), + journalItems + ) + return { + slots: slots.filter((slot) => !waiting.has(slot.message.id)), + waitingSlots: slots.filter((slot) => waiting.has(slot.message.id)) + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts b/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts index 6772eec7a47..518e33a9586 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-message-rail.test.ts @@ -8,7 +8,6 @@ import type { NativeChatResolvedPrompt } from './native-chat-resolution-receipt' import type { NativeChatTurnDiff } from './native-chat-turn-diffs' import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' import { useNativeChatMessageRail } from './use-native-chat-message-rail' -import { selectNativeChatActiveTurnKey } from '../../../../shared/native-chat-turn-status' function message(id: string, role: NativeChatMessage['role']): NativeChatMessage { return { @@ -32,7 +31,7 @@ function slotsOf(messages: NativeChatMessage[]) { return buildNativeChatTranscriptSlots({ messages, turnKeys, - activeTurnKey: selectNativeChatActiveTurnKey(messages), + liveTurnKey: undefined, receipts: new Map(), turnStatuses: { active: null, completedByTurn: {} }, turnDiffs: new Map(), diff --git a/src/renderer/src/components/native-chat/use-native-chat-turn-membership.ts b/src/renderer/src/components/native-chat/use-native-chat-turn-membership.ts new file mode 100644 index 00000000000..1793dcc1d68 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-turn-membership.ts @@ -0,0 +1,36 @@ +import { useMemo } from 'react' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { nativeChatTurnMembership } from '../../../../shared/native-chat-turn-membership' +import { nativeChatRowsInDrawOrder } from '../../../../shared/native-chat-turn-grouping' + +export type NativeChatTurnRows = { + /** The rows in the order the transcript draws them. */ + messages: readonly NativeChatMessage[] + /** Each drawn row's turn, by index into `messages`. */ + turnKeys: readonly (string | undefined)[] + liveTurnKey: string | undefined +} + +/** Each row's turn, which turn is live, and the order the rows draw in, resolved once: from the + * turn record when the host states scopes, else by journal order. */ +export function useNativeChatTurnMembership( + messages: readonly NativeChatMessage[], + journalItems: readonly AgentJournalRenderItem[] | undefined, + journalSubmissions: readonly AgentJournalSubmission[] | undefined +): NativeChatTurnRows { + return useMemo(() => { + const { turnKeys, liveTurnKey, drawOrder } = nativeChatTurnMembership( + messages, + journalItems ? { items: journalItems, submissions: journalSubmissions ?? [] } : null + ) + return { + messages: nativeChatRowsInDrawOrder(messages, drawOrder), + turnKeys: nativeChatRowsInDrawOrder(turnKeys, drawOrder), + liveTurnKey + } + }, [journalItems, journalSubmissions, messages]) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts b/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts index 33a795b8b9d..6bf7f2fdc81 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-turn-status.ts @@ -1,6 +1,6 @@ -import { useLayoutEffect, useState } from 'react' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { useLayoutEffect, useMemo, useState } from 'react' import { + NATIVE_CHAT_UNANCHORED_TURN_KEY, reduceNativeChatTurnTiming, selectNativeChatTurnStatuses, type NativeChatSettledTurns, @@ -11,16 +11,17 @@ import { export type { NativeChatTurnStatus } export function useNativeChatTurnStatus({ - messages, - activeTurnKey, + turnKeys, + liveTurnKey, isWorking, workingStartedAt, settledTurns, thinking = false }: { - messages: readonly NativeChatMessage[] - /** The user message whose bar carries the live clock (`selectNativeChatActiveTurnKey`). */ - activeTurnKey: string + /** Each row's turn, as `nativeChatTurnMembership` places it. */ + turnKeys: readonly (string | undefined)[] + /** The live turn, whose bar carries the running clock (`nativeChatTurnMembership`). */ + liveTurnKey: string | undefined isWorking: boolean workingStartedAt?: number | null /** Recorded durations (the host's journal or the transcript); they outrank what this client observed. */ @@ -31,12 +32,14 @@ export function useNativeChatTurnStatus({ active: NativeChatTurnStatus | null completedByTurn: Readonly> } { + const activeTurnKey = liveTurnKey ?? NATIVE_CHAT_UNANCHORED_TURN_KEY const [timingByTurn, setTimingByTurn] = useState({}) + const validTurnKeys = useMemo( + () => new Set(turnKeys.filter((turnKey) => turnKey !== undefined)), + [turnKeys] + ) useLayoutEffect(() => { - const validTurnKeys = new Set( - messages.filter((message) => message.role === 'user').map((message) => message.id) - ) setTimingByTurn((current) => reduceNativeChatTurnTiming(current, { activeTurnKey, @@ -46,7 +49,7 @@ export function useNativeChatTurnStatus({ now: Date.now() }) ) - }, [activeTurnKey, isWorking, messages, workingStartedAt]) + }, [activeTurnKey, isWorking, validTurnKeys, workingStartedAt]) return selectNativeChatTurnStatuses(timingByTurn, { activeTurnKey, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx new file mode 100644 index 00000000000..6fc339e90c8 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx @@ -0,0 +1,93 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + call: vi.fn(), + outboxSend: vi.fn() +})) +let items: AgentJournalRenderItem[] = [] + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ + state: { + fence: 3, + items, + submissions: [], + status: 'ready', + error: null, + hasOlder: false, + handoff: null + }, + loadingOlder: false, + loadOlder: vi.fn() + }) +})) + +vi.mock('./use-structured-agent-session-outbox', () => ({ + structuredSessionOperationId: () => 'operation-1', + useStructuredAgentSessionOutbox: () => ({ + outbox: [], + blockedClientMessageId: null, + error: null, + send: mocks.outboxSend, + retry: vi.fn() + }) +})) + +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { useStructuredAgentSession } from './use-structured-agent-session' + +function answer(text: string, scoped: boolean): AgentJournalRenderItem { + return { + itemId: text, + revision: 0, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] }, + ...(scoped ? { turnScope: { kind: 'thread' as const } } : {}) + } +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.outboxSend.mockReturnValue(true) +}) + +/** Starts `/compact` and leaves its reply outstanding, then sends a message. */ +function sendDuringCommand(): boolean { + mocks.call.mockImplementation((_target: unknown, method: string) => + method === 'agentSession.conversationCommand' ? new Promise(() => {}) : Promise.resolve(null) + ) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'codex', + target: { kind: 'local' }, + isVisible: true + }) + ) + act(() => { + void result.current.runConversationCommand('compact') + }) + return result.current.send('typed during the command') +} + +it('still refuses a message locally while an older host runs a command', () => { + items = [answer('from an older host', false)] + + expect(sendDuringCommand()).toBe(false) + expect(mocks.outboxSend).not.toHaveBeenCalled() +}) + +it('queues a message typed during a command on a host that runs it as a turn', () => { + items = [answer('from this host', true)] + + expect(sendDuringCommand()).toBe(true) + expect(mocks.outboxSend).toHaveBeenCalledOnce() +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 8756d47aa71..ce66d47cbce 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -30,6 +30,7 @@ import type { StructuredAgentSessionLaunchView } from './use-native-chat-provisi import { useStructuredAgentSessionThreadGoal } from './use-structured-agent-session-thread-goal' import { useStructuredAgentSessionContextUsage } from './use-structured-agent-session-context-usage' import { useStructuredAgentSessionRailOutline } from './use-structured-agent-session-rail-outline' +import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' export type { StructuredPromptItem } from './structured-agent-session-message-projection' @@ -169,14 +170,15 @@ export function useStructuredAgentSession(args: { /** The journal's rows for sent messages, which carry a rejected message's whole fact. */ submissions: transportState.submissions, blockedClientMessageId: outboxController.blockedClientMessageId, + // A message typed during a command queues behind it on the host. send: (...input: Parameters) => - !commandPending.current && outboxController.send(...input), + // Legacy: an older host refuses sends while a command runs; removable once those hosts age out. + (!commandPending.current || hostStatesTurnScopes(transportState.journalItems)) && + outboxController.send(...input), retry: outboxController.retry, isWorking: transportState.isWorking, workingStartedAt: transportState.turnTiming.workingStartedAt, settledTurns: transportState.turnTiming.settledTurns, - activeTurnOpenedBy: transportState.turnTiming.activeTurnOpenedBy, - turnKeysByItemId: transportState.turnTiming.turnKeysByItemId, turnActivity: transportState.turnActivity, backgroundTasks: transportState.backgroundTasks, turnId: transportState.turnId, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.test.tsx index d065f34f81c..c946c830866 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.test.tsx @@ -76,7 +76,8 @@ describe('useStructuredAgentTurnTiming', () => { vi.useFakeTimers() vi.setSystemTime(CLIENT_NOW) const running = [ - user('u1', 1), + // The acknowledged send: its entry's key is the submission's, not the provider's. + user('orca:first', 1), lifecycle( 't1', 2, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts b/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts index dd6b1662824..e12975bf5ce 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts @@ -12,10 +12,8 @@ import { } from '../../../../shared/structured-agent-turn-clock-anchor' /** Host-recorded turn timing for the structured lane: settled durations straight - * off the journal, the transcript key that owns the running turn's bar, each - * row's owning turn, and a - * skew-free start for the live counter whose host-to-local conversion is latched - * once per turn. */ + * off the journal, and a skew-free start for the live counter whose host-to-local + * conversion is latched once per turn. */ export function useStructuredAgentTurnTiming( { items, @@ -27,13 +25,8 @@ export function useStructuredAgentTurnTiming( hostClock?: StructuredAgentHostClock | null }, turnId: string | null -): { - settledTurns: NativeChatSettledTurns - workingStartedAt: number | null - activeTurnOpenedBy: string | null - turnKeysByItemId: ReadonlyMap -} { - const { settledTurns, runningTiming, activeTurnOpenedBy, turnKeysByItemId } = useMemo( +): { settledTurns: NativeChatSettledTurns; workingStartedAt: number | null } { + const { settledTurns, runningTiming } = useMemo( () => selectStructuredAgentTurnBars(items, submissions, turnId), [items, submissions, turnId] ) @@ -50,10 +43,5 @@ export function useStructuredAgentTurnTiming( if (step.latch !== latch) { setLatch(step.latch) } - return { - settledTurns, - workingStartedAt: step.workingStartedAt, - activeTurnOpenedBy, - turnKeysByItemId - } + return { settledTurns, workingStartedAt: step.workingStartedAt } } diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index 515ba2300c5..82fd9caca69 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -120,6 +120,22 @@ describe('the words written beside a failure fact', () => { ) }) + it('names /compact as the next step for the start a /compact needed', () => { + const compact = (kind: AgentSessionFailureKind) => + agentSessionFailureSentence({ kind }, 'rejection', { + agentName: 'Claude', + command: 'compact' + }) + expect(compact('notSignedIn')).toBe( + 'Claude is not signed in for the selected account. Sign in, then run /compact again.' + ) + expect(compact('startFailed')).toBe("Claude couldn't start. Run /compact again.") + expect(compact('restartFailed')).toBe("Claude couldn't restart. Run /compact again.") + expect(compact('providerStartFailed')).toBe( + 'Claude stopped before it finished starting. Run /compact again.' + ) + }) + it('names the agent that stopped starting, and a failed start or restart gives a next step', () => { const sentence = (kind: AgentSessionFailureKind, agentName?: string) => agentSessionFailureSentence({ kind }, 'rejection', { agentName }) diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 06befa038eb..2b334cf9629 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -16,6 +16,7 @@ import { type SubmissionRejectionFact, type SubmissionRejectionKind } from './agent-session-failure' +import type { AgentSessionConversationCommand } from './agent-session-conversation-command' import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals' import { DISPATCH_REJECTED_CANCELLED, @@ -52,7 +53,7 @@ export type AgentSessionFailureWordsContext = { provider?: 'claude' | 'codex' /** The conversation command a failed start was for, so the next step is to run it again * rather than to send a message. */ - command?: 'clear' + command?: AgentSessionConversationCommand /** The surface retries for the person — its own Retry beside the words, or a read that reconnects * on its own — so they leave out sending or trying again. */ retryControl?: boolean @@ -109,7 +110,7 @@ function quotingPersonDetail(lead: string, detail: ProviderDiagnostic | undefine /** What to do once the start can work, for a sentence that ends in it. */ function retryStep({ command }: AgentSessionFailureWordsContext): string { - return command === 'clear' ? 'run /clear again' : 'send your message again' + return command ? `run /${command} again` : 'send your message again' } /** The next step after a start or restart that failed: the command, or the message, again. */ @@ -117,7 +118,7 @@ function startRetry({ command, retryControl }: AgentSessionFailureWordsContext): if (retryControl) { return '' } - return command === 'clear' ? ' Run /clear again.' : ' Send your message to try again.' + return command ? ` Run /${command} again.` : ' Send your message to try again.' } function couldNot(verb: string): Sentence { @@ -201,6 +202,8 @@ const FAILURE_SENTENCES = { retryControl ? 'This message was not delivered.' : 'This message was not delivered. Send it again to continue.', + commandRefused: ({ retryControl }) => + `This command didn't run.${retryControl ? '' : ' Try it again.'}`, compactionFailed: (_, fact) => quotingPersonDetail('Compaction failed', fact.detail), compactionUnconfirmed: () => 'Compaction completion is unconfirmed.', cancelUnconfirmed: () => 'Cancellation was not confirmed.', diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index ee4d5f739b2..13568348f34 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -34,6 +34,8 @@ export const AGENT_SESSION_FAILURE_KINDS = [ 'chatClosed', 'hostRestarted', 'notDelivered', + /** A conversation command the chat's state refused when its turn to run came. */ + 'commandRefused', 'compactionFailed', 'compactionUnconfirmed', 'cancelUnconfirmed', @@ -119,7 +121,8 @@ export type AgentSessionFailureFact = { kind: AgentSessionFailureKind /** Provider-authored only; absent whenever Orca wrote the words. */ detail?: ProviderDiagnostic - /** On `restartFailed` and `startFailed`: the refusal that kept the agent from starting. */ + /** On `restartFailed` and `startFailed`: the refusal that kept the agent from starting. On + * `commandRefused`: the refusal that kept the command from running. */ refusal?: AgentSessionRefusalReference /** On `attachmentInvalid`: which check the attachment failed. */ attachment?: AgentSessionAttachmentProblem diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index d193db01846..c6d3fc7f5e6 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -178,7 +178,8 @@ const MessageBody = z.object({ role: z.string().min(1), blocks: z.array(Block), // Open like roles: a send mode a newer build writes must not turn the row malformed. - sentAs: z.string().min(1).optional() + sentAs: z.string().min(1).optional(), + command: z.object({ name: z.string().min(1) }).optional() }) const ThreadGoal = z.object({ @@ -276,7 +277,8 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ requestedAt: z.number().finite().positive().optional(), completedAt: z.number().finite().positive().optional(), durationMs: z.number().finite().nonnegative().optional(), - contextUsage: AgentSessionContextUsageSchema.optional() + contextUsage: AgentSessionContextUsageSchema.optional(), + providerTurnId: z.string().min(1).optional() }) ]) @@ -294,6 +296,13 @@ export const AgentJournalProducerLinkageFields = { attempt: z.number().int().optional() } as const +/** Open like the other persisted vocabularies: a scope kind a newer host states must not turn + * the row malformed. A reader places only `turn` with an id; anything else reads as `thread`. */ +export const AgentJournalTurnScopeSchema = z.object({ + kind: z.string().min(1), + turnItemId: z.string().min(1).optional() +}) + export const AgentJournalRenderItemSchema = z.object({ itemId: z.string().min(1), revision: z.number().int(), @@ -303,6 +312,7 @@ export const AgentJournalRenderItemSchema = z.object({ observedAt: z.number(), recovered: z.literal(true).optional(), recoveredAt: z.number().optional(), + turnScope: AgentJournalTurnScopeSchema.optional(), ...AgentJournalProducerLinkageFields }) diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index a17561e6b81..2ca143b8681 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -103,6 +103,9 @@ export type AgentJournalMessageItem = { /** Absent ⇒ an ordinary turn input. `goal` ⇒ the text was set as the thread * goal's objective, and the provider pursues it without a turn of its own. */ sentAs?: AgentJournalMessageSendMode + /** Present on a conversation command the user sent, such as `/compact`. The text is what the + * user typed; this names the command so no reader parses it. Open like `sentAs`. */ + command?: { name: string } } export type AgentJournalToolCallState = 'running' | 'completed' | 'failed' @@ -228,6 +231,9 @@ export type AgentJournalTurnLifecycle = { /** What the provider said about its context window during or after this turn. * Usually written by a later revision, since the provider answers after the end. */ contextUsage?: AgentSessionContextUsage + /** On a turn a conversation command opened: the provider turn that carried out the command, + * once the provider opened one. Nothing else re-derives it after the command settles. */ + providerTurnId?: string } /** Provider thread-goal lifecycle. Open like other persisted vocabularies: a @@ -341,6 +347,18 @@ export type AgentJournalProducerLinkage = { attempt?: number } +/** Which turn a row belongs to, stated by the write that created it. `turn` names the turn + * record's journal key; `thread` is a row that belongs to no turn — a notice about the + * conversation, or a message not yet delivered into one. Turn records themselves are `thread`. */ +export type AgentJournalTurnScope = { kind: 'turn'; turnItemId: string } | { kind: 'thread' } + +export const AGENT_JOURNAL_THREAD_SCOPE: AgentJournalTurnScope = { kind: 'thread' } + +/** Who produced a row and which turn it belongs to: what every item write states. */ +export type AgentJournalRowAttribution = AgentJournalProducerLinkage & { + turnScope: AgentJournalTurnScope +} + /** Where the journal placed an item: the sequence of the row that created it, * then its place among that row's writes. The timeline's only ordering key. */ export type AgentJournalPosition = { @@ -364,6 +382,8 @@ export type AgentJournalRenderItem = AgentJournalProducerLinkage & { recovered?: true /** When crash reconciliation wrote this revision; present exactly when `recovered` is. */ recoveredAt?: number + /** Absent only from a host that predates it. */ + turnScope?: AgentJournalTurnScope } // ─── Submissions ──────────────────────────────────────────────────────────── diff --git a/src/shared/agent-session-rewind.ts b/src/shared/agent-session-rewind.ts index 3767a1c59fc..a3cc94de7ec 100644 --- a/src/shared/agent-session-rewind.ts +++ b/src/shared/agent-session-rewind.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { AgentJournalItemBodySchema } from './agent-session-journal-schemas' +import { + AgentJournalItemBodySchema, + AgentJournalProducerLinkageFields, + AgentJournalTurnScopeSchema +} from './agent-session-journal-schemas' import { parseAgentJournalItemKey } from './agent-session-journal-item-key' import type { AgentSessionMutationEnvelope } from './agent-session-wire' @@ -42,7 +46,10 @@ export const AgentSessionRewindRecordSchema = z.object({ z.object({ itemId: Key.refine((key) => parseAgentJournalItemKey(key) !== null), body: AgentJournalItemBodySchema, - observedAt: z.number().finite() + observedAt: z.number().finite(), + // Absent on records written before rows stated them: the rebuild then places the row by position. + turnScope: AgentJournalTurnScopeSchema.optional(), + ...AgentJournalProducerLinkageFields }) ) .max(10_000) diff --git a/src/shared/agent-session-turn-record.ts b/src/shared/agent-session-turn-record.ts index d321b663b7b..2932eef5c1f 100644 --- a/src/shared/agent-session-turn-record.ts +++ b/src/shared/agent-session-turn-record.ts @@ -59,12 +59,17 @@ export function agentJournalTurnBody(turn: AgentJournalTurnLifecycle): AgentJour return { kind: 'turn', ...turn } } -/** The pre-v3 carrier, for clients that predate the `turn` item. The agent name +/** The pre-v3 carrier, for clients that predate the `turn` item. The agent name is the session's + * when the caller knows it — a command's turn is keyed `orca:…` whatever agent ran it — else it * comes from the lifecycle identity (`legacy::…`). */ export function legacyAgentJournalTurnStatusBody( turn: AgentJournalTurnLifecycle, - itemId: string + itemId: string, + sessionAgent?: string | null ): AgentJournalStatusItem { - const agent = itemId.startsWith('legacy:claude:') ? 'Claude' : 'Codex' + const agent = + (sessionAgent ?? (itemId.startsWith('legacy:claude:') ? 'claude' : 'codex')) === 'claude' + ? 'Claude' + : 'Codex' return { kind: 'status', text: agentTurnLifecycleText(agent, turn.state), turnLifecycle: turn } } diff --git a/src/shared/native-chat-turn-fold.test.ts b/src/shared/native-chat-turn-fold.test.ts index d6bba89e552..7608bb3864b 100644 --- a/src/shared/native-chat-turn-fold.test.ts +++ b/src/shared/native-chat-turn-fold.test.ts @@ -11,6 +11,7 @@ function row(overrides: Partial = {}): NativeChatTurnFold role: 'assistant', rendersProse: true, outlivesTurn: false, + reportsTurnOutcome: false, ...overrides } } @@ -146,6 +147,23 @@ describe('nativeChatTurnFold', () => { }) expect(foldedRows.size).toBe(0) }) + + it('never folds a row reporting how its turn ended, and keeps the last prose as the answer', () => { + const rows = [ + row({ role: 'user' }), + row(), + row({ role: 'system', rendersProse: true, reportsTurnOutcome: true }), + row(), + row({ role: 'system', rendersProse: true, reportsTurnOutcome: true }) + ] + const { foldedRows } = nativeChatTurnFold({ + rows, + settledTurnKeys: SETTLED, + expandedTurnKeys: NONE + }) + expect([...foldedRows]).toEqual([1]) + expect(nativeChatTurnAnswerRows(rows).get('turn-1')).toBe(3) + }) }) describe("a subagent's words are never the turn's answer", () => { diff --git a/src/shared/native-chat-turn-fold.ts b/src/shared/native-chat-turn-fold.ts index 52407a9d524..9c97e2eea58 100644 --- a/src/shared/native-chat-turn-fold.ts +++ b/src/shared/native-chat-turn-fold.ts @@ -25,6 +25,9 @@ export type NativeChatTurnFoldRow = { * spawn roster or a background task. That row is the durable report of how * the work ended — often the only one — so it never folds. */ outlivesTurn: boolean + /** Whether the row reports how its turn ended — an error, or a compaction's result. A fold + * that hid it would leave the turn's status as the only trace of why it stopped. */ + reportsTurnOutcome: boolean /** The subagent that produced the row. Absent ⇒ the session's own agent. */ agentId?: string } @@ -87,11 +90,12 @@ export function nativeChatTurnFold({ for (const [index, row] of rows.entries()) { const { turnKey } = row // Outside the fold by construction: the reader's own message anchors the - // turn, and a roster or background-task row outlives it. + // turn, a roster or background-task row outlives it, and an end report explains it. if ( turnKey === undefined || row.role === 'user' || row.outlivesTurn || + row.reportsTurnOutcome || !settledTurnKeys.has(turnKey) ) { continue diff --git a/src/shared/native-chat-turn-grouping.test.ts b/src/shared/native-chat-turn-grouping.test.ts index 5c6425dd7ed..fed223cc83f 100644 --- a/src/shared/native-chat-turn-grouping.test.ts +++ b/src/shared/native-chat-turn-grouping.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import type { NativeChatMessage } from './native-chat-types' -import { nativeChatRowTurnKeys, nativeChatSelfAnchoredTurnRows } from './native-chat-turn-grouping' +import { + nativeChatRowTurnKeys, + nativeChatTurnBarRows, + nativeChatTurnDrawOrder +} from './native-chat-turn-grouping' function message(id: string, role: NativeChatMessage['role'] = 'assistant'): NativeChatMessage { return { id, role, blocks: [{ type: 'text', text: id }], timestamp: null, source: 'transcript' } @@ -62,15 +66,66 @@ describe('nativeChatRowTurnKeys', () => { }) }) -describe('nativeChatSelfAnchoredTurnRows', () => { - it('anchors a turn with no user bubble at its first rendered row', () => { +describe('nativeChatTurnBarRows', () => { + it('anchors a turn with no user bubble above its first rendered row', () => { const messages = [message('A', 'user'), message('t1'), message('t2'), message('t3')] const turnKeys = ['A', 'A', 'wake-turn', 'wake-turn'] - expect(nativeChatSelfAnchoredTurnRows(messages, turnKeys)).toEqual(new Map([['wake-turn', 2]])) + expect(nativeChatTurnBarRows(messages, turnKeys)).toEqual( + new Map([ + ['A', { index: 0, above: false }], + ['wake-turn', { index: 2, above: true }] + ]) + ) }) - it('never anchors a turn whose key is a rendered message', () => { + it('puts a turn opened by a rendered message under that message', () => { const messages = [message('A', 'user'), message('t1')] - expect(nativeChatSelfAnchoredTurnRows(messages, ['A', 'A']).size).toBe(0) + expect(nativeChatTurnBarRows(messages, ['A', 'A']).get('A')).toEqual({ index: 0, above: false }) + }) +}) + +describe('nativeChatTurnDrawOrder', () => { + it("draws a message that opened a later turn after the earlier turn's remaining rows", () => { + // B was sent while A ran and Claude queued it: A's tool run and answer follow B in the journal. + const messages = [ + message('A', 'user'), + message('a1'), + message('B', 'user'), + message('a2'), + message('a-done'), + message('b1') + ] + const turnKeys = ['A', 'A', 'B', 'A', 'A', 'B'] + expect(nativeChatTurnDrawOrder(messages, turnKeys, new Set(['A', 'B']))).toEqual([ + 0, 1, 3, 4, 2, 5 + ]) + }) + + it('keeps each of several queued openers ahead of its own rows', () => { + const messages = [ + message('A', 'user'), + message('B', 'user'), + message('C', 'user'), + message('a-done'), + message('b1'), + message('c1') + ] + const turnKeys = ['A', 'B', 'C', 'A', 'B', 'C'] + const order = nativeChatTurnDrawOrder(messages, turnKeys, new Set(['A', 'B', 'C'])) + expect(order?.map((index) => messages[index]!.id)).toEqual([ + 'A', + 'a-done', + 'B', + 'b1', + 'C', + 'c1' + ]) + }) + + it('leaves a steer, and a message that opened no turn, where they were written', () => { + const messages = [message('A', 'user'), message('steer', 'user'), message('a1')] + expect(nativeChatTurnDrawOrder(messages, ['A', 'A', 'A'], new Set(['A']))).toBeNull() + const waiting = [message('A', 'user'), message('C', 'user'), message('a1')] + expect(nativeChatTurnDrawOrder(waiting, ['A', 'C', 'A'], new Set(['A']))).toBeNull() }) }) diff --git a/src/shared/native-chat-turn-grouping.ts b/src/shared/native-chat-turn-grouping.ts index 7c181352db5..97570b23edd 100644 --- a/src/shared/native-chat-turn-grouping.ts +++ b/src/shared/native-chat-turn-grouping.ts @@ -1,20 +1,21 @@ -// Which turn each transcript row belongs to, and where a turn with no user -// bubble anchors its bar. Shared because desktop and mobile both group rows and -// place bars from these keys, and a row grouped differently on each surface is -// the same bug twice. +// Which turn each transcript row belongs to, where each turn draws its bar, and the order the rows +// draw in. Shared because desktop and mobile both group rows and place bars from these keys, and a +// row grouped differently on each surface is the same bug twice. -import type { NativeChatMessage } from './native-chat-types' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { readAgentJournalTurn } from './agent-session-turn-record' +import type { NativeChatRole } from './native-chat-types' + +type NativeChatTurnRow = { id: string; role: NativeChatRole } /** - * Resolve each row's turn key. The host's attribution (`turnKeysByItemId`, from - * `selectStructuredAgentTurnBars`) wins, so rows after a mid-turn send stay with - * the turn that produced them. Rows it cannot name keep positional grouping — - * an unmapped user row keys itself, anything else inherits the previous row's - * key — which on a host without turn attribution is exactly today's - * preceding-user-message grouping. + * Resolve each row's turn key. The host's attribution (`turnKeysByItemId`) wins, so rows after a + * mid-turn send stay with the turn that produced them. Rows it cannot name keep positional + * grouping — an unmapped user row keys itself, anything else inherits the previous row's key — + * which with no attribution is exactly preceding-user-message grouping. */ export function nativeChatRowTurnKeys( - messages: readonly NativeChatMessage[], + messages: readonly NativeChatTurnRow[], turnKeysByItemId?: ReadonlyMap | null ): (string | undefined)[] { let currentTurnKey: string | undefined @@ -31,19 +32,124 @@ export function nativeChatRowTurnKeys( }) } -/** For each turn key with no message of its own — a turn the provider opened, or - * whose opener is outside the loaded window — the row index that anchors its - * bar: the turn's first rendered row, matching the turn's own position. */ -export function nativeChatSelfAnchoredTurnRows( - messages: readonly NativeChatMessage[], +/** + * Turn ownership for a host that states no turn scope, read from journal order: every item between + * a root turn record and the next belongs to that record's turn (the record is appended when the + * turn opens, and an opener's user item is written ahead of dispatch). `recordKeys` maps each root + * record to its anchor, or null when it names no opener. A user item that opened any turn keys + * itself; one the provider folded into a running turn (a steer) takes that turn's key, but only + * once the turn produces more rows after it, so a fresh tail send is not pulled into the turn it + * is merely waiting behind. Items before the first record stay absent. + */ +export function nativeChatJournalOrderTurnKeys( + items: readonly AgentJournalRenderItem[], + recordKeys: ReadonlyMap +): ReadonlyMap { + const openers = new Set([...recordKeys.values()].filter((key) => key !== null)) + const keys = new Map() + let currentKey: string | null = null + // User items folded into the current turn, held until a later row proves the turn continued. + let pendingUserItemIds: string[] = [] + for (const item of items) { + if (recordKeys.has(item.itemId)) { + currentKey = recordKeys.get(item.itemId) ?? null + pendingUserItemIds = [] + continue + } + if (readAgentJournalTurn(item.body)) { + continue + } + if (item.body.kind === 'message' && item.body.role === 'user') { + if (openers.has(item.itemId)) { + keys.set(item.itemId, item.itemId) + } else if (currentKey !== null) { + pendingUserItemIds.push(item.itemId) + } + continue + } + if (currentKey !== null) { + for (const userItemId of pendingUserItemIds) { + keys.set(userItemId, currentKey) + } + pendingUserItemIds = [] + keys.set(item.itemId, currentKey) + } + } + return keys +} + +/** Where each turn draws its bar: its first row, and above that row when the turn has no user + * bubble of its own (one the provider opened, or whose opener is outside the loaded window). */ +export function nativeChatTurnBarRows( + messages: readonly { id: string }[], turnKeys: readonly (string | undefined)[] -): ReadonlyMap { - const messageIds = new Set(messages.map((message) => message.id)) - const anchors = new Map() +): ReadonlyMap { + const bars = new Map() turnKeys.forEach((turnKey, index) => { - if (turnKey !== undefined && !messageIds.has(turnKey) && !anchors.has(turnKey)) { - anchors.set(turnKey, index) + if (turnKey !== undefined && !bars.has(turnKey)) { + bars.set(turnKey, { index, above: messages[index]?.id !== turnKey }) } }) - return anchors + return bars +} + +/** + * Row indexes in the order the transcript draws them, or null when that is journal order. A + * message is written when it is sent, but a provider that queues it behind the running turn opens + * its turn only after that turn ends: the rows the earlier turn wrote meanwhile are still that + * turn's. So a message that opened a turn draws after them, just before its own turn's rows. + */ +export function nativeChatTurnDrawOrder( + messages: readonly NativeChatTurnRow[], + turnKeys: readonly (string | undefined)[], + openers: ReadonlySet +): number[] | null { + const opensTurn = (index: number): boolean => + messages[index]?.role === 'user' && + turnKeys[index] === messages[index].id && + openers.has(messages[index].id) + const firstRow = new Map() + turnKeys.forEach((turnKey, index) => { + if (turnKey !== undefined && !firstRow.has(turnKey)) { + firstRow.set(turnKey, index) + } + }) + const movedAfter = new Map() + const moved = new Set() + for (const [index, message] of messages.entries()) { + if (!opensTurn(index)) { + continue + } + let after = -1 + for (let row = index + 1; row < messages.length; row += 1) { + const turnKey = turnKeys[row] + if (turnKey === undefined || opensTurn(row)) { + continue + } + // Its own turn's rows, or a later turn's, end the earlier turns' rows it waited behind. + if (turnKey === message.id || (firstRow.get(turnKey) ?? row) > index) { + break + } + after = row + } + if (after !== -1) { + moved.add(index) + movedAfter.set(after, [...(movedAfter.get(after) ?? []), index]) + } + } + if (moved.size === 0) { + return null + } + return messages.flatMap((_, index) => [ + ...(moved.has(index) ? [] : [index]), + ...(movedAfter.get(index) ?? []) + ]) +} + +/** `rows` in the transcript's draw order. */ +export function nativeChatRowsInDrawOrder( + rows: readonly T[], + drawOrder: readonly number[] | null +): readonly T[] { + return drawOrder === null ? rows : drawOrder.map((index) => rows[index]!) } diff --git a/src/shared/native-chat-turn-membership.test.ts b/src/shared/native-chat-turn-membership.test.ts new file mode 100644 index 00000000000..53963b73ef0 --- /dev/null +++ b/src/shared/native-chat-turn-membership.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey, agentJournalSubmissionKey } from './agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnScope +} from './agent-session-journal-types' +import { nativeChatRowsInDrawOrder } from './native-chat-turn-grouping' +import { nativeChatTurnMembership, structuredAgentTurnAnchors } from './native-chat-turn-membership' +import type { NativeChatRole } from './native-chat-types' + +const THREAD: AgentJournalTurnScope = { kind: 'thread' } +let sequence = 0 + +function item( + itemId: string, + body: AgentJournalItemBody, + /** Null for a host that predates scopes. */ + turnScope: AgentJournalTurnScope | null = THREAD +): AgentJournalRenderItem { + sequence += 1 + return { + itemId, + revision: 0, + sequence, + observedAt: sequence, + body, + ...(turnScope ? { turnScope } : {}) + } +} + +const user = (itemId: string, scope?: AgentJournalTurnScope | null) => + item(itemId, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: itemId }] }, scope) +const assistant = (itemId: string, scope?: AgentJournalTurnScope | null) => + item( + itemId, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: itemId }] }, + scope + ) +const status = (itemId: string, scope?: AgentJournalTurnScope | null) => + item(itemId, { kind: 'status', text: itemId }, scope) +const turn = ( + itemId: string, + userItemId: string | undefined, + scope?: AgentJournalTurnScope | null, + state: 'running' | 'completed' = 'completed' +) => + item( + itemId, + { + kind: 'turn', + turnId: itemId, + state, + ...(userItemId === undefined ? {} : { userItemId }) + }, + scope + ) +const inTurn = (turnItemId: string): AgentJournalTurnScope => ({ kind: 'turn', turnItemId }) + +/** Rows as a surface hands them over: journal items that draw, in order. */ +function rows(items: readonly AgentJournalRenderItem[]): { id: string; role: NativeChatRole }[] { + return items.flatMap((entry): { id: string; role: NativeChatRole }[] => { + if (entry.body.kind === 'message') { + return [{ id: entry.itemId, role: entry.body.role === 'user' ? 'user' : 'assistant' }] + } + return entry.body.kind === 'status' ? [{ id: entry.itemId, role: 'system' }] : [] + }) +} + +function keys( + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[] = [] +) { + return nativeChatTurnMembership(rows(items), { items, submissions }).turnKeys +} + +function liveTurnKey(items: readonly AgentJournalRenderItem[]) { + return nativeChatTurnMembership(rows(items), { items, submissions: [] }).liveTurnKey +} + +describe('nativeChatTurnMembership', () => { + it('groups rows by the turn their scope names, and a steer into the turn it joined', () => { + const items = [ + user('u1'), + turn('t1', 'u1'), + assistant('a1', inTurn('t1')), + user('steer', inTurn('t1')), + assistant('a2', inTurn('t1')), + status('note') + ] + expect(keys(items)).toEqual(['u1', 'u1', 'u1', 'u1', undefined]) + }) + + it('keys a turn no message opened on its own record', () => { + const items = [ + user('u1'), + turn('t1', 'u1'), + assistant('a1', inTurn('t1')), + // A provider-resumed turn: its record names no present user entry. + turn('wake', 'codex:thread:wake:0'), + assistant('a2', inTurn('wake')) + ] + expect(keys(items)).toEqual(['u1', 'u1', 'wake']) + }) + + it('anchors a command turn on its /compact entry, and leaves the turn before it alone', () => { + const compactKey = agentJournalSubmissionKey('cmd-1') + const commandTurn = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'command-turn:cmd-1' + }) + const items = [ + user('u1'), + turn('t1', 'u1'), + assistant('a1', inTurn('t1')), + item(compactKey, { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: '/compact' }], + command: { name: 'compact' } + }), + turn(commandTurn, compactKey), + status('compacted', inTurn(commandTurn)) + ] + expect(keys(items)).toEqual(['u1', 'u1', compactKey, compactKey]) + }) + + it('resolves a turn record naming the provider item a submission adopted', () => { + const sent = agentJournalSubmissionKey('send-1') + const items = [user(sent), turn('t1', 'codex:thread:t1:0'), assistant('a1', inTurn('t1'))] + const submissions: AgentJournalSubmission[] = [ + { + clientMessageId: 'send-1', + fence: 1, + payloadFingerprint: 'f', + dispatchState: 'accepted', + providerItemId: 'codex:thread:t1:0', + reason: null, + submittedAt: 1, + resolvedAt: 2 + } + ] + expect(keys(items, submissions)).toEqual([sent, sent]) + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe(sent) + }) + + it('places a conversation row, and a row naming a turn the journal no longer holds, in none', () => { + const items = [ + user('u1'), + turn('t1', 'u1'), + status('restart-note'), + assistant('orphan', inTurn('gone')) + ] + expect(keys(items)).toEqual(['u1', undefined, undefined]) + }) + + it('groups by position for a host that states no scope, and with no journal', () => { + const items = [user('u1', null), assistant('a1', null), user('u2', null), status('s', null)] + expect(keys(items)).toEqual(['u1', 'u1', 'u2', 'u2']) + expect(nativeChatTurnMembership(rows(items)).turnKeys).toEqual(['u1', 'u1', 'u2', 'u2']) + }) +}) + +describe('the live turn', () => { + it('is a running turn the provider opened on its own, not the settled user turn before it', () => { + const settled = [user('u1'), turn('t1', 'u1'), assistant('a1', inTurn('t1'))] + const wake = turn('wake', 'codex:thread:wake:0', THREAD, 'running') + // Just opened: nothing drawn for it yet, and still the live turn. + expect(liveTurnKey([...settled, wake])).toBe('wake') + expect(liveTurnKey([...settled, wake, assistant('a2', inTurn('wake'))])).toBe('wake') + }) + + it('is the user row that opened the running turn, including after a steer into it', () => { + const items = [ + user('u1'), + turn('t1', 'u1', THREAD, 'running'), + assistant('a1', inTurn('t1')), + user('steer', inTurn('t1')) + ] + expect(liveTurnKey(items)).toBe('u1') + }) + + it('is the newest user row while its send has not opened a turn', () => { + const items = [user('u1'), turn('t1', 'u1'), assistant('a1', inTurn('t1')), user('u2')] + expect(liveTurnKey(items)).toBe('u2') + }) + + it('is the newest user row for a host that states no scope, and with no journal', () => { + const items = [ + user('u1', null), + turn('wake', undefined, null, 'running'), + assistant('a1', null), + user('u2', null), + status('s', null) + ] + expect(liveTurnKey(items)).toBe('u2') + expect(nativeChatTurnMembership(rows(items)).liveTurnKey).toBe('u2') + expect(nativeChatTurnMembership([]).liveTurnKey).toBeUndefined() + }) +}) + +// A message sent while A runs, which the provider queued behind A: its turn opens only after A's +// remaining rows, which the journal wrote after it. +describe('a message the provider answered after the running turn', () => { + const journal = (statesScope: boolean) => { + const scope = (turnItemId?: string) => + statesScope ? (turnItemId ? inTurn(turnItemId) : THREAD) : null + return [ + user('A', scope()), + turn('tA', 'A', scope()), + assistant('a1', scope('tA')), + user('B', scope('tA')), + assistant('a-tool', scope('tA')), + assistant('FIRST DONE', scope('tA')), + turn('tB', 'B', scope(), 'running'), + assistant('b1', scope('tB')) + ] + } + + it.each([ + ['states each row’s turn', true], + ['states no scope', false] + ])("draws A's remaining rows in A's turn, then B, on a host that %s", (_host, statesScope) => { + const items = journal(statesScope) + const transcript = rows(items) + const membership = nativeChatTurnMembership(transcript, { items, submissions: [] }) + const drawn = nativeChatRowsInDrawOrder( + transcript.map((row, index) => [row.id, membership.turnKeys[index]]), + membership.drawOrder + ) + expect(drawn).toEqual([ + ['A', 'A'], + ['a1', 'A'], + ['a-tool', 'A'], + ['FIRST DONE', 'A'], + ['B', 'B'], + ['b1', 'B'] + ]) + expect(membership.liveTurnKey).toBe('B') + }) +}) diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts new file mode 100644 index 00000000000..67cd3172e6c --- /dev/null +++ b/src/shared/native-chat-turn-membership.ts @@ -0,0 +1,228 @@ +// Which turn each transcript row belongs to, and which turn is live, read from the turn record +// rather than from position. +// +// A turn's anchor is the user entry that opened it — its record's `userItemId`, directly or +// through the provider item a submission adopted — or, for a turn no entry opened (one the +// provider resumed on its own), the turn record itself. A row belongs to the turn its stated scope +// names; a row scoped to the conversation, or to a turn the journal no longer holds, belongs to +// none. A host that states no scope is read by journal order, and by position where that names +// nothing. +// Shared by desktop and mobile, whose keys must agree. + +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import { isRootAgentJournalItem } from './agent-session-journal-producer' +import type { + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnLifecycle +} from './agent-session-journal-types' +import { readAgentJournalTurn } from './agent-session-turn-record' +import { + nativeChatJournalOrderTurnKeys, + nativeChatRowTurnKeys, + nativeChatTurnDrawOrder +} from './native-chat-turn-grouping' +import type { NativeChatRole } from './native-chat-types' +import { isStructuredAgentSessionCommandTurn } from './structured-agent-session-command-entry' +import { liveStructuredAgentSessionTurnScope } from './structured-agent-session-live-turn' + +/** Whether the host writing this journal states each row's turn. Only a host that runs `/compact` + * as a turn of the send path does, so this is also how a client tells that host from an older one. */ +export function hostStatesTurnScopes(items: readonly AgentJournalRenderItem[]): boolean { + return items.some((item) => item.turnScope !== undefined) +} + +export type NativeChatTurnJournal = { + items: readonly AgentJournalRenderItem[] + submissions: readonly AgentJournalSubmission[] +} + +/** + * Each root turn record's anchor, by the record's item id. `userItemId` resolves to a present user + * entry, directly or through a submission's adopted provider item; a record that names no entry + * falls back to the nearest user entry before it, which only older hosts write. A key nothing + * resolves yet is the send still in flight ahead of the record — Codex reports a turn open before + * it echoes the send — and with none, the turn anchors on its own record. + */ +export function structuredAgentTurnAnchors( + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[] = [] +): ReadonlyMap { + const userItemIds = new Set( + items.flatMap((item) => + item.body.kind === 'message' && item.body.role === 'user' ? [item.itemId] : [] + ) + ) + const aliases = new Map() + // Codex folds a send issued mid-turn into the running turn under the SAME provider key, so the + // earliest submission that names a key is the prompt that opened the turn. + for (const submission of submissions) { + if (submission.providerItemId && !aliases.has(submission.providerItemId)) { + aliases.set(submission.providerItemId, agentJournalSubmissionKey(submission.clientMessageId)) + } + } + const inFlight = new Set( + submissions + .filter((submission) => submission.dispatchState === 'pending' && !submission.providerItemId) + .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) + ) + const anchors = new Map() + let precedingUserItemId: string | null = null + let inFlightSinceLastTurn: string | null = null + for (const item of items) { + if (userItemIds.has(item.itemId)) { + precedingUserItemId = item.itemId + if (inFlightSinceLastTurn === null && inFlight.has(item.itemId)) { + inFlightSinceLastTurn = item.itemId + } + continue + } + const turn = readAgentJournalTurn(item.body) + if (!turn || !isRootAgentJournalItem(item)) { + continue + } + anchors.set( + item.itemId, + anchorOf(item.itemId, turn, userItemIds, aliases, precedingUserItemId, inFlightSinceLastTurn) + ) + inFlightSinceLastTurn = null + } + return anchors +} + +function anchorOf( + turnItemId: string, + turn: AgentJournalTurnLifecycle, + userItemIds: ReadonlySet, + aliases: ReadonlyMap, + precedingUserItemId: string | null, + inFlightUserItemId: string | null +): string { + const key = turn.userItemId + if (key === undefined) { + return precedingUserItemId ?? turnItemId + } + if (userItemIds.has(key)) { + return key + } + const aliased = aliases.get(key) + return aliased !== undefined && userItemIds.has(aliased) + ? aliased + : (inFlightUserItemId ?? turnItemId) +} + +export type NativeChatTurnMembership = { + /** Each row's turn by index: the anchor of the turn it belongs to, or undefined for none. */ + turnKeys: (string | undefined)[] + /** The turn live now, whose bar carries the running clock and whose rows stay live: the running + * root turn's anchor, else the newest user row's turn (a send whose turn has not opened yet). A + * turn the provider opened on its own is live without one. */ + liveTurnKey: string | undefined + /** Row indexes in the order the transcript draws them (`nativeChatTurnDrawOrder`), or null when + * that is the order given. */ + drawOrder: readonly number[] | null +} + +/** + * Places each row in its turn. A user entry that anchors a turn, or is scoped to none, keys + * itself; one delivered into a running turn (a steer) takes that turn's key. A host that states no + * scope is read by journal order instead (`nativeChatJournalOrderTurnKeys`). + */ +export function nativeChatTurnMembership( + messages: readonly { id: string; role: NativeChatRole }[], + journal?: NativeChatTurnJournal | null +): NativeChatTurnMembership { + if (!journal) { + const turnKeys = nativeChatRowTurnKeys(messages) + return { turnKeys, liveTurnKey: newestUserTurnKey(messages, turnKeys), drawOrder: null } + } + const anchors = structuredAgentTurnAnchors(journal.items, journal.submissions) + const running = liveStructuredAgentSessionTurnScope(journal.items) + if (!hostStatesTurnScopes(journal.items)) { + const recordKeys = namedRecordKeys(journal.items, anchors) + const turnKeys = nativeChatRowTurnKeys( + messages, + nativeChatJournalOrderTurnKeys(journal.items, recordKeys) + ) + const runningNamed = running.kind === 'turn' ? recordKeys.get(running.turnItemId) : null + return { + turnKeys, + liveTurnKey: runningNamed ?? newestUserTurnKey(messages, turnKeys), + drawOrder: nativeChatTurnDrawOrder(messages, turnKeys, anchoringUserItems(recordKeys)) + } + } + const runningKey = running.kind === 'turn' ? anchors.get(running.turnItemId) : undefined + const anchoring = new Set(anchors.values()) + const scopes = new Map(journal.items.map((item) => [item.itemId, item.turnScope])) + const turnKeys = messages.map((message) => { + const scope = scopes.get(message.id) + const turnKey = + scope?.kind === 'turn' && scope.turnItemId ? anchors.get(scope.turnItemId) : undefined + if (message.role !== 'user') { + return turnKey + } + return anchoring.has(message.id) ? message.id : (turnKey ?? message.id) + }) + return { + turnKeys, + liveTurnKey: runningKey ?? newestUserTurnKey(messages, turnKeys), + drawOrder: nativeChatTurnDrawOrder(messages, turnKeys, anchoring) + } +} + +/** Each root record's anchor, or null for a record that names no opener (only older hosts write + * one): such a record owns no rows, which keep their position. */ +function namedRecordKeys( + items: readonly AgentJournalRenderItem[], + anchors: ReadonlyMap +): ReadonlyMap { + const keys = new Map() + for (const item of items) { + const anchor = anchors.get(item.itemId) + if (anchor !== undefined) { + keys.set( + item.itemId, + readAgentJournalTurn(item.body)?.userItemId === undefined ? null : anchor + ) + } + } + return keys +} + +function anchoringUserItems(recordKeys: ReadonlyMap): ReadonlySet { + return new Set([...recordKeys.values()].filter((key) => key !== null)) +} + +/** + * The rows accepted but not yet handed over while a conversation command's turn runs, as a message + * sent during `/compact` is: the host hands nothing over until the command ends, so they draw after + * that turn's live activity. Any other running turn takes a send within moments, so it stays put. + */ +export function nativeChatMessagesWaitingBehindLiveTurn( + messages: readonly { id: string; queued?: true }[], + items: readonly AgentJournalRenderItem[] | null | undefined +): ReadonlySet { + const queued = messages.filter((message) => message.queued === true) + return new Set( + queued.length > 0 && items && commandTurnRunning(items) + ? queued.map((message) => message.id) + : [] + ) +} + +function commandTurnRunning(items: readonly AgentJournalRenderItem[]): boolean { + const running = liveStructuredAgentSessionTurnScope(items) + const bodyOf = (itemId: string) => items.find((item) => item.itemId === itemId)?.body + return ( + running.kind === 'turn' && + isStructuredAgentSessionCommandTurn(readAgentJournalTurn(bodyOf(running.turnItemId)), bodyOf) + ) +} + +function newestUserTurnKey( + messages: readonly { role: NativeChatRole }[], + turnKeys: readonly (string | undefined)[] +): string | undefined { + const index = messages.findLastIndex((message) => message.role === 'user') + return index === -1 ? undefined : turnKeys[index] +} diff --git a/src/shared/native-chat-turn-status.ts b/src/shared/native-chat-turn-status.ts index acbab30aabf..3773a9bf423 100644 --- a/src/shared/native-chat-turn-status.ts +++ b/src/shared/native-chat-turn-status.ts @@ -4,8 +4,6 @@ // (used directly — mobile ships English only) so the two surfaces never drift. // Everything here is pure; each platform owns its own clock. -import type { NativeChatMessage } from './native-chat-types' - export const NATIVE_CHAT_TURN_STATUS_COPY = { thinking: 'Thinking', working: 'Working…', @@ -106,20 +104,6 @@ export type NativeChatTurnTimingByTurn = Readonly message.role === 'user')?.id ?? - NATIVE_CHAT_UNANCHORED_TURN_KEY - ) -} - /** The turn-timing state machine, lifted out of the React hook so desktop and * mobile stamp start/stop identically. Returns the same reference when nothing * changed so callers can bail out of a state update. */ diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 392ebb5b8f6..b090666511c 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -208,6 +208,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { turnId?: string /** How a user message was delivered when it was not an ordinary prompt. */ sentAs?: AgentJournalMessageSendMode + /** Accepted but not yet handed to the agent: drawn after everything the agent has done. */ + queued?: true /** Set only by the structured projection, on rows the journal holds, and ranks * them ahead of time. Terminal-backed messages never carry it, and worker reads strip it. */ journalPosition?: AgentJournalPosition diff --git a/src/shared/structured-agent-session-command-entry.ts b/src/shared/structured-agent-session-command-entry.ts new file mode 100644 index 00000000000..fd76b45d43a --- /dev/null +++ b/src/shared/structured-agent-session-command-entry.ts @@ -0,0 +1,54 @@ +// A conversation command the user sent, such as `/compact`, and the turn it ran as. Neither is a +// request of the user's to the agent: the sidebar's prompt, preview and verdict, the completion +// feed and restart resume all read past them to the last real request. + +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalTurnLifecycle +} from './agent-session-journal-types' +import { readAgentJournalTurn } from './agent-session-turn-record' + +export function isStructuredAgentSessionCommandEntry( + body: AgentJournalItemBody | null | undefined +): boolean { + return body?.kind === 'message' && body.command !== undefined +} + +/** A turn a command entry opened: the entry its record names carries the command. */ +export function isStructuredAgentSessionCommandTurn( + turn: Pick | null | undefined, + bodyOf: (itemId: string) => AgentJournalItemBody | null | undefined +): boolean { + return ( + turn?.userItemId !== undefined && isStructuredAgentSessionCommandEntry(bodyOf(turn.userItemId)) + ) +} + +/** The item ids of every command turn record in `items`, for readers that skip their rows. */ +export function structuredAgentSessionCommandTurnItemIds( + items: readonly AgentJournalRenderItem[] +): ReadonlySet { + const bodies = new Map(items.map((item) => [item.itemId, item.body])) + const ids = new Set() + for (const item of items) { + if ( + isStructuredAgentSessionCommandTurn(readAgentJournalTurn(item.body), (id) => bodies.get(id)) + ) { + ids.add(item.itemId) + } + } + return ids +} + +/** A command entry, or a row its turn produced. */ +export function isStructuredAgentSessionCommandRow( + item: Pick, + commandTurnItemIds: ReadonlySet +): boolean { + const scope = item.turnScope + return ( + isStructuredAgentSessionCommandEntry(item.body) || + (scope?.kind === 'turn' && commandTurnItemIds.has(scope.turnItemId)) + ) +} diff --git a/src/shared/structured-agent-session-dispatch-rejection.test.ts b/src/shared/structured-agent-session-dispatch-rejection.test.ts index be16c8a3fbd..95f522a765d 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.test.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.test.ts @@ -56,7 +56,9 @@ describe('classifyDispatchRejection', () => { // Orca stopped a start that hung: the agent failed, and the message never reached it. ['hostStopped', 'undelivered', 'failure'], ['writeFailed', 'transport', 'failure'], - ['queueFull', 'transport', 'failure'] + ['queueFull', 'transport', 'failure'], + // The chat's state refused a queued command when its turn to run came. + ['commandRefused', 'content', 'failure'] ] as const)('reads the written %s rejection as %s, verdict %s', (kind, category, verdict) => { const written = agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection', diff --git a/src/shared/structured-agent-session-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index 68e40a53039..57b8e745ca5 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -95,6 +95,7 @@ const KIND_CATEGORY = { attachmentInvalid: 'content', attachmentUnreadable: 'content', emptyMessage: 'content', + commandRefused: 'content', queueFull: 'transport', writeFailed: 'transport', hostFault: 'transport' @@ -119,6 +120,7 @@ const KIND_VERDICT = { attachmentInvalid: 'failure', attachmentUnreadable: 'failure', emptyMessage: 'failure', + commandRefused: 'failure', queueFull: 'failure', writeFailed: 'failure', hostFault: 'failure', diff --git a/src/shared/structured-agent-session-latest-request.test.ts b/src/shared/structured-agent-session-latest-request.test.ts index 7cdb1b2d36a..6bb7f117bf8 100644 --- a/src/shared/structured-agent-session-latest-request.test.ts +++ b/src/shared/structured-agent-session-latest-request.test.ts @@ -24,13 +24,21 @@ import { projectStructuredAgentSessionStatusSummary } from './structured-agent-s const START_FAILURE = 'Claude is not signed in.' -function userEntry(clientMessageId: string, sequence: number): AgentJournalRenderItem { +/** `intoTurn` names the turn the message's handover delivered it into — a steer. */ +function userEntry( + clientMessageId: string, + sequence: number, + intoTurn?: string +): AgentJournalRenderItem { return { itemId: agentJournalSubmissionKey(clientMessageId), revision: 0, sequence, observedAt: sequence, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] } + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: clientMessageId }] }, + turnScope: intoTurn + ? { kind: 'turn', turnItemId: `codex:turn:${intoTurn}` } + : { kind: 'thread' } } } @@ -183,7 +191,7 @@ const ROWS: Row[] = [ items: [ userEntry('m1', 1), turn('t1', 2, { state: 'completed', outcome: 'success', completedAt: 50 }), - userEntry('steer', 3) + userEntry('steer', 3, 't1') ], submissions: [ sent('m1', { dispatchState: 'accepted' }), diff --git a/src/shared/structured-agent-session-latest-request.ts b/src/shared/structured-agent-session-latest-request.ts index 9679c0c12bd..c6742e8408b 100644 --- a/src/shared/structured-agent-session-latest-request.ts +++ b/src/shared/structured-agent-session-latest-request.ts @@ -1,9 +1,11 @@ // The session's latest request and what became of it: the verdict a sidebar row reports once -// the session is idle, and whether there is a request to list at all. +// the session is idle, whether there is a request to list at all, and the prompt and answer its row +// quotes. // // A request is either a turn, whose record carries the provider's verdict, or a send that never -// became one because the agent or its start refused it. A send inside a running turn (a steer) -// is not a request of its own: the turn it joined answers for it. +// became one because the agent or its start refused it. A send its handover placed inside a +// running turn (a steer) is not a request of its own: the turn it joined answers for it. Nor is a +// conversation command. import type { AgentJournalRenderItem, @@ -16,6 +18,12 @@ import { isRootAgentJournalItem } from './agent-session-journal-producer' import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' import { isUnansweredStructuredAgentSessionDispatch } from './structured-agent-session-unanswered-dispatch' +import { + isStructuredAgentSessionCommandEntry, + isStructuredAgentSessionCommandRow, + structuredAgentSessionCommandTurnItemIds +} from './structured-agent-session-command-entry' +import type { NativeChatBlock } from './native-chat-types' export type StructuredAgentSessionLatestRequest = { kind: 'turn' | 'refused-send' @@ -36,9 +44,16 @@ export function latestStructuredAgentSessionRequest( submissions: readonly AgentJournalSubmission[] ): StructuredAgentSessionLatestRequest | null { const rejected = rejectedSubmissionsByItem(submissions) + const commandTurns = structuredAgentSessionCommandTurnItemIds(items) for (let index = items.length - 1; index >= 0; index -= 1) { const item = items[index] - if (!item || !isRootAgentJournalItem(item)) { + // A conversation command is not a request: the verdict stays the last real request's. + if ( + !item || + !isRootAgentJournalItem(item) || + commandTurns.has(item.itemId) || + isStructuredAgentSessionCommandEntry(item.body) + ) { continue } const turn = readAgentJournalTurn(item.body) @@ -56,7 +71,8 @@ export function latestStructuredAgentSessionRequest( if ( submission && classifyDispatchRejection(submission).verdict === 'failure' && - !deliveredIntoRunningTurn(items, index, submission) + // Handed into a running turn (a steer): that turn answers for it. + item.turnScope?.kind !== 'turn' ) { return { kind: 'refused-send', @@ -111,31 +127,6 @@ function rejectedSubmissionsByItem( return rejected } -/** A send handed over while the turn before it was still running joined that turn. Read off the - * host clock, because a turn record's end revises it in place and keeps no sequence of its own. - * A send never handed over reached no turn. */ -function deliveredIntoRunningTurn( - items: readonly AgentJournalRenderItem[], - index: number, - submission: AgentJournalSubmission -): boolean { - // Older hosts dispatched a send as they recorded it. - const deliveredAt = - submission.handedOverAt ?? (submission.handoverRecorded ? undefined : submission.submittedAt) - if (deliveredAt === undefined) { - return false - } - for (let previous = index - 1; previous >= 0; previous -= 1) { - const item = items[previous] - const turn = readAgentJournalTurn(item?.body) - if (item && turn) { - const endedAt = turnEndedAt(item, turn) - return turn.state === 'running' || (endedAt !== undefined && endedAt > deliveredAt) - } - } - return false -} - /** A turn recovery settled ended when that settle was written: when the user learns it stopped. */ function turnEndedAt( item: AgentJournalRenderItem, @@ -143,3 +134,63 @@ function turnEndedAt( ): number | undefined { return item.recoveredAt ?? turn.completedAt } + +function messageProse(blocks: readonly NativeChatBlock[]): string { + return blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') +} + +/** The newest prompt the session's own user turn carries, as the sidebar quotes + * it. Scoped to root rows for the same reason the assistant line is: a provider + * that journals a subagent's own prompt would otherwise requote it as the + * session's. */ +export function latestStructuredAgentSessionPrompt( + items: readonly AgentJournalRenderItem[] +): string { + const body = latestStructuredAgentSessionUserItem(items)?.body + return body?.kind === 'message' ? messageProse(body.blocks) : '' +} + +export function latestStructuredAgentSessionUserItem( + items: readonly AgentJournalRenderItem[] +): AgentJournalRenderItem | null { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index] + if ( + item?.body.kind === 'message' && + item.body.role === 'user' && + isRootAgentJournalItem(item) && + !isStructuredAgentSessionCommandEntry(item.body) + ) { + return item + } + } + return null +} + +/** The newest prose THE SESSION'S OWN AGENT wrote in the latest user turn — not a + * subagent's, whose rows share this journal and are usually the newer ones while + * a child runs. Tool-only assistant items are skipped; the user boundary clears + * prose from the preceding turn. */ +export function latestStructuredAgentSessionAssistantMessage( + items: readonly AgentJournalRenderItem[] +): string { + const commandTurns = structuredAgentSessionCommandTurnItemIds(items) + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index] + const body = item?.body + // A command and what its turn produced are not the conversation's latest answer. + if (!isRootAgentJournalItem(item) || isStructuredAgentSessionCommandRow(item, commandTurns)) { + continue + } + if (body?.kind === 'message' && body.role === 'user') { + return '' + } + if (body?.kind === 'message' && body.role === 'assistant') { + const prose = messageProse(body.blocks) + if (prose.trim()) { + return prose + } + } + } + return '' +} diff --git a/src/shared/structured-agent-session-live-turn.ts b/src/shared/structured-agent-session-live-turn.ts index 2f5a0747667..d4cba70f616 100644 --- a/src/shared/structured-agent-session-live-turn.ts +++ b/src/shared/structured-agent-session-live-turn.ts @@ -17,9 +17,11 @@ // (none). So a child-linked row can never be what terminates one of these // scans. Re-check that before giving any of those sites a producer. -import type { - AgentJournalRenderItem, - AgentJournalTurnLifecycle +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem, + type AgentJournalTurnLifecycle, + type AgentJournalTurnScope } from './agent-session-journal-types' import { isRootAgentJournalItem } from './agent-session-journal-producer' import { readAgentJournalTurn } from './agent-session-turn-record' @@ -64,6 +66,22 @@ export function newestStructuredAgentSessionTurnBySequence( return newest } +/** The scope a row written now joins: the running turn, or the conversation when none runs. + * By sequence, for items held unordered. */ +export function liveStructuredAgentSessionTurnScope( + items: Iterable +): AgentJournalTurnScope { + let newest: AgentJournalRenderItem | null = null + for (const item of items) { + if ((newest === null || item.sequence >= newest.sequence) && readAgentJournalTurn(item.body)) { + newest = item + } + } + return newest && readAgentJournalTurn(newest.body)?.state === 'running' + ? { kind: 'turn', turnItemId: newest.itemId } + : AGENT_JOURNAL_THREAD_SCOPE +} + /** Whether that newest turn is still running, which is all most callers want. */ export function activeStructuredAgentSessionTurnIdBySequence( items: Iterable diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 334cde7cfe8..da8017a2d50 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -1,6 +1,7 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { agentJournalItemPosition } from './agent-session-journal-position' +import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' import type { NativeChatMessage } from './native-chat-types' import { reconcileStructuredAgentSessionOutbox, @@ -31,8 +32,25 @@ export function projectStructuredAgentSessionMessages( } } const journalled = new Set(visibleItems.map((item) => item.itemId)) + // Not delivered yet, so nothing the agent does meanwhile — a command it waits behind — comes + // after it. Its handover places it in the conversation. + const queued = new Set( + submissions + .filter(isQueuedAgentJournalSubmission) + .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) + ) + const delivered: NativeChatMessage[] = [] + const held: NativeChatMessage[] = [] + for (const message of projectItems(visibleItems)) { + if (queued.has(message.id)) { + held.push({ ...message, queued: true }) + } else { + delivered.push(message) + } + } return [ - ...projectItems(visibleItems), + ...delivered, + ...held, ...optimistic .filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId))) .map((entry): NativeChatMessage => { diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts index 691872c3587..8570b0c3374 100644 --- a/src/shared/structured-agent-session-projection.test.ts +++ b/src/shared/structured-agent-session-projection.test.ts @@ -8,13 +8,13 @@ import { hasUnansweredStructuredAgentSessionDispatch, projectStructuredItemToNativeChat, projectStructuredItemsToNativeChat, - latestStructuredAgentSessionAssistantMessage, projectStructuredAgentSessionStatus, projectStructuredAgentSessionStatusState, projectStructuredAgentSessionStatusSummary, structuredAgentSessionPaneKey } from './structured-agent-session-projection' import { statusStructuredAgentSessionToolCall } from './structured-agent-session-live-turn' +import { latestStructuredAgentSessionAssistantMessage } from './structured-agent-session-latest-request' function item( itemId: string, diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index f35af81b932..0adac0f6d2e 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -10,7 +10,7 @@ import { type AgentJournalSubmission, type AgentJournalTurnOutcome } from './agent-session-journal-types' -import { agentJournalLinkageFields, isRootAgentJournalItem } from './agent-session-journal-producer' +import { agentJournalLinkageFields } from './agent-session-journal-producer' import { structuredAgentSessionStatusBlock } from './structured-agent-session-status-block' import { agentJournalItemRowOrigin } from './agent-session-journal-position' import { @@ -21,6 +21,8 @@ import { describeToolInput } from './native-chat-tool-summary' import { statusStructuredAgentSessionToolCall } from './structured-agent-session-live-turn' import { hasStructuredAgentSessionRequest, + latestStructuredAgentSessionAssistantMessage, + latestStructuredAgentSessionPrompt, latestStructuredAgentSessionRequest, type StructuredAgentSessionLatestRequest } from './structured-agent-session-latest-request' @@ -196,63 +198,6 @@ export function projectStructuredAgentSessionStatus( return owesStructuredAgentSessionWork(items, submissions, currentFence) ? 'working' : 'idle' } -function messageProse(blocks: readonly NativeChatBlock[]): string { - return blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') -} - -/** The newest prompt the session's own user turn carries, as the sidebar quotes - * it. Scoped to root rows for the same reason the assistant line is: a provider - * that journals a subagent's own prompt would otherwise requote it as the - * session's. */ -export function latestStructuredAgentSessionPrompt( - items: readonly AgentJournalRenderItem[] -): string { - const body = latestStructuredAgentSessionUserItem(items)?.body - return body?.kind === 'message' ? messageProse(body.blocks) : '' -} - -export function latestStructuredAgentSessionUserItem( - items: readonly AgentJournalRenderItem[] -): AgentJournalRenderItem | null { - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index] - if ( - item?.body.kind === 'message' && - item.body.role === 'user' && - isRootAgentJournalItem(item) - ) { - return item - } - } - return null -} - -/** The newest prose THE SESSION'S OWN AGENT wrote in the latest user turn — not a - * subagent's, whose rows share this journal and are usually the newer ones while - * a child runs. Tool-only assistant items are skipped; the user boundary clears - * prose from the preceding turn. */ -export function latestStructuredAgentSessionAssistantMessage( - items: readonly AgentJournalRenderItem[] -): string { - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index] - const body = item?.body - if (!isRootAgentJournalItem(item)) { - continue - } - if (body?.kind === 'message' && body.role === 'user') { - return '' - } - if (body?.kind === 'message' && body.role === 'assistant') { - const prose = messageProse(body.blocks) - if (prose.trim()) { - return prose - } - } - } - return '' -} - /** The activity fields a sidebar row shows beside the prompt, named as the agent-status * entry names them so the client can hand them straight to a row. */ export type StructuredAgentSessionStatusProjection = { diff --git a/src/shared/structured-agent-session-turn-bars.test.ts b/src/shared/structured-agent-session-turn-bars.test.ts index 219939d00e6..30ca3af6a38 100644 --- a/src/shared/structured-agent-session-turn-bars.test.ts +++ b/src/shared/structured-agent-session-turn-bars.test.ts @@ -5,9 +5,9 @@ import type { AgentJournalTurnLifecycle } from './agent-session-journal-types' import type { NativeChatMessage } from './native-chat-types' +import { nativeChatTurnMembership } from './native-chat-turn-membership' import { reduceNativeChatTurnTiming, - selectNativeChatActiveTurnKey, selectNativeChatTurnStatuses, type NativeChatTurnTimingByTurn } from './native-chat-turn-status' @@ -150,6 +150,42 @@ function message(id: string, role: NativeChatMessage['role'] = 'user'): NativeCh return { id, role, blocks: [{ type: 'text', text: id }], timestamp: null, source: 'transcript' } } +/** The row whose bar carries the running clock, as a surface places it. These journals are from a + * host that states no row's turn, so the bar follows the running record's named opener. */ +function barOf( + transcript: readonly NativeChatMessage[], + snapshot: { + items: readonly AgentJournalRenderItem[] + submissions: readonly AgentJournalSubmission[] + } +): string | undefined { + return nativeChatTurnMembership(transcript, snapshot).liveTurnKey +} + +/** The same journal from a host that states each row's turn: a user row belongs to no turn, a + * steer to the turn `steers` names, and any other row to the root turn record before it. */ +function scoped( + items: readonly AgentJournalRenderItem[], + steers: ReadonlyMap = new Map() +): AgentJournalRenderItem[] { + let open: string | null = null + return items.map((item) => { + const steered = steers.get(item.itemId) + if (item.body.kind === 'turn') { + open = item.itemId + } + const turnItemId = + steered ?? + (item.body.kind === 'turn' || (item.body.kind === 'message' && item.body.role === 'user') + ? null + : open) + return { + ...item, + turnScope: turnItemId ? { kind: 'turn' as const, turnItemId } : { kind: 'thread' as const } + } + }) +} + describe('a send queued behind a running Claude turn', () => { it('leaves the live bar with the prompt that opened the running turn', () => { const bars = selectStructuredAgentTurnBars( @@ -157,9 +193,8 @@ describe('a send queued behind a running Claude turn', () => { whileQueued.submissions, whileQueued.turnId ) - expect(bars.activeTurnOpenedBy).toBe('orca:A') const transcript = [message('orca:prior'), message('orca:A'), message('orca:B')] - expect(selectNativeChatActiveTurnKey(transcript, bars.activeTurnOpenedBy)).toBe('orca:A') + expect(barOf(transcript, whileQueued)).toBe('orca:A') // A was sent into an idle session: it counts from its own send, as before. expect(bars.runningTiming).toEqual({ state: 'running', @@ -172,7 +207,8 @@ describe('a send queued behind a running Claude turn', () => { it("moves the bar to B when its own turn opens, counting from A's end", () => { const bars = selectStructuredAgentTurnBars(whileB.items, whileB.submissions, whileB.turnId) - expect(bars.activeTurnOpenedBy).toBe('orca:B') + const transcript = [message('orca:prior'), message('orca:A'), message('orca:B')] + expect(barOf(transcript, whileB)).toBe('orca:B') expect(bars.settledTurns.get('orca:A')).toEqual({ startedAt: A.started, workedSeconds: 17 }) expect(bars.runningTiming && structuredAgentTurnOrigin(bars.runningTiming)).toBe(A.ended) // A client attaching 3 s into turn 2 counts 3 s, not the 13 s B waited behind turn 1. @@ -195,7 +231,6 @@ describe('a send queued behind a running Claude turn', () => { expect(Math.floor((B.ended - liveOriginB) / 1000)).toBe(51) expect(A.ended - A.sent + (B.ended - liveOriginB)).toBe(B.ended - A.sent) expect(selectStructuredAgentTurnBars(settled.items, settled.submissions, null)).toMatchObject({ - activeTurnOpenedBy: null, runningTiming: null }) }) @@ -210,7 +245,7 @@ describe('a send queued behind a running Claude turn', () => { now: number ) => { const bars = selectStructuredAgentTurnBars(snapshot.items, snapshot.submissions, turnId) - const activeTurnKey = selectNativeChatActiveTurnKey(transcript, bars.activeTurnOpenedBy) + const activeTurnKey = barOf(transcript, snapshot)! const workingStartedAt = structuredAgentTurnLocalStartedAt(bars.runningTiming!, now, now) timing = reduceNativeChatTurnTiming(timing, { activeTurnKey, @@ -281,7 +316,7 @@ describe('a send Codex folds into the running turn', () => { it('keeps exactly one bar, under the opening prompt, live and settled', () => { const live = selectStructuredAgentTurnBars(items, submissions, 't1') - expect(selectNativeChatActiveTurnKey(transcript, live.activeTurnOpenedBy)).toBe('orca:first') + expect(barOf(transcript, { items, submissions })).toBe('orca:first') expect(live.runningTiming && structuredAgentTurnOrigin(live.runningTiming)).toBe(1_000) let timing: NativeChatTurnTimingByTurn = {} @@ -296,7 +331,7 @@ describe('a send Codex folds into the running turn', () => { const after = selectStructuredAgentTurnBars(ended, submissions, null) // The turn is over: nothing names an owner, so the key falls back to the newest prompt, // which never ran a turn of its own and so has nothing to settle. - const activeTurnKey = selectNativeChatActiveTurnKey(transcript, after.activeTurnOpenedBy) + const activeTurnKey = barOf(transcript, { items: ended, submissions })! expect(activeTurnKey).toBe('orca:second') timing = reduceNativeChatTurnTiming(timing, { activeTurnKey, @@ -324,9 +359,7 @@ describe('turns whose host names no opener', () => { turn(2, 1_100, { turnId: 't1', state: 'running', startedAt: 1_100 }), user(3, 'u2', 2_000) ] - const bars = selectStructuredAgentTurnBars(items, [], 't1') - expect(bars.activeTurnOpenedBy).toBeNull() - expect(selectNativeChatActiveTurnKey([message('orca:u1'), message('orca:u2')], null)).toBe( + expect(barOf([message('orca:u1'), message('orca:u2')], { items, submissions: [] })).toBe( 'orca:u2' ) // Settled timing keeps its journal-order attribution. @@ -344,7 +377,9 @@ describe('turns whose host names no opener', () => { userItemId: self }) ] - expect(selectStructuredAgentTurnBars(items, [], 'resumed').activeTurnOpenedBy).toBe(self) + expect(barOf([message('orca:u1')], { items, submissions: [] })).toBe(self) + // A host that states each row's turn anchors it the same way. + expect(barOf([message('orca:u1')], { items: scoped(items), submissions: [] })).toBe(self) }) it('keeps the running bar on the send Codex opened a turn for before it echoes it', () => { @@ -364,21 +399,22 @@ describe('turns whose host names no opener', () => { user(4, 'second', 3_000), turn(5, 3_100, { turnId: 't2', state: 'running', startedAt: 3_100, userItemId: key }) ] + const transcript = [message('orca:first'), message('orca:second')] const submissions = [accepted('first', 'claude:first'), accepted('second', null)] - const bars = selectStructuredAgentTurnBars(items, submissions, 't2') - expect(bars.activeTurnOpenedBy).toBe('orca:second') - expect(bars.turnKeysByItemId.get('orca:second')).toBe('orca:second') - // A turn with no send in flight still anchors to its own record. - const selfOpened = selectStructuredAgentTurnBars( - items, - [accepted('first', 'claude:first'), accepted('second', 'claude:second')], - 't2' - ) - expect(selfOpened.activeTurnOpenedBy).toBe('legacy:claude:55368cfb:turn-lifecycle%3At2') + for (const journal of [items, scoped(items)]) { + const membership = nativeChatTurnMembership(transcript, { items: journal, submissions }) + expect(membership.liveTurnKey).toBe('orca:second') + expect(membership.turnKeys[1]).toBe('orca:second') + // A turn with no send in flight still anchors to its own record. + const selfOpened = [accepted('first', 'claude:first'), accepted('second', 'claude:second')] + expect(barOf(transcript, { items: journal, submissions: selfOpened })).toBe( + 'legacy:claude:55368cfb:turn-lifecycle%3At2' + ) + } }) it('names nothing for the unanchored transcript', () => { - expect(selectNativeChatActiveTurnKey([message('a', 'assistant')], null)).toBe('__unanchored__') + expect(nativeChatTurnMembership([message('a', 'assistant')], null).liveTurnKey).toBeUndefined() }) }) @@ -431,45 +467,57 @@ describe('the previous turn has no recorded end', () => { }) describe('which turn owns each transcript row', () => { + /** Each drawn row's turn, by id, as a surface places it. */ const keysOf = (snapshot: { - turnId: string | null items: AgentJournalRenderItem[] submissions: AgentJournalSubmission[] - }) => - selectStructuredAgentTurnBars(snapshot.items, snapshot.submissions, snapshot.turnId) - .turnKeysByItemId + }): ReadonlyMap => { + const transcript = snapshot.items.flatMap((item) => + item.body.kind === 'turn' + ? [] + : [ + message( + item.itemId, + item.body.kind === 'message' && item.body.role === 'user' ? 'user' : 'tool' + ) + ] + ) + const { turnKeys } = nativeChatTurnMembership(transcript, snapshot) + return new Map(transcript.map((row, index) => [row.id, turnKeys[index]])) + } + const turn1Record = `legacy:claude:55368cfb:turn-lifecycle%3A${turn1.turnId}` it('keeps the rows Claude produces after a mid-turn send with the turn that ran them', () => { // The #23621 shape: B lands mid-turn and three tool calls follow, one turn. - const midTurn = { - turnId: turn1.turnId, - items: [ - user(54, 'A', A.sent), - turn(57, T + 77_224, { - ...turn1, - state: 'running', - startedAt: A.started, - requestedAt: A.sent - }), - tool(58, T + 77_300), - user(60, 'B', B.sent), - tool(61, T + 80_100), - tool(62, T + 80_200), - tool(63, T + 80_300) - ], - submissions: [accepted('A', turn1.userItemId), accepted('B', null)] - } - const keys = keysOf(midTurn) - expect(keys.get('orca:A')).toBe('orca:A') - expect(keys.get('orca:B')).toBe('orca:A') - for (const sequence of [58, 61, 62, 63]) { - expect(keys.get(`orca:claude-tool%3A${sequence}`)).toBe('orca:A') + const items = [ + user(54, 'A', A.sent), + turn(57, T + 77_224, { + ...turn1, + state: 'running', + startedAt: A.started, + requestedAt: A.sent + }), + tool(58, T + 77_300), + user(60, 'B', B.sent), + tool(61, T + 80_100), + tool(62, T + 80_200), + tool(63, T + 80_300) + ] + const submissions = [accepted('A', turn1.userItemId), accepted('B', null)] + const steer = new Map([['orca:B', turn1Record]]) + for (const journal of [items, scoped(items, steer)]) { + const keys = keysOf({ items: journal, submissions }) + expect(keys.get('orca:A')).toBe('orca:A') + expect(keys.get('orca:B')).toBe('orca:A') + for (const sequence of [58, 61, 62, 63]) { + expect(keys.get(`orca:claude-tool%3A${sequence}`)).toBe('orca:A') + } } }) - it('leaves a fresh tail send unowned until the turn proves it continued past it', () => { + it('leaves a fresh tail send in its own group until the turn proves it continued past it', () => { // B is the newest row: nothing after it says the running turn absorbed it. - expect(keysOf(whileQueued).has('orca:B')).toBe(false) + expect(keysOf(whileQueued).get('orca:B')).toBe('orca:B') // B's own turn opened: B is an opener and keys itself. expect(keysOf(whileB).get('orca:B')).toBe('orca:B') expect(keysOf(whileB).get(`orca:claude-tool%3A58`)).toBe('orca:A') @@ -477,10 +525,11 @@ describe('which turn owns each transcript row', () => { it('folds a Codex-coalesced send into the turn its provider key names', () => { const key = 'codex:thread:t1:0' + const record = 'legacy:codex:s:turn-lifecycle%3At1' const items: AgentJournalRenderItem[] = [ user(1, 'first', 1_000), { - itemId: 'legacy:codex:s:turn-lifecycle%3At1', + itemId: record, revision: 1, sequence: 2, observedAt: 1_200, @@ -497,10 +546,12 @@ describe('which turn owns each transcript row', () => { tool(4, 5_000) ] const submissions = [accepted('first', key), accepted('second', key)] - const keys = keysOf({ turnId: 't1', items, submissions }) - expect(keys.get('orca:first')).toBe('orca:first') - expect(keys.get('orca:second')).toBe('orca:first') - expect(keys.get('orca:claude-tool%3A4')).toBe('orca:first') + for (const journal of [items, scoped(items, new Map([['orca:second', record]]))]) { + const keys = keysOf({ items: journal, submissions }) + expect(keys.get('orca:first')).toBe('orca:first') + expect(keys.get('orca:second')).toBe('orca:first') + expect(keys.get('orca:claude-tool%3A4')).toBe('orca:first') + } }) it('keys a provider-opened turn and its rows to the turn record itself', () => { @@ -515,18 +566,27 @@ describe('which turn owns each transcript row', () => { }), tool(3, 6_000) ] - const keys = keysOf({ turnId: 'resumed', items, submissions: [] }) - // u1 predates the wake turn and never opened one: positional grouping keeps it. - expect(keys.has('orca:u1')).toBe(false) - expect(keys.get('orca:claude-tool%3A3')).toBe(self) + for (const journal of [items, scoped(items)]) { + const keys = keysOf({ items: journal, submissions: [] }) + // u1 predates the wake turn and never opened one: it keeps its own group. + expect(keys.get('orca:u1')).toBe('orca:u1') + expect(keys.get('orca:claude-tool%3A3')).toBe(self) + } }) - it('attributes nothing for an older host that names no opener', () => { + it('groups by position for an older host that names no opener', () => { const items = [ user(1, 'u1', 1_000), turn(2, 1_100, { turnId: 't1', state: 'running', startedAt: 1_100 }), - tool(3, 1_200) + tool(3, 1_200), + user(4, 'u2', 1_300), + tool(5, 1_400) ] - expect(keysOf({ turnId: 't1', items, submissions: [] }).size).toBe(0) + expect([...keysOf({ items, submissions: [] }).values()]).toEqual([ + 'orca:u1', + 'orca:u1', + 'orca:u2', + 'orca:u2' + ]) }) }) diff --git a/src/shared/structured-agent-session-turn-timing.ts b/src/shared/structured-agent-session-turn-timing.ts index e760796618c..3f3607638b9 100644 --- a/src/shared/structured-agent-session-turn-timing.ts +++ b/src/shared/structured-agent-session-turn-timing.ts @@ -10,6 +10,7 @@ import type { AgentJournalTurnLifecycleState } from './agent-session-journal-types' import { readAgentJournalTurn } from './agent-session-turn-record' +import { structuredAgentTurnAnchors } from './native-chat-turn-membership' import type { NativeChatSettledTurn, NativeChatSettledTurns } from './native-chat-turn-status' export type StructuredAgentTurnTiming = { @@ -72,144 +73,44 @@ function readTiming( } } -/** One turn record, read in journal order. */ -type StructuredAgentJournalTurn = { - timing: StructuredAgentTurnTiming | null - /** The transcript key that anchors this turn's bar and owns its rows: the - * opener user item when the host names one it can resolve (or the send still - * in flight ahead of the record), else the turn record's own item (a turn the - * provider opened, or an opener outside the loaded window). Null only for an - * older host that names nothing. */ - key: string | null -} - +/** Every root turn record in one pass over the journal, keyed by its anchor (see + * `structuredAgentTurnAnchors`: the user message that opened it, or the record itself for a turn + * no message opened) and by provider turn id. Untimed rows are skipped from the anchor map unless + * explicitly unverifiable (null). */ type StructuredAgentJournalTurns = { - /** Timing keyed by the user message that opened each turn. */ - byUserItem: ReadonlyMap - byTurnId: ReadonlyMap + byAnchor: ReadonlyMap + byTurnId: ReadonlyMap } -/** Every turn record in one pass over the journal. A row can name its user - * message directly or by a provider key that resolves through its alias. - * Rows from older hosts carry no key and fall back, for timing only, - * to the nearest user message before them in journal order — the submission - * row is written ahead of dispatch, so it always precedes the provider's - * turn-start. Untimed rows are skipped unless explicitly unverifiable (null). */ function readStructuredAgentJournalTurns( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[] = [] ): StructuredAgentJournalTurns { - const itemIds = new Set(items.map((item) => item.itemId)) - const aliases = new Map() - // Codex folds a send issued mid-turn into the running turn under the SAME provider - // key, so the earliest submission that names a key is the prompt that opened the turn. - for (const submission of submissions) { - if (submission.providerItemId && !aliases.has(submission.providerItemId)) { - aliases.set(submission.providerItemId, agentJournalSubmissionKey(submission.clientMessageId)) - } - } - // Sends not yet matched to a provider item. Codex reports a turn open before it - // echoes the send, so for that gap the turn names a key no alias resolves yet. - const inFlight = new Set( - submissions - .filter((submission) => submission.dispatchState === 'pending' && !submission.providerItemId) - .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) - ) - const byUserItem = new Map() - const byTurnId = new Map() - let precedingUserItemId: string | null = null - let inFlightSinceLastTurn: string | null = null + const anchors = structuredAgentTurnAnchors(items, submissions) + const byAnchor = new Map() + const byTurnId = new Map() let precedingTurnEndedAt: number | undefined for (const item of items) { - if (item.body.kind === 'message' && item.body.role === 'user') { - precedingUserItemId = item.itemId - if (inFlightSinceLastTurn === null && inFlight.has(item.itemId)) { - inFlightSinceLastTurn = item.itemId - } - continue - } - const turn = readAgentJournalTurn(item.body) - if (!turn) { + const anchor = anchors.get(item.itemId) + const turn = anchor === undefined ? null : readAgentJournalTurn(item.body) + if (anchor === undefined || !turn) { continue } const timing = readTiming(item, precedingTurnEndedAt) precedingTurnEndedAt = timing?.completedAt ?? item.observedAt - const key = turn.userItemId - const named = key === undefined ? null : itemIds.has(key) ? key : (aliases.get(key) ?? null) - // An unresolved opener is the send still in flight ahead of this record; with none, - // it is outside the window, and the turn anchors to its own record like a - // provider-opened one — never to a preceding prompt that did not open it. - const turnKey = key === undefined ? null : (named ?? inFlightSinceLastTurn ?? item.itemId) - inFlightSinceLastTurn = null - byTurnId.set(turn.turnId, { timing, key: turnKey }) - if (!timing && turn.state !== 'unverifiable') { - continue - } - const userItemId = key === undefined ? precedingUserItemId : turnKey - if (userItemId !== null) { - byUserItem.set(userItemId, timing) + byTurnId.set(turn.turnId, timing) + if (timing || turn.state === 'unverifiable') { + byAnchor.set(anchor, timing) } } - return { byUserItem, byTurnId } -} - -/** - * Which turn owns each journal item, as the transcript groups rows: every item - * between a turn record and the next belongs to that record's turn (the record - * is appended when the turn opens and revised in place, and an opener's user - * item is written ahead of dispatch, so journal order is turn order). A user - * item that opened any turn — even a later one it queued for — keys itself; one - * the provider folded into a running turn (a steer) takes that turn's key, but - * only once the turn produces more rows after it, so a fresh tail send is not - * pulled into the turn it is merely waiting behind. Items before the first turn - * record stay absent, and the surface keeps its positional grouping for them. - */ -function turnKeysByItemIdOf( - items: readonly AgentJournalRenderItem[], - byTurnId: ReadonlyMap -): ReadonlyMap { - const openers = new Set() - for (const turn of byTurnId.values()) { - if (turn.key !== null) { - openers.add(turn.key) - } - } - const keys = new Map() - let currentKey: string | null = null - // User items folded into the current turn, held until a later row proves the - // turn continued past them. - let pendingUserItemIds: string[] = [] - for (const item of items) { - const turn = readAgentJournalTurn(item.body) - if (turn) { - currentKey = byTurnId.get(turn.turnId)?.key ?? null - pendingUserItemIds = [] - continue - } - if (item.body.kind === 'message' && item.body.role === 'user') { - if (openers.has(item.itemId)) { - keys.set(item.itemId, item.itemId) - } else if (currentKey !== null) { - pendingUserItemIds.push(item.itemId) - } - continue - } - if (currentKey !== null) { - for (const userItemId of pendingUserItemIds) { - keys.set(userItemId, currentKey) - } - pendingUserItemIds = [] - keys.set(item.itemId, currentKey) - } - } - return keys + return { byAnchor, byTurnId } } export function selectStructuredAgentTurnTimings( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[] = [] ): ReadonlyMap { - return readStructuredAgentJournalTurns(items, submissions).byUserItem + return readStructuredAgentJournalTurns(items, submissions).byAnchor } /** The live turn's lifecycle timing, or null when its row carries no host start @@ -218,7 +119,7 @@ export function selectStructuredAgentRunningTurnTiming( items: readonly AgentJournalRenderItem[], turnId: string ): StructuredAgentTurnTiming | null { - return readStructuredAgentJournalTurns(items).byTurnId.get(turnId)?.timing ?? null + return readStructuredAgentJournalTurns(items).byTurnId.get(turnId) ?? null } /** The single instant every reading of a turn's elapsed time counts from: the @@ -286,7 +187,7 @@ function settledTurnsOf( submissions: readonly AgentJournalSubmission[] ): NativeChatSettledTurns { const settled = new Map() - for (const [userItemId, timing] of turns.byUserItem) { + for (const [userItemId, timing] of turns.byAnchor) { const workedSeconds = completedStructuredAgentTurnSeconds(timing) settled.set( userItemId, @@ -305,9 +206,8 @@ function settledTurnsOf( } /** Everything a chat surface reads off the journal for its turn bars, from one - * pass: settled durations, the running turn's timing, the item that anchors its - * bar — which a message sent while the turn runs is not — and which turn owns - * each row of the transcript. */ + * pass: settled durations and the running turn's timing. Which bar carries the + * running clock is the turn membership's answer (`nativeChatTurnMembership`). */ export function selectStructuredAgentTurnBars( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[], @@ -315,18 +215,10 @@ export function selectStructuredAgentTurnBars( ): { settledTurns: NativeChatSettledTurns runningTiming: StructuredAgentTurnTiming | null - /** The transcript key the running turn's bar anchors to: its opening user - * message, or the turn record itself when the provider opened the turn. - * Null on an older host, where the newest user message stays the anchor. */ - activeTurnOpenedBy: string | null - turnKeysByItemId: ReadonlyMap } { const turns = readStructuredAgentJournalTurns(items, submissions) - const running = turnId === null ? undefined : turns.byTurnId.get(turnId) return { settledTurns: settledTurnsOf(turns, submissions), - runningTiming: running?.timing ?? null, - activeTurnOpenedBy: running?.key ?? null, - turnKeysByItemId: turnKeysByItemIdOf(items, turns.byTurnId) + runningTiming: turnId === null ? null : (turns.byTurnId.get(turnId) ?? null) } } diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 7af9283f328..320bb7b4925 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -75,6 +75,7 @@ export function structuredHostStub( modelCatalog: vi.fn(() => ({ origin: 'unknown' as const })), readCommands: vi.fn(() => ({ commands: [{ name: 'clear', kind: 'command' as const }] })), history: vi.fn(() => ({ ok: true, page: { items: [] } })), + sessionAgent: vi.fn(() => null), journalSnapshot: vi.fn(() => ({ sessionId, cursor: { epoch: 'epoch-a', sequence: 0 },