diff --git a/mobile/src/session/mobile-native-chat-host-journal-frames.test-fixture.ts b/mobile/src/session/mobile-native-chat-host-journal-frames.test-fixture.ts new file mode 100644 index 000000000000..d57fb48f531a --- /dev/null +++ b/mobile/src/session/mobile-native-chat-host-journal-frames.test-fixture.ts @@ -0,0 +1,116 @@ +// What the host's own fold and history readers send a phone subscribed to a journal, at any row. + +import type { + AgentJournalCursor, + AgentJournalSnapshot +} from '../../../src/shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import { + applyJournalRow, + createJournalReducerState, + renderJournalState +} from '../../../src/main/native-chat/agent-session-journal/journal-reducer' +import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' +import type { AgentSessionJournal } from '../../../src/main/native-chat/agent-session-journal/journal-store' +import { + readAgentSessionHistory, + readAgentSessionHydrationPage +} from '../../../src/main/native-chat/agent-session-wire/agent-session-history-page' + +export type HostJournalFrames = { + /** The host's fold after `upTo`, read through its own history readers. */ + journalAt: (upTo: number) => AgentSessionJournal + /** The lease fence once the host has written `upTo`. */ + fenceAt: (upTo: number) => number + /** The snapshot a phone subscribing at `upTo` gets. */ + snapshotAt: (upTo: number) => AgentSessionSubscribeEvent + /** What the host sends a subscriber at `cursor` and `fence` once the journal reaches `upTo`: the + * rows since as batches carrying the subscriber's fence, or, once the fence moved (a new child), + * a fresh snapshot, as the attach that moved it sends every subscriber. */ + framesTo: ( + subscriber: { cursor: AgentJournalCursor; fence: number }, + upTo: number + ) => AgentSessionSubscribeEvent[] +} + +export function hostJournalFrames( + sessionId: string, + epoch: string, + rows: readonly JournalRow[] +): HostJournalFrames { + // One fold, read at every row: each row's snapshot and the aliases its readers resolve with. + const state = createJournalReducerState(sessionId, epoch) + const snapshots: AgentJournalSnapshot[] = [] + const aliases: ReadonlyMap[] = [] + const fences: number[] = [] + for (const row of rows) { + applyJournalRow(state, row) + // A copy: the fold revises its submissions in place. + snapshots.push(structuredClone(renderJournalState(state))) + aliases.push(new Map(state.aliases)) + fences.push(Math.max(row.fence, fences.at(-1) ?? 0)) + } + const journalAt = (upTo: number): AgentSessionJournal => { + const snapshot = snapshots[upTo - 1]! + const journal = { + isReadOnly: false, + snapshot: () => snapshot, + cursor: () => snapshot.cursor, + // Without a body the store's own resolution reads only the aliases. + canonicalItemId: (itemId: string) => aliases[upTo - 1]!.get(itemId) ?? itemId, + readSince: (cursor: AgentJournalCursor, limit?: number) => ({ + ok: true as const, + rows: rows.slice(cursor.sequence, upTo).slice(0, limit), + cursor: snapshot.cursor + }) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the history readers call only the members above. + return journal as unknown as AgentSessionJournal + } + const fenceAt = (upTo: number): number => fences[upTo - 1]! + const snapshotAt = (upTo: number): AgentSessionSubscribeEvent => { + const fenceThen = fenceAt(upTo) + const page = readAgentSessionHydrationPage(journalAt(upTo), fenceThen) + return { type: 'snapshot', sessionId: sessionId, page, fence: fenceThen, hostNow: 0 } + } + const framesTo: HostJournalFrames['framesTo'] = (subscriber, upTo) => { + if (fenceAt(upTo) !== subscriber.fence) { + return [snapshotAt(upTo)] + } + const journal = journalAt(upTo) + const frames: AgentSessionSubscribeEvent[] = [] + let cursor = subscriber.cursor + for (;;) { + const result = readAgentSessionHistory(journal, { + sessionId: sessionId, + direction: 'after', + cursor, + limit: 200 + }) + if (!result.ok) { + throw new Error(`the host reset the stream: ${result.reset}`) + } + const { page } = result + if (page.window.nextCursor.sequence <= cursor.sequence) { + return frames + } + frames.push({ + type: 'batch', + sessionId: sessionId, + batch: { + cursor: page.window.nextCursor, + items: page.items, + removedItemIds: page.removedItemIds, + submissions: page.submissions + }, + fence: subscriber.fence, + hostNow: 0 + }) + cursor = page.window.nextCursor + if (!page.hasNewer) { + return frames + } + } + } + return { journalAt, fenceAt, snapshotAt, framesTo } +} diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx new file mode 100644 index 000000000000..065df61ea190 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity-hooks.test.tsx @@ -0,0 +1,352 @@ +// The phone's own hooks, overlay and chat view: a phone that sent the never-opened send itself, +// holding its echo, and reading the host's Stopping from the status feed, must hand its FlatList the +// same rows, bars and footer after every frame as a phone that opened the chat fresh at that frame, +// and the stop row from the frame that took the send back. Not covered: the row components and +// native rendering, the controller and lane that build the overlay's inputs, and a transport that +// reconnects and resubscribes. + +import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + AgentSessionStatusEvent, + AgentSessionSubscribeEvent +} from '../../../src/shared/agent-session-wire' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { RpcClient } from '../transport/rpc-client' +import { MobileNativeChatOverlay } from './MobileNativeChatOverlay' +import { MobileNativeChatView } from './MobileNativeChatView' +import { + NEVER_OPENED, + STOP_JOURNAL_SESSION, + stopJournal +} from './mobile-native-chat-stop-journal.test-fixture' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' +import type { MobileNativeChatController } from './use-mobile-native-chat-controller' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() } +})) +// The chat view's own test mocks: the FlatList is a host element whose props the test reads. +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, absoluteFillObject: {} }, + 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('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) +vi.mock('./MobileNativeChatComposer', () => ({ MobileNativeChatComposer: 'Composer' })) +vi.mock('./MobileNativeChatQueuedMessages', () => ({ MobileNativeChatQueuedMessages: 'Queued' })) +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) +vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) +vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('./MobileAgentWorkingIndicator', () => ({ + MobileAgentWorkingIndicator: 'WorkingIndicator' +})) + +const STOP_ROW = `stopped-before-start:orca:${NEVER_OPENED}` + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the overlay reads only these members of the image attachments, none of which this test exercises. +const OVERLAY_IMAGES = { + sendNativeChat: async () => true, + attachImage: async () => {}, + attachments: [], + removeAttachment: () => {}, + isAttaching: false +} as unknown as Parameters[0]['images'] + +const HOST_SUPPORT = { + promptCancel: true, + questionAnswers: true, + queuedMessages: true, + statusFeed: true, + quietRepeatedStop: true +} + +type Phone = { + renderer: ReactTestRenderer + transcript: (event: AgentSessionSubscribeEvent) => void + status: (stopping: boolean) => void + /** The phone's own send of the never-opened message, answered before its row streams in. */ + send: () => void + /** What the chat view hands its FlatList: each row as drawn, with its bar, then the footer. */ + read: () => string + /** The ids the chat view hands its FlatList. */ + listed: () => string[] + /** Keys the FlatList would collide on, or a changed list it was handed in the same array. */ + listFaults: string[] +} + +type DrawnRowProps = { + message: NativeChatMessage + turnStatus: { workedSeconds: number | null } | null + activeTurnIsWorking: boolean +} + +/** A row element as the list draws it: its message, its bar, and whether its turn is live. */ +function drawnRow(row: ReactNode): unknown { + if (!isValidElement(row)) { + return row ?? null + } + const { message, turnStatus, activeTurnIsWorking } = row.props + const bar = turnStatus === null ? '-' : (turnStatus.workedSeconds ?? 'live') + return [ + message.id, + message.role, + message.stoppedBeforeStart === true, + message.blocks, + bar, + activeTurnIsWorking + ] +} + +/** The footer: the live status and the rows waiting behind it. */ +function drawnFooter(node: ReactNode): unknown { + if (Array.isArray(node)) { + return node.map(drawnFooter) + } + if (!isValidElement<{ children?: ReactNode; line?: { stopping?: boolean } }>(node)) { + return node ?? null + } + const element: ReactElement<{ children?: ReactNode; line?: { stopping?: boolean } }> = node + if (element.type === 'LiveStatus') { + return ['live', element.props.line?.stopping === true] + } + return element.type === 'ChatMessage' ? drawnRow(element) : drawnFooter(element.props.children) +} + +const mounted: ReactTestRenderer[] = [] +afterEach(() => { + for (const renderer of mounted.splice(0)) { + act(() => renderer.unmount()) + } +}) + +async function mountPhone(): Promise { + const listeners = new Map void>() + const client: RpcClient = { + sendRequest: async () => ({ + id: 'request', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime' } + }), + subscribe: (method, _params, onData) => { + listeners.set(method, onData) + return () => {} + }, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + let latest: ReturnType | null = null + function Harness(): ReturnType { + const session = useMobileStructuredAgentSession({ + client, + sessionId: STOP_JOURNAL_SESSION, + sourceIdentity: 'host\0workspace', + enabled: true, + connected: true, + agent: 'codex', + hostSupport: HOST_SUPPORT, + onSendError: () => {} + }) + const drafts = useMobileNativeChatDrafts({ + hostId: 'host', + worktreeId: 'workspace', + tabId: 'tab', + sessionId: STOP_JOURNAL_SESSION, + messages: session.session.messages, + transcriptSettled: session.session.status === 'ready' + }) + latest = drafts + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every member the list's rows, bars and footer read comes from the two hooks above, as the controller passes them; showNativeChat, nativeChatAgent, nativeChatCanStop, nativeChatStructured and the scope key are constants; the overlay's other members stay undefined: the composer's and Stop's handlers, files and options never reach the list, the streaming text is undefined on a structured tab too, and the prompts, which would hide the live status, are absent in this flow. + const controller = { + showNativeChat: true, + nativeChatSession: session.session, + nativeChatAgent: 'codex', + nativeChatAgentWorking: session.isWorking, + nativeChatCanStop: false, + nativeChatStructured: true, + nativeChatTurnIndicator: session.turnIndicator, + nativeChatWorkingStartedAt: session.workingStartedAt, + nativeChatSettledTurns: session.settledTurns, + nativeChatTurnJournal: session.turnJournal, + nativeChatStreamLive: session.isWorking, + nativeChatStreamScopeKey: 'host\0workspace\0tab', + chatPending: drafts.pending, + chatImagePreviewsByMessageId: drafts.imagePreviewsByMessageId, + chatComposerText: drafts.composerText, + setChatComposerText: drafts.setComposerText, + getChatComposerEditGeneration: drafts.getComposerEditGeneration, + nativeChatQueued: session.queued + } as unknown as MobileNativeChatController + return createElement(MobileNativeChatOverlay, { + controller, + onOpenFile: () => {}, + images: OVERLAY_IMAGES, + onMicPress: () => {}, + micActive: false, + dictationMode: 'toggle', + onMicPressIn: () => {}, + onMicPressOut: () => {}, + inputLockReason: null, + sendErrorMessage: null, + onClearSendError: () => {}, + sendSurfaceId: 'host\0workspace\0tab', + getSendCompletionGeneration: () => 0, + keyboardInset: 0 + }) + } + let renderer: ReactTestRenderer | null = null + await act(async () => { + renderer = create(createElement(Harness)) + }) + const listFaults: string[] = [] + let handed: { data: unknown; ids: string } | null = null + const flatList = (): { data: NativeChatMessage[]; props: ReactTestInstance['props'] } => { + const props = renderer!.root.find((node) => String(node.type) === 'FlatList').props + const data: NativeChatMessage[] = props.data + const ids = data.map((row) => row.id).join(',') + const keys = data.map((row, index) => props.keyExtractor(row, index)) + if (new Set(keys).size !== keys.length) { + listFaults.push(`colliding keys: ${keys.join(',')}`) + } + if (handed && handed.data === data && handed.ids !== ids) { + listFaults.push(`changed rows in the same array: ${ids}`) + } + handed = { data, ids } + return { data, props } + } + mounted.push(renderer!) + await vi.waitFor(() => expect(listeners.get('agentSession.subscribe')).toBeDefined()) + await vi.waitFor(() => expect(listeners.get('agentSession.subscribeStatus')).toBeDefined()) + return { + renderer: renderer!, + transcript: (event) => + act(() => listeners.get('agentSession.subscribe')!(structuredClone(event))), + status: (stopping) => { + const event: AgentSessionStatusEvent = { + type: 'status', + session: { + sessionId: STOP_JOURNAL_SESSION, + workspaceId: 'workspace', + agent: 'codex', + status: stopping ? 'working' : 'idle', + latestPrompt: '', + updatedAt: 0, + ...(stopping ? { stopping: true } : {}) + } + } + act(() => listeners.get('agentSession.subscribeStatus')!(event)) + }, + send: () => { + const origin = latest!.captureSendOrigin(NEVER_OPENED)! + act(() => latest!.acceptSend(origin, NEVER_OPENED)) + }, + read: () => { + const view = renderer!.root.findByType(MobileNativeChatView).props + const { data, props } = flatList() + const renderRow: (row: { item: NativeChatMessage; index: number }) => ReactNode = + props.renderItem + return JSON.stringify({ + working: view.agentWorking, + stopping: view.turnIndicator.stopping, + rows: data.map((item, index) => drawnRow(renderRow({ item, index }))), + footer: drawnFooter(props.ListFooterComponent) + }) + }, + listed: () => flatList().data.map((row) => row.id), + listFaults + } +} + +describe("the phone's hooks, from merged frames and from a fresh snapshot", () => { + const journal = stopJournal(8) + const last = journal.rows.length + const stoppingAt = (upTo: number): boolean => + journal.stopping.some((window) => upTo >= window.from && upTo <= window.until) + + const lastRemoval = journal.rows.findLast( + (row) => row.kind === 'tombstone' && row.stopEvent === undefined + )!.seq + + // Subscribed before the history's last dropped status, before the turn a send was made into + // while stopping, and just before the send. + it.each([lastRemoval - 3, journal.neverOpenedSent - 30, journal.neverOpenedSent - 1])( + 'show the same list after every frame, subscribed at row %i', + async (start) => { + const live = await mountPhone() + live.transcript(journal.snapshotAt(start)) + live.status(stoppingAt(start)) + let cursor = { epoch: 'epoch-1', sequence: start } + let fence = journal.fenceAt(start) + const differing: string[] = [] + for (let upTo = start + 1; upTo <= last; upTo += 1) { + if (upTo === journal.neverOpenedSent) { + live.send() + expect(live.read()).toContain('"pending-') + } + for (const event of journal.framesTo({ cursor, fence }, upTo)) { + live.transcript(event) + fence = event.type === 'end' ? fence : (event.fence ?? fence) + } + cursor = { epoch: 'epoch-1', sequence: upTo } + live.status(stoppingAt(upTo)) + if (upTo === journal.takenBack - 1) { + // The never-opened send's own Stop, still settling. + expect(live.read()).toContain('"stopping":true') + } + const fresh = await mountPhone() + fresh.transcript(journal.snapshotAt(upTo)) + fresh.status(stoppingAt(upTo)) + if (live.read() !== fresh.read()) { + differing.push(`frame through ${upTo}`) + } + differing.push(...live.listFaults.splice(0), ...fresh.listFaults.splice(0)) + if (upTo >= journal.takenBack && !live.listed().includes(STOP_ROW)) { + differing.push(`no stop row in the merged list through ${upTo}`) + } + if (upTo >= journal.takenBack && !fresh.listed().includes(STOP_ROW)) { + differing.push(`no stop row in the fresh list at ${upTo}`) + } + act(() => fresh.renderer.unmount()) + mounted.splice(mounted.indexOf(fresh.renderer), 1) + } + expect(differing).toEqual([]) + }, + 60_000 + ) +}) diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts new file mode 100644 index 000000000000..3762d57006e4 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts @@ -0,0 +1,250 @@ +// The phone's list built from the frames it merged must equal the list a fresh snapshot of the same +// journal builds, and must show the stop row from the frame that took the send back. Covers journal +// -> host readers -> phone reducer -> projection -> fold -> turn membership, bars and the list's +// disclosure; the phone's own hooks, its echo and the host's Stopping are covered by +// mobile-native-chat-merged-snapshot-parity-hooks.test.tsx. Not covered: +// - a batch that carries a new fence (the background-task publish, rows an attach drains); +// - what the exit's settlement writes after the withdrawal (unknown marks, a lifecycle batch); +// - a queue-capable phone's send while stopping as a queued draft card and its hand-off. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { describe, expect, it } from 'vitest' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice' +import { nativeChatRowsInDrawOrder } from '../../../src/shared/native-chat-turn-grouping' +import { + nativeChatMessagesWaitingBehindLiveTurn, + nativeChatTurnMembership +} from '../../../src/shared/native-chat-turn-membership' +import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' +import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' +import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession, + type StructuredAgentSessionState +} from '../../../src/shared/structured-agent-session-reducer' +import { selectStructuredAgentTurnBars } from '../../../src/shared/structured-agent-session-turn-timing' +import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names' +import { + buildMobileNativeChatTransientData, + foldMobileNativeChatMessages +} from './mobile-native-chat-render-data' +import { + NEVER_OPENED, + stopJournal, + type StopJournal +} from './mobile-native-chat-stop-journal.test-fixture' +import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' + +const STOP_ROW = `stopped-before-start:orca:${NEVER_OPENED}` + +type Drawn = { header: string; rows: { id: string; role: string; line: string }[] } + +function apply( + state: StructuredAgentSessionState, + events: readonly AgentSessionSubscribeEvent[] +): StructuredAgentSessionState { + // Each phone gets its own copy, as it would off the wire. + return events.reduce( + (next, event) => + reduceStructuredAgentSession(next, { type: 'event', event: structuredClone(event) }, 0), + state + ) +} + +/** The rows the phone's transcript reads, as its session hook builds them: the journal plus the + * notice a cut turn gets, projected with no other rejected send drawn in place. */ +function phoneTranscript(state: StructuredAgentSessionState) { + const items = withNativeChatCutTurnNotices(state.items, { + agentName: TUI_AGENT_DISPLAY_NAMES.codex + }) + return { + items, + messages: projectStructuredAgentSessionMessages(items, [], state.submissions, { + rejectedInPlace: false + }) + } +} + +/** What the phone's list draws: each row in draw order, its content, its turn and that turn's bar. */ +function drawn(state: StructuredAgentSessionState): Drawn { + const { items, messages } = phoneTranscript(state) + const folded = foldMobileNativeChatMessages(messages) + const { data } = buildMobileNativeChatTransientData({ + messages, + folded, + streaming: null, + pending: [] + }) + const membership = nativeChatTurnMembership(data, { items, submissions: state.submissions }) + const rows = nativeChatRowsInDrawOrder(data, membership.drawOrder) + const turnKeys = nativeChatRowsInDrawOrder(membership.turnKeys, membership.drawOrder) + const waiting = nativeChatMessagesWaitingBehindLiveTurn(rows, items) + const turnId = activeStructuredAgentSessionTurnId(state.items) + const { settledTurns } = selectStructuredAgentTurnBars(items, state.submissions, turnId) + const working = isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence) + return { + header: `working ${working} live ${membership.liveTurnKey ?? '-'}`, + rows: rows.map((row, index) => { + const turnKey = turnKeys[index] + const bar = turnKey === undefined ? '-' : JSON.stringify(settledTurns.get(turnKey) ?? 'none') + const flags = [row.stoppedBeforeStart ? 'stopped' : '', waiting.has(row.id) ? 'waiting' : ''] + const line = [row.id, row.role, ...flags, turnKey ?? '-', bar, JSON.stringify(row.blocks)] + return { id: row.id, role: row.role, line: line.join(' ') } + }) + } +} + +/** The ids the phone's list shows: its rows through the list's own turn disclosure. */ +function listed(state: StructuredAgentSessionState): string[] { + const { items, messages } = phoneTranscript(state) + const folded = foldMobileNativeChatMessages(messages) + const { data } = buildMobileNativeChatTransientData({ + messages, + folded, + streaming: null, + pending: [] + }) + const turnId = activeStructuredAgentSessionTurnId(state.items) + const { settledTurns } = selectStructuredAgentTurnBars(items, state.submissions, turnId) + let ids: string[] = [] + function List(): null { + const turns = useMobileNativeChatTurnDisclosure({ + messages: data, + enabled: true, + isWorking: isStructuredAgentSessionMainAgentWorking(turnId, state.submissions, state.fence), + settledTurns, + turnJournal: { items, submissions: state.submissions }, + scopeKey: 'host\0workspace\0tab' + }) + ids = turns.listMessages.map((row) => row.id) + return null + } + let renderer: ReactTestRenderer | null = null + act(() => { + renderer = create(createElement(List)) + }) + act(() => renderer!.unmount()) + return ids +} + +/** + * A fresh page starts part way into a long chat, and a turn whose user row it cut off is keyed + * differently there; so both lists are compared from the fresh page's first user row on. + */ +function comparable(merged: Drawn, fresh: Drawn): { merged: string[]; fresh: string[] } { + const first = fresh.rows.findIndex((row) => row.role === 'user') + const from = first === -1 ? fresh.rows.length : first + const anchor = fresh.rows[from]?.id + const mergedFrom = + anchor === undefined ? merged.rows.length : merged.rows.findIndex((row) => row.id === anchor) + return { + merged: [ + merged.header, + ...merged.rows.slice(mergedFrom === -1 ? 0 : mergedFrom).map((row) => row.line) + ], + fresh: [fresh.header, ...fresh.rows.slice(from).map((row) => row.line)] + } +} + +/** Every frame from each subscribe point in `starts`, `rowsPerFrame` rows at a time; with how many + * of those frames the merged state held only a window of the chat. */ +function compareFrames( + journal: StopJournal, + starts: readonly number[], + rowsPerFrame: number +): { differing: string[]; windowed: number } { + const last = journal.rows.length + const fresh = new Map() + const freshAt = (upTo: number): Drawn => { + const cached = fresh.get(upTo) + if (cached) { + return cached + } + const drawnThen = drawn(apply(EMPTY_STRUCTURED_AGENT_SESSION, [journal.snapshotAt(upTo)])) + fresh.set(upTo, drawnThen) + return drawnThen + } + const freshListing = new Map() + const freshListed = (upTo: number): boolean => { + const cached = freshListing.get(upTo) + if (cached !== undefined) { + return cached + } + const shown = listed( + apply(EMPTY_STRUCTURED_AGENT_SESSION, [journal.snapshotAt(upTo)]) + ).includes(STOP_ROW) + freshListing.set(upTo, shown) + return shown + } + const differing: string[] = [] + let windowed = 0 + for (const start of starts) { + let merged = apply(EMPTY_STRUCTURED_AGENT_SESSION, [journal.snapshotAt(start)]) + for (let upTo = Math.min(start + rowsPerFrame, last); upTo <= last;) { + merged = apply( + merged, + journal.framesTo({ cursor: merged.cursor!, fence: merged.fence! }, upTo) + ) + windowed += merged.hasOlder ? 1 : 0 + const compared = comparable(drawn(merged), freshAt(upTo)) + if (JSON.stringify(compared.merged) !== JSON.stringify(compared.fresh)) { + differing.push(`subscribed at ${start}, frame through ${upTo}`) + } + if (upTo >= journal.takenBack && !listed(merged).includes(STOP_ROW)) { + differing.push(`subscribed at ${start}, no stop row in the list through ${upTo}`) + } + if (upTo >= journal.takenBack && !freshListed(upTo)) { + differing.push(`fresh at ${upTo}, no stop row in the list`) + } + upTo = upTo === last ? last + 1 : Math.min(upTo + rowsPerFrame, last) + } + } + return { differing, windowed } +} + +const range = (from: number, to: number): number[] => + Array.from({ length: to - from }, (_, index) => from + index) + +// The QA journal has the host write a send made while stopping after the stopped turn's end; a host +// that writes it while the turn still runs is covered too. +describe.each(['after-end', 'during-turn'] as const)( + 'a short chat, the send while stopping written %s: merged frames draw as a fresh snapshot does', + (sendWhileStopping) => { + const journal = stopJournal(8, sendWhileStopping) + + it('draws the send the exit took back with its stop row, from the frame that took it back', () => { + const fresh = drawn( + apply(EMPTY_STRUCTURED_AGENT_SESSION, [journal.snapshotAt(journal.takenBack)]) + ) + expect(fresh.rows.slice(-2).map((row) => row.line.split(' ').slice(0, 3).join(' '))).toEqual([ + `orca:${NEVER_OPENED} user stopped`, + `stopped-before-start:orca:${NEVER_OPENED} system ` + ]) + }) + + it.each([1, 2, 3])('from every subscribe point, %i row(s) per frame', (rowsPerFrame) => { + expect(compareFrames(journal, range(2, journal.rows.length), rowsPerFrame).differing).toEqual( + [] + ) + }) + } +) + +describe('a chat longer than the phone first loads', () => { + const journal = stopJournal(70) + const last = journal.rows.length + // Every 37th row of the history, then every row from the last two history turns on, which revise + // and drop a status the window then holds. + const lastTurns = journal.neverOpenedSent - 70 + const starts = [...range(2, lastTurns).filter((row) => row % 37 === 0), ...range(lastTurns, last)] + + it('draws the same at every frame from each subscribe point, from a window of the chat', () => { + expect(apply(EMPTY_STRUCTURED_AGENT_SESSION, [journal.snapshotAt(last)]).hasOlder).toBe(true) + const { differing, windowed } = compareFrames(journal, starts, 1) + expect(differing).toEqual([]) + expect(windowed).toBeGreaterThan(1_000) + }, 60_000) +}) diff --git a/mobile/src/session/mobile-native-chat-stop-journal.test-fixture.ts b/mobile/src/session/mobile-native-chat-stop-journal.test-fixture.ts new file mode 100644 index 000000000000..bdf77d41296d --- /dev/null +++ b/mobile/src/session/mobile-native-chat-stop-journal.test-fixture.ts @@ -0,0 +1,258 @@ +// A Codex chat's journal shaped like a long chat whose last send never opened and was taken back +// at the child's exit, right after a send made while a Stop was ending the turn before it. + +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalItemBody, + type AgentJournalTurnScope +} from '../../../src/shared/agent-session-journal-types' +import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection' +import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' +import { + hostJournalFrames, + type HostJournalFrames +} from './mobile-native-chat-host-journal-frames.test-fixture' + +export const STOP_JOURNAL_SESSION = 'codex_session' +export const NEVER_OPENED = 'never-opened' +const THREAD = 'thread-1' +const EPOCH = 'epoch-1' + +type RowInput = { + [K in JournalRow['kind']]: Omit< + Extract, + 'v' | 'epoch' | 'seq' | 'fence' | 'ts' + > +}[JournalRow['kind']] + +/** A person's Stop is ending work from `from` until `until` (journal rows), as the status feed says. */ +export type StopJournalStopping = { from: number; until: number } + +export type StopJournal = HostJournalFrames & { + rows: readonly JournalRow[] + /** The row that took the never-opened send back. */ + takenBack: number + /** The never-opened send's submission row. */ + neverOpenedSent: number + stopping: readonly StopJournalStopping[] +} + +/** Where the host writes a send made while a Stop is ending a turn: after that turn's end (a host + * that holds sends while Stopping, as the QA journal shows), or while the turn still runs. */ +export type SendWhileStoppingWritten = 'after-end' | 'during-turn' + +export function stopJournal( + historyTurns: number, + sendWhileStopping: SendWhileStoppingWritten = 'after-end' +): StopJournal { + const rows: JournalRow[] = [] + const stopping: StopJournalStopping[] = [] + let fence = 0 + const add = (row: RowInput): number => { + const seq = rows.length + 1 + const envelope = { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, epoch: EPOCH, seq, fence } + const stamped = { ...row, ...envelope, ts: 1_000 * seq } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `row` is one arm of the union less the envelope, and the envelope added here is the same for every arm. + rows.push(stamped as JournalRow) + return seq + } + const turnItem = (turnId: string): string => + `legacy:codex:${STOP_JOURNAL_SESSION}:turn-lifecycle%3A${turnId}` + const inTurn = (turnId: string): AgentJournalTurnScope => ({ + kind: 'turn', + turnItemId: turnItem(turnId) + }) + const message = (role: 'user' | 'assistant', text: string): AgentJournalItemBody => ({ + kind: 'message', + role, + blocks: [{ type: 'text', text }] + }) + const submit = (id: string): number => + add({ + kind: 'submission', + clientMessageId: id, + payloadFingerprint: id, + providerHandle: { kind: 'codex', threadId: THREAD }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, + handoverRecorded: true, + origin: 'client' + }) + const handOver = (id: string): number => + add({ + kind: 'dispatch', + clientMessageId: id, + state: 'pending', + providerItemId: null, + reason: null, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const stopEvent = (turnId?: string): number => + add({ + kind: 'tombstone', + itemId: 'orca:stop-event', + revision: 1, + stopEvent: { reason: 'user-stop', at: 1, ...(turnId ? { turnId } : {}) } + }) + const turnBody = (id: string, turnId: string, state: 'running' | 'completed' | 'interrupted') => + ({ kind: 'turn', turnId, state, userItemId: `orca:${id}`, startedAt: 1 }) as const + /** The provider opens a turn for `id`, echoes the send and streams an answer. */ + const openTurn = (id: string, turnId: string): void => { + const record = turnItem(turnId) + const providerKey = `codex:${THREAD}:${turnId}:0` + const running = { ...turnBody(id, turnId, 'running'), userItemId: providerKey } + add({ + kind: 'item', + itemId: record, + revision: 1, + body: running, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + add({ + kind: 'item', + itemId: record, + revision: 2, + body: turnBody(id, turnId, 'running'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + add({ + kind: 'dispatch', + clientMessageId: id, + state: 'accepted', + providerItemId: providerKey, + reason: null + }) + add({ + kind: 'item', + itemId: providerKey, + revision: 1, + body: message('user', id), + turnScope: inTurn(turnId) + }) + for (const revision of [1, 2, 3]) { + add({ + kind: 'item', + itemId: `codex:${THREAD}:${turnId}:1`, + revision, + body: message('assistant', `${turnId} part ${revision}`), + turnScope: inTurn(turnId) + }) + } + } + const endTurn = (id: string, turnId: string, state: 'completed' | 'interrupted'): void => { + add({ + kind: 'lifecycle-batch', + settlementId: `turn-completed:${turnId}`, + mutations: [ + { + kind: 'item', + itemId: `codex:${THREAD}:${turnId}:1`, + revision: 4, + body: message('assistant', `${turnId} done`), + turnScope: inTurn(turnId) + }, + { + kind: 'item', + itemId: turnItem(turnId), + revision: 3, + body: { ...turnBody(id, turnId, state), completedAt: 2 }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + }) + if (state === 'interrupted') { + add({ + kind: 'item', + itemId: `orca:stop%3A${turnId}`, + revision: 1, + body: { kind: 'status', text: 'Cancellation requested.' }, + turnScope: inTurn(turnId) + }) + } + } + + add({ + kind: 'epoch', + reason: 'session_created', + providerHandle: { kind: 'codex', threadId: THREAD } + }) + fence = 1 + for (let index = 0; index < historyTurns; index += 1) { + const id = `history-${index}` + const turnId = `t-history-${index}` + submit(id) + handOver(id) + openTurn(id, turnId) + // A provider status the turn revises, then drops again or keeps: early on, and near the end. + const revisesStatus = index < 2 || index >= historyTurns - 2 + if (revisesStatus) { + const progress = `codex:${THREAD}:${turnId}:progress` + for (const [revision, text] of [ + [1, 'Compacting context'], + [2, 'Context compacted'] + ] as const) { + add({ + kind: 'item', + itemId: progress, + revision, + body: { kind: 'status', text }, + turnScope: inTurn(turnId) + }) + } + if (index % 2 === 0) { + add({ kind: 'tombstone', itemId: progress, revision: 3 }) + } + } + if (index % 2 === 1) { + const from = stopEvent(turnId) + endTurn(id, turnId, 'interrupted') + stopping.push({ from, until: rows.length }) + } else { + endTurn(id, turnId, 'completed') + } + } + // A Stop is ending a turn when a send arrives; the host hands it over once the turn has ended. + submit('stopped') + handOver('stopped') + openTurn('stopped', 't-stopped') + const stoppedAt = stopEvent('t-stopped') + if (sendWhileStopping === 'during-turn') { + submit('sent-while-stopping') + } + endTurn('stopped', 't-stopped', 'interrupted') + stopping.push({ from: stoppedAt, until: rows.length }) + if (sendWhileStopping === 'after-end') { + submit('sent-while-stopping') + } + handOver('sent-while-stopping') + openTurn('sent-while-stopping', 't-sent-while-stopping') + endTurn('sent-while-stopping', 't-sent-while-stopping', 'completed') + // A send the provider never opened; a turn-less Stop ends the child, whose exit takes it back. + const neverOpenedSent = submit(NEVER_OPENED) + handOver(NEVER_OPENED) + const stopNeverOpened = stopEvent() + const takenBack = add({ + kind: 'dispatch', + clientMessageId: NEVER_OPENED, + state: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_CANCELLED, + rejection: { kind: 'cancelled' }, + recovered: true + }) + stopping.push({ from: stopNeverOpened, until: takenBack - 1 }) + // The next send starts a new child under a new fence. + fence = 2 + submit('recovery') + handOver('recovery') + openTurn('recovery', 't-recovery') + endTurn('recovery', 't-recovery', 'completed') + + return { + rows, + takenBack, + neverOpenedSent, + stopping, + ...hostJournalFrames(STOP_JOURNAL_SESSION, EPOCH, rows) + } +} diff --git a/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx b/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx new file mode 100644 index 000000000000..fda3b94f18f4 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx @@ -0,0 +1,180 @@ +// The phone draws a send a Stop took back before the agent started it where it was sent, with the +// one row after it, and gives neither a turn of its own. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnScope +} from '../../../src/shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection' +import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' +import { selectStructuredAgentSettledTurns } from '../../../src/shared/structured-agent-session-turn-timing' + +vi.mock('react-native', async () => { + const React = await import('react') + const Text = ({ children, ...props }: { children?: React.ReactNode }): React.ReactNode => + React.createElement('Text', props, children) + return { + ActivityIndicator: 'ActivityIndicator', + Animated: { + Text, + Value: class { + setValue(): void {} + }, + loop: (animation: unknown) => animation, + sequence: () => ({ start: vi.fn(), stop: vi.fn() }), + timing: () => ({ start: vi.fn(), stop: vi.fn() }) + }, + Image: 'Image', + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + Text, + View: ({ children, ...props }: { children?: React.ReactNode }) => + React.createElement('View', props, children), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 } + } +}) +vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) +vi.mock('lucide-react-native', () => ({ + ArrowUp: 'ArrowUp', + ChevronDown: 'ChevronDown', + Copy: 'Copy', + SquareChevronRight: 'SquareChevronRight', + SquareTerminal: 'SquareTerminal', + Wrench: 'Wrench', + ChevronRight: 'ChevronRight' +})) +vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: () => null })) +vi.mock('../components/MobileSelectableText', async () => { + const React = await import('react') + return { + MobileSelectableText: ({ children }: { children?: React.ReactNode }) => + React.createElement('Text', null, children) + } +}) +vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ + MobileNativeChatMessageActionsSheet: () => null +})) + +import { MobileMarkdown } from '../components/MobileMarkdown' +import { MobileNativeChatMessage } from './MobileNativeChatMessage' +import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' + +const THREAD: AgentJournalTurnScope = { kind: 'thread' } +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 sent = (id: string, text: string) => + item(agentJournalSubmissionKey(id), { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text }] + }) + +function submission( + clientMessageId: string, + overrides: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2, + ...overrides + } +} + +const items = [ + sent('warm-up', 'warm up'), + item('t1', { + kind: 'turn', + turnId: 't1', + state: 'completed', + outcome: 'success', + userItemId: agentJournalSubmissionKey('warm-up'), + startedAt: 1_000, + completedAt: 4_000 + }), + item( + 't1-answer', + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Warmed up.' }] }, + { kind: 'turn', turnItemId: 't1' } + ), + sent('never-ran', 'look around') +] +const submissions = [ + submission('warm-up'), + submission('never-ran', { dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED }) +] + +type Disclosure = ReturnType +let disclosure: Disclosure | null = null + +function Harness({ messages }: { messages: readonly NativeChatMessage[] }): null { + disclosure = useMobileNativeChatTurnDisclosure({ + messages, + enabled: true, + isWorking: false, + settledTurns: selectStructuredAgentSettledTurns(items, submissions), + turnJournal: { items, submissions }, + workingStartedAt: null, + scopeKey: 'host\0worktree\0tab-a' + }) + return null +} + +describe('a send a Stop took back before the agent started it, on the phone', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('stays where it was sent, with the stop row after it and no turn status of its own', () => { + const messages = projectStructuredAgentSessionMessages(items, [], submissions, { + rejectedInPlace: false + }) + expect(messages.map((message) => message.id)).toEqual([ + agentJournalSubmissionKey('warm-up'), + 't1-answer', + agentJournalSubmissionKey('never-ran'), + `stopped-before-start:${agentJournalSubmissionKey('never-ran')}` + ]) + + act(() => { + renderer = create(createElement(Harness, { messages })) + }) + const statuses = messages.map( + (message, index) => disclosure?.resolveRow(index, message).turnStatus ?? null + ) + expect(statuses[0]?.workedSeconds).toBe(3) + expect(statuses.slice(2)).toEqual([null, null]) + + act(() => renderer?.unmount()) + act(() => { + renderer = create(createElement(MobileNativeChatMessage, { message: messages[3]! })) + }) + expect(renderer!.root.findAllByType(MobileMarkdown).map((node) => node.props.content)).toEqual([ + 'Stopped before the agent started' + ]) + }) +}) diff --git a/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts b/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts index c9da99a3cbaf..2652c041b525 100644 --- a/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-send-bypass.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import type { RpcResponse } from '../transport/types' +import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection' import { sendMobileStructuredAgentSessionMessage } from './mobile-structured-agent-session-send' import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' @@ -21,6 +22,29 @@ function ok(result: unknown): RpcResponse { return { id: 'request-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } } +/** Its own submission, which a Stop took back before the agent started it. */ +function stoppedAnswer(clientMessageId: string): RpcResponse { + return ok({ + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { + clientMessageId, + submission: { + clientMessageId, + fence: 3, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_CANCELLED, + submittedAt: 10, + resolvedAt: 10 + } + } + }) +} + function queuedAnswer(clientMessageId: string, state: 'waiting' | 'withdrawn'): RpcResponse { return ok({ ok: true, @@ -31,8 +55,9 @@ function queuedAnswer(clientMessageId: string, state: 'waiting' | 'withdrawn'): }) } -/** Each `agentSession.send` answered in turn: a lost answer, or a queued draft in that state. */ -function hostAnswering(answers: readonly ('lost' | 'waiting' | 'withdrawn')[]) { +/** Each `agentSession.send` answered in turn: a lost answer, a queued draft in that state, or its + * submission stopped. */ +function hostAnswering(answers: readonly ('lost' | 'waiting' | 'withdrawn' | 'stopped')[]) { const ids: string[] = [] const deliveries: unknown[] = [] const sendRequest = vi.fn(async (_method, params) => { @@ -44,7 +69,7 @@ function hostAnswering(answers: readonly ('lost' | 'waiting' | 'withdrawn')[]) { if (answer === 'lost' || answer === undefined) { throw markRpcDeliveryUnknown(new Error('Connection closed')) } - return queuedAnswer(id, answer) + return answer === 'stopped' ? stoppedAnswer(id) : queuedAnswer(id, answer) }) const client: RpcClient = { sendRequest, @@ -154,4 +179,37 @@ describe('a resend past a saved record storage would not clear', () => { "Sent, but this phone couldn't update its record of sent messages." ) }) + + // Lost first send; its withdrawn replay; the fresh resend's answer lost; the retry's withdrawn + // replay; then that resend's own replay withdrawn too. A resend is sent once, never again. + it('sends a withdrawn replay once more at most, then hands a queued draft back', async () => { + const { client, ids } = hostAnswering(['lost', 'withdrawn', 'lost', 'withdrawn', 'withdrawn']) + const onError = vi.fn() + expect(await sendAgain(client, onError)).toBe('unknown') + asyncStorage.setItem.mockRejectedValue(new Error('disk full')) + asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) + expect(await sendAgain(client, onError)).toBe('unknown') + + expect(await sendAgain(client, onError)).toBe('rejected') + expect(ids).toHaveLength(5) + expect(ids[4]).toBe(ids[2]) + expect(onError).toHaveBeenCalledWith('Message not sent') + }) + + it('reads a resend a Stop took back again as sent, since the chat draws it, not as not sent', async () => { + const { client, ids } = hostAnswering(['lost', 'stopped', 'lost', 'stopped', 'stopped']) + const onError = vi.fn() + expect(await sendAgain(client, onError, false)).toBe('unknown') + asyncStorage.setItem.mockRejectedValue(new Error('disk full')) + asyncStorage.removeItem.mockRejectedValue(new Error('disk full')) + expect(await sendAgain(client, onError, false)).toBe('unknown') + + expect(await sendAgain(client, onError, false)).toBe('accepted') + expect(ids).toHaveLength(5) + expect(ids[4]).toBe(ids[2]) + // Only the storage failure is reported; nothing says the message was not sent. + expect(onError.mock.calls).toEqual([ + ["Sent, but this phone couldn't update its record of sent messages."] + ]) + }) }) diff --git a/mobile/src/session/mobile-structured-agent-session-send.ts b/mobile/src/session/mobile-structured-agent-session-send.ts index ce7f60a8b929..c7a2aa356128 100644 --- a/mobile/src/session/mobile-structured-agent-session-send.ts +++ b/mobile/src/session/mobile-structured-agent-session-send.ts @@ -14,7 +14,10 @@ import { timeoutForDeadline } from './mobile-structured-agent-session-rpc' import { structuredSessionOperationId } from './structured-session-operation-id' -import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery' +import { + mobileStructuredSendDelivery, + mobileStructuredSendWithdrawnBeforeStart +} from './mobile-structured-send-delivery' import { bypassedMobileStructuredSendOperationId, clearMobileStructuredSendOperation, @@ -162,11 +165,12 @@ export async function sendMobileStructuredAgentSessionMessage(input: { } } const withdrawnReplay = - result.status === 'accepted' && - 'queued' in result.value && - result.value.queued?.state === 'withdrawn' + (result.status === 'accepted' && + 'queued' in result.value && + result.value.queued?.state === 'withdrawn') || + (operation.retained && mobileStructuredSendWithdrawnBeforeStart(result)) if (withdrawnReplay && operation.retained && !input.resendingAfterWithdrawal) { - // The retained id's draft was withdrawn, so it never reached the agent: + // The retained id's draft or submission was withdrawn, so it never reached the agent: // this identical message is a new one, not a replay to swallow. A record // storage would not clear is bookkeeping: it is reported, never allowed to // block the send. @@ -182,6 +186,10 @@ export async function sendMobileStructuredAgentSessionMessage(input: { } return resent } + if (withdrawnReplay && mobileStructuredSendWithdrawnBeforeStart(result)) { + // Not resent, but the chat draws it with its stop row: handing it back too would show it twice. + return 'accepted' + } if (withdrawnReplay) { // Not resent: no card and no bubble holds the text, so it goes back to the // composer rather than vanishing. diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index 278142b271cc..b7e457c7569d 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/mobile/src/session/mobile-structured-send-delivery.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalDispatchState } from '../../../src/shared/agent-session-journal-types' import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire' +import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection' import { mobileStructuredSendDelivery } from './mobile-structured-send-delivery' import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc' import { structuredSendResultFixture } from './structured-agent-send-result.test-fixture' +const WITHDRAWN_SENTENCE = 'This message was withdrawn before the agent started it.' + function accepted( dispatchState: AgentJournalDispatchState, reason: string | null = null @@ -153,6 +156,28 @@ describe('mobileStructuredSendDelivery', () => { } }) + // The chat draws a send a Stop took back with its stop row, so it never goes back to the draft. A + // retained replay is resent by the caller, which needs the id spent and no words of its own. + it.each([ + { retained: false, reason: DISPATCH_REJECTED_CANCELLED, fact: false, outcome: 'accepted' }, + { retained: false, reason: WITHDRAWN_SENTENCE, fact: true, outcome: 'accepted' }, + { retained: true, reason: DISPATCH_REJECTED_CANCELLED, fact: false, outcome: 'rejected' }, + { retained: true, reason: WITHDRAWN_SENTENCE, fact: true, outcome: 'rejected' } + ] as const)( + 'reads a send a Stop took back (retained $retained, typed fact $fact) as $outcome, spent, wordless', + ({ retained, reason, fact, outcome }) => { + const value = structuredSendResultFixture('rejected', reason) + if (fact && 'submission' in value) { + value.submission.rejection = { kind: 'cancelled' } + } + expect(mobileStructuredSendDelivery({ status: 'accepted', value }, retained)).toEqual({ + outcome, + operationIdSpent: true, + error: null + }) + } + ) + it('shows a provider content rejection verbatim', () => { expect( mobileStructuredSendDelivery(accepted('rejected', 'Claude does not support .bmp')) diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index bc0db996a93d..2d48d1d2a9e0 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -13,6 +13,11 @@ // // accepted/pending — the send happened. The id is spent; a later identical // message is a new message and must carry a new id. +// withdrawn — a submission a Stop took back before the agent started it is +// drawn in the chat with its stop row, so it spends the id and goes back to no +// draft. Answering a first send, it is that message: sent, then stopped. As a +// retained replay it cannot be told from a new send of the same text, and it +// provably never ran, so the caller sends it again under a fresh id. // rejected — a terminal refusal or rejected submission spends a fresh id. A // pending-admission refusal, or any refusal after earlier transport doubt, // keeps it because neither proves a retained delivery did not happen. Two @@ -31,6 +36,7 @@ import type { AgentSessionSendResult } from '../../../src/shared/agent-session-w import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry' import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-session-refusal-notice' import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-send-disposition' +import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import type { StructuredAgentSessionMutationCallResult } from './mobile-structured-agent-session-rpc' @@ -42,6 +48,21 @@ export type MobileStructuredSendDelivery = { error: string | null } +/** Whether a send answer is its own submission, which a Stop took back before the agent started it. */ +export function mobileStructuredSendWithdrawnBeforeStart( + result: StructuredAgentSessionMutationCallResult +): boolean { + if (result.status !== 'accepted' || !('submission' in result.value)) { + return false + } + const { submission } = result.value + return ( + submission.queuedMessageId === undefined && + submission.dispatchState === 'rejected' && + dispatchWasWithdrawn(submission) + ) +} + export function mobileStructuredSendDelivery( result: StructuredAgentSessionMutationCallResult, retained = false @@ -102,6 +123,11 @@ export function mobileStructuredSendDelivery( // composer, so the words never show twice. return { outcome: 'queued', operationIdSpent: true, error: null } } + if (mobileStructuredSendWithdrawnBeforeStart(result)) { + // The chat draws it with its stop row, so it is never handed back to the composer as well. A + // retained replay is resent by the caller. + return { outcome: retained ? 'rejected' : 'accepted', operationIdSpent: true, error: null } + } if (submission.dispatchState === 'rejected') { return { outcome: 'rejected', diff --git a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts index 008119dae5e4..21fc23efe724 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts-glued-pending.test.ts @@ -158,6 +158,42 @@ describe('useMobileNativeChatDrafts glued pending sends', () => { expect(renderedPendingTexts(history, state)).toEqual(['fix the', 'bug']) }) + // A send of the same text as a message a Stop took back is resent as a new message: its bubble + // waits beside the stopped row for that new row, never for the stopped one. + it('keeps a resend bubble beside a stopped row of the same text until its own row lands', async () => { + const history = [userTurn('m1', 'run the tests', 1000), assistantTurn('m2', 'stopped', 1100)] + await mount(history) + act(() => send('run the tests')) + + await update([...history]) + expect(pendingTexts()).toEqual(['run the tests']) + + const resent = [...history, userTurn('m3', 'run the tests', 5000)] + await update(resent) + expect(state?.pending).toEqual([]) + expect(renderedPendingTexts(resent, state)).toEqual([]) + }) + + // The likelier order: the press goes out before this phone has drawn the stopped row. + it('shows the text once at every step when the stopped row lands after the resend press', async () => { + const history = [assistantTurn('m0', 'ready', 500)] + await mount(history) + act(() => send('run the tests')) + expect(renderedPendingTexts(history, state)).toEqual(['run the tests']) + + // The stopped row takes the bubble's place; nothing is left pending beside it. + const stopped = [...history, userTurn('m1', 'run the tests', 1000)] + await update(stopped) + expect(state?.pending).toEqual([]) + expect(renderedPendingTexts(stopped, state)).toEqual([]) + + // The resend's own row then lands beside it, and still nothing is pending. + const resent = [...stopped, userTurn('m2', 'run the tests', 5000)] + await update(resent) + expect(state?.pending).toEqual([]) + expect(renderedPendingTexts(resent, state)).toEqual([]) + }) + it('still retires each bubble on its own when the sends do not glue', async () => { const history = [assistantTurn('m1', 'ready', 1000)] await mount(history) diff --git a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx index 086482537ef2..4fa1d5bd13c0 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-send.test.tsx @@ -2,6 +2,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalDispatchState } from '../../../src/shared/agent-session-journal-types' +import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection' import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' @@ -21,13 +22,13 @@ function ok(result: unknown) { return { ok: true, result, _meta: { runtimeId: 'runtime-1' } } } -function sendResult(dispatchState: AgentJournalDispatchState) { +function sendResult(dispatchState: AgentJournalDispatchState, reason: string | null = null) { return ok({ ok: true, replayed: false, fence: 3, cursor: { epoch: 'epoch-1', sequence: 1 }, - value: structuredSendResultFixture(dispatchState) + value: structuredSendResultFixture(dispatchState, reason) }) } @@ -151,6 +152,58 @@ describe('mobile structured send retries', () => { expect(calls().every(([, params]) => !('retryUnknown' in (params as object)))).toBe(true) }) + // A replay of a retained id cannot be told from a new send of the same text, and a Stop took the + // first one back before the agent started it, so this press goes out as a new message. + it('sends a replay a Stop took back once more, under a fresh id, saying nothing', async () => { + let attempts = 0 + sendRequest.mockImplementation(async (method) => { + if (method !== 'agentSession.send') { + return method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + } + attempts += 1 + if (attempts === 1) { + throw markRpcDeliveryUnknown(new Error('Connection closed')) + } + return attempts === 2 + ? sendResult('rejected', DISPATCH_REJECTED_CANCELLED) + : sendResult('pending') + }) + await mountSession() + + await act(async () => { + expect(await hook!.sendWithOutcome('stopped one')).toBe('unknown') + expect(await hook!.sendWithOutcome('stopped one')).toBe('accepted') + }) + + expect(onSendError).not.toHaveBeenCalled() + // The lost first send, its replay, and exactly one resend under a new id. + const ids = sentIds() + expect(ids).toHaveLength(3) + expect(ids[1]).toBe(ids[0]) + expect(ids[2]).not.toBe(ids[0]) + }) + + it('reads a first answer a Stop took back as sent, saying nothing, and spends its id', async () => { + sendRequest.mockImplementation(async (method) => + method === 'agentSession.send' + ? sendResult('rejected', DISPATCH_REJECTED_CANCELLED) + : method === 'agentSession.options' + ? ok({ models: [], current: {} }) + : ok({}) + ) + await mountSession() + + await act(async () => { + expect(await hook!.sendWithOutcome('stopped first')).toBe('accepted') + expect(await hook!.sendWithOutcome('stopped first')).toBe('accepted') + }) + + expect(onSendError).not.toHaveBeenCalled() + const ids = sentIds() + expect(ids).toHaveLength(2) + expect(ids[1]).not.toBe(ids[0]) + }) + it('releases an ack-lost id after the journal accepts it for a later identical intent', async () => { let attempts = 0 sendRequest.mockImplementation(async (method) => { diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json index 55e1adc660a4..ac79b0b7c065 100644 --- a/mobile/tsconfig.json +++ b/mobile/tsconfig.json @@ -14,5 +14,5 @@ } }, "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["**/*.test.ts", "**/*.test.tsx"] + "exclude": ["**/*.test.ts", "**/*.test.tsx", "**/*.test-fixture.ts"] } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts index 7f5704a09eb0..5f3a934e91e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts @@ -1,6 +1,7 @@ // A Stop that ends the child after its interrupt failed: its row reports on the run it stopped, so -// one whose child end took back the send it found, with no turn running, leaves none. Its message -// is back in the composer, and a row would sit under the turn before as if that turn were stopped. +// one whose child end took back the send it found, with no turn running, leaves none. A client +// draws that send where it was sent with its own row, and a second row would sit under the turn +// before on an older client, as if that turn were stopped. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' 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 index d8871314342b..e6c3c608ae20 100644 --- 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 @@ -320,8 +320,8 @@ async function cancelAndNote( if (ended) { cancelled = true // A child end that took back every send it found, with no turn running, ended a run that - // never started: its message is back in the composer, and a row would sit under the turn - // before as if that turn were stopped. + // never started: a client draws that send where it was sent, with its own row saying so. A + // row here would say it twice, and an older client draws it under the turn before. note = stoppedTurnId === null && tookBackEverySend(ctx, sentBeforeStop) ? null diff --git a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts new file mode 100644 index 000000000000..80432e8399c8 --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts @@ -0,0 +1,360 @@ +// Where a send a Stop took back is drawn: after the turn it waited on, never above that turn's own +// rows, and never below a later exchange, a restart included. Codex's resume rewrites every +// finished turn's start time to its own whole seconds, so the order is decided by the journal alone. +// Driven through the shipped host, journal and Codex adapter; only the Codex child is fake. + +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 { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' +import { settledWithin } from '../codex/codex-structured-dispatch-test-support' +import { CODEX_TURN_OPEN_WAIT_MS } from '../codex/codex-structured-turn-open-wait' +import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { NATIVE_CHAT_STOPPED_BEFORE_START_TEXT } from '../../shared/native-chat-stopped-before-start' +import { projectNativeChatTranscript } from '../../shared/native-chat-transcript-projection' +import { nativeChatRowsInDrawOrder } from '../../shared/native-chat-turn-grouping' +import { + nativeChatTurnMembership, + structuredAgentTurnAnchors +} from '../../shared/native-chat-turn-membership' +import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' +import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const CALLER = { callerKey: 'codex-stopped-send-resume-test' } +const MODEL = { + model: 'gpt-test', + displayName: 'GPT Test', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true +} + +let root: string +let host: StructuredAgentSessionHost +let fence: number +let handlers: CodexAppServerConnectionHandlers | undefined +let answers: number +let resumes: number +/** What `thread/resume` reports as the thread's turns, as Codex does: times in whole seconds. */ +let resumeTurns: unknown[] | null +let turns: ReturnType +/** Codex takes the interrupt and answers it; the test streams more and sends the end later. */ +let interruptEndHeld: boolean +let operations = 0 + +/** The durable ledger stamps its own clock and refuses an id far from it. */ +const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` + +function envelope(method: string, fields: Record) { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value.clientMessageId +} + +/** The chat's Stop; `turnId` names the turn it stops, as the phone does. */ +function stop(turnId?: string): Promise { + const fields = turnId === undefined ? {} : { turnId } + return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) +} + +function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessageId: string) { + const submission = submissions.find((entry) => entry.clientMessageId === clientMessageId) + return submission?.dispatchState === 'rejected' + ? classifyDispatchRejection(submission).category + : submission?.dispatchState +} + +async function submissions(): Promise { + await host.flushStreamedEvents(SESSION) + return (await host.journalSnapshot(SESSION)).submissions +} + +/** Each row's text, in the order every client draws the transcript, turns grouped. */ +async function drawn(): Promise { + await host.flushStreamedEvents(SESSION) + const { items, submissions } = await host.journalSnapshot(SESSION) + const journal = { items, submissions } + const { conversation } = projectNativeChatTranscript( + projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: true }), + undefined, + journal + ) + return nativeChatRowsInDrawOrder( + conversation, + nativeChatTurnMembership(conversation, journal).drawOrder + ).map((message) => message.blocks.map((block) => ('text' in block ? block.text : '')).join('')) +} + +const openConnection: typeof openCodexAppServerConnection = async ( + _launch, + connectionHandlers = {} +) => { + handlers = connectionHandlers + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method, params) => { + if (method === 'thread/resume') { + resumes += 1 + return { thread: { id: THREAD, ...(resumeTurns ? { turns: resumeTurns } : {}) } } + } + if (method === 'thread/start') { + return { thread: { id: THREAD } } + } + if (method === 'model/list') { + return { data: [MODEL], nextCursor: null } + } + if (method === 'turn/start') { + answers += 1 + return turns.routes['turn/start']() + } + if (method === 'turn/steer') { + return turns.routes['turn/steer'](params) + } + if (method === 'turn/interrupt' && interruptEndHeld) { + turns.takeInterrupt() + return {} + } + if (method === 'turn/interrupt') { + return turns.routes['turn/interrupt'](params) + } + return {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + } + return connection +} + +async function startHost(): Promise { + host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveCodexCommand: () => 'codex', + resolveEnvironment: async () => ({ PATH: process.env.PATH }), + openCodexConnection: openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) +} + +async function attach(expected: number | null): Promise { + const params = hostTestAttachParams(expected, { providerHandle: undefined }) + params.envelope.clientOperationId = operationId() + const attached = await host.attach(CALLER, params) + if (attached.ok) { + fence = attached.value.fence + return + } + const refusal = JSON.stringify(attached.refusal) + const current = /"currentFence":(\d+)/.exec(refusal)?.[1] + if (current === undefined) { + throw new Error(refusal) + } + await attach(Number(current)) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-stopped-send-resume-')) + answers = 0 + resumes = 0 + resumeTurns = null + interruptEndHeld = false + turns = codexTurnLifecycleFake( + THREAD, + () => (method, params) => handlers?.onNotification?.(method, params) + ) + await startHost() + await attach(null) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) +}) + +describe("a send made while Codex held the first send's answer, then a Stop", () => { + it("is drawn after the first send's interrupted turn, which streamed after the Stop", async () => { + const release = turns.holdNextAnswer() + const first = await send('first') + await vi.waitFor(() => expect(answers).toBe(1)) + // Both wait on the session behind the held answer: the second send, then the Stop. + const second = send('second') + const stopping = stop() + interruptEndHeld = true + release() + const secondId = await second + await vi.waitFor(async () => expect(verdictOf(await submissions(), secondId)).toBe('withdrawn')) + turns.start() + turns.echo(first) + // The turn opened after the second send was accepted, and streams after the Stop was pressed. + handlers?.onNotification?.('item/completed', { + threadId: THREAD, + turn: { id: 'turn-1' }, + item: { type: 'agentMessage', id: 'streamed', text: 'streamed after the Stop' } + }) + await settledWithin(stopping, 3_000) + turns.end('interrupted') + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).items + .map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-1')?.state + ).toBe('interrupted') + ) + + // The Stop's own note is the host's to place; the order here is the stopped send's. + const rows = (await drawn()).filter((text) => text !== 'Cancellation requested.') + expect(rows.slice(rows.indexOf('first'))).toEqual([ + 'first', + 'streamed after the Stop', + 'second', + NATIVE_CHAT_STOPPED_BEFORE_START_TEXT + ]) + }) +}) + +describe('a send a Stop took back, then a send made while it read Stopping', () => { + it('keeps the stopped send above the later exchange after a restart resumes the thread', async () => { + const first = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + // Codex has answered but not opened the turn, so the Stop waits for it to open. + const stopping = stop() + expect(await settledWithin(stopping, 500)).toBe('held') + const second = await send('sent while stopping') + turns.start() + await settledWithin(stopping, 3_000) + await vi.waitFor(async () => expect(verdictOf(await submissions(), first)).toBe('withdrawn')) + await vi.waitFor(() => expect(answers).toBe(2)) + turns.start() + turns.echo(second) + turns.end('completed') + await vi.waitFor(async () => expect(verdictOf(await submissions(), second)).toBe('accepted')) + const live = await drawn() + expect(live).toEqual([ + 'look around', + NATIVE_CHAT_STOPPED_BEFORE_START_TEXT, + 'sent while stopping' + ]) + + // Restart: the resume reports each finished turn with its start in Codex's whole seconds. + resumeTurns = (await host.journalSnapshot(SESSION)).items.flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn?.startedAt !== undefined && turn.turnId.startsWith('turn-') + ? [ + { + id: turn.turnId, + status: 'completed', + startedAt: Math.floor(turn.startedAt / 1000), + completedAt: Math.ceil((turn.completedAt ?? turn.startedAt) / 1000) + 1, + items: [] + } + ] + : [] + }) + await stopStructuredAgentSessionRuntime() + await startHost() + await attach(fence) + await vi.waitFor(() => expect(resumes).toBeGreaterThan(0)) + await vi.waitFor(async () => { + const restored = (await host.journalSnapshot(SESSION)).items + .map((item) => readAgentJournalTurn(item.body)?.startedAt) + .find((startedAt) => startedAt !== undefined) + expect((restored ?? 1) % 1000).toBe(0) + }) + + expect(await drawn()).toEqual(live) + }) +}) + +describe('a send a Stop took back before Codex echoed it', () => { + it('is drawn as the opener of the turn Codex opened for it, with no row of its own', async () => { + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + await stop('turn-1') + await vi.waitFor(async () => expect(verdictOf(await submissions(), sent)).toBe('withdrawn')) + + // Codex opened its turn before echoing it, so that turn, ended interrupted, is the send's: it + // carries the stop, and no row of its own follows the send. + const snapshot = await host.journalSnapshot(SESSION) + const turnRecord = snapshot.items.find( + (item) => readAgentJournalTurn(item.body)?.turnId === 'turn-1' + ) + expect( + structuredAgentTurnAnchors(snapshot.items, snapshot.submissions).get(turnRecord!.itemId) + ).toBe(agentJournalSubmissionKey(sent)) + expect(await drawn()).toEqual(['look around', 'Cancellation requested.']) + }) + + it('is drawn where it was sent, then the one row saying it never started, when no turn opened', async () => { + const warmUp = await send('warm up') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(warmUp) + turns.end('completed') + const release = turns.holdNextAnswer() + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(2)) + const stopping = stop() + release() + // The Stop waits out the turn-open wait for a turn that never opens, then ends there. + expect(await settledWithin(stopping, CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + expect(verdictOf(await submissions(), sent)).toBe('withdrawn') + + // Every client draws the send where it was sent, then the one row saying it never started. + await host.flushStreamedEvents(SESSION) + const { items, submissions: settled } = await host.journalSnapshot(SESSION) + const messages = projectStructuredAgentSessionMessages(items, [], settled, { + rejectedInPlace: true + }) + expect(messages.slice(-2).map((message) => [message.role, message.blocks])).toEqual([ + ['user', [{ type: 'text', text: 'look around' }]], + ['system', [expect.objectContaining({ text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT })]] + ]) + expect(await drawn()).toEqual(['warm up', 'look around', NATIVE_CHAT_STOPPED_BEFORE_START_TEXT]) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.stopped-before-start.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.stopped-before-start.test.tsx new file mode 100644 index 000000000000..1e81cceb83b7 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.stopped-before-start.test.tsx @@ -0,0 +1,267 @@ +// @vitest-environment happy-dom + +// A send a Stop took back before the agent started it stays where it was sent, with one row after +// it, never under the turn before, and with no header of its own. + +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 { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' +import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection' +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 STOP_ROW = 'Stopped before the agent started' +const THREAD: AgentJournalTurnScope = { kind: 'thread' } +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 sent = (id: string, text: string) => + item(agentJournalSubmissionKey(id), { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text }] + }) + +function turn( + itemId: string, + userId: string, + seconds: number, + end: { state: 'completed' | 'interrupted'; outcome: 'success' | 'cancellation' } +) { + return item(itemId, { + kind: 'turn', + turnId: itemId, + ...end, + userItemId: agentJournalSubmissionKey(userId), + startedAt: 1_000, + completedAt: 1_000 + seconds * 1_000 + }) +} + +/** "warm up", answered and settled in 3s. */ +function warmUp(): AgentJournalRenderItem[] { + return [ + sent('warm-up', 'warm up'), + turn('t1', 'warm-up', 3, { state: 'completed', outcome: 'success' }), + item( + 't1-answer', + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Warmed up.' }] }, + { kind: 'turn', turnItemId: 't1' } + ) + ] +} + +function submission( + clientMessageId: string, + overrides: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2, + ...overrides + } +} + +const stopped = (id: string, overrides: Partial = {}) => + submission(id, { dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED, ...overrides }) + +function journalList( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[], + live: { isWorking: boolean; workingStartedAt: number | null } = { + isWorking: false, + workingStartedAt: null + } +): React.JSX.Element { + return ( + + ) +} + +function renderJournal(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { + render(journalList(items, submissions)) +} + +function follows(later: HTMLElement, earlier: HTMLElement): boolean { + return Boolean(earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING) +} + +describe('a send a Stop took back before the agent started it', () => { + it('stays after the turn before it, with the stop row after it and no header of its own', () => { + renderJournal( + [...warmUp(), sent('never-ran', 'look around')], + [submission('warm-up'), stopped('never-ran')] + ) + + const message = screen.getByText('look around') + const row = screen.getByText(STOP_ROW) + expect(follows(message, screen.getByText('Warmed up.'))).toBe(true) + expect(follows(row, message)).toBe(true) + expect(screen.getAllByText(/^(Worked|Working) for/)).toHaveLength(1) + expect(screen.getByText('Worked for 3s')).toBeInTheDocument() + }) + + it('keeps no clock it showed while the send waited on its turn', () => { + const items = [...warmUp(), sent('never-ran', 'look around')] + const { rerender } = render( + journalList( + items, + [submission('warm-up'), submission('never-ran', { dispatchState: 'pending' })], + { + isWorking: true, + workingStartedAt: Date.now() - 5_000 + } + ) + ) + expect(screen.getByText('Working for 5s')).toBeInTheDocument() + + rerender(journalList(items, [submission('warm-up'), stopped('never-ran')])) + + expect(screen.getAllByText(/^(Worked|Working) for/).map((node) => node.textContent)).toEqual([ + 'Worked for 3s' + ]) + expect(follows(screen.getByText(STOP_ROW), screen.getByText('look around'))).toBe(true) + }) + + it('draws one stop row after sends taken back together', () => { + renderJournal( + [...warmUp(), sent('first', 'first send'), sent('second', 'second send')], + [submission('warm-up'), stopped('first'), stopped('second')] + ) + + expect(screen.getAllByText(STOP_ROW)).toHaveLength(1) + expect(follows(screen.getByText(STOP_ROW), screen.getByText('second send'))).toBe(true) + }) + + it('leaves a send whose turn opened to that turn, which says it was interrupted', () => { + renderJournal( + [ + ...warmUp(), + sent('opened', 'look around'), + turn('t2', 'opened', 2, { state: 'interrupted', outcome: 'cancellation' }) + ], + [submission('warm-up'), stopped('opened')] + ) + + expect(screen.getByText('look around')).toBeInTheDocument() + expect(screen.getByText('Interrupted after 2s')).toBeInTheDocument() + expect(screen.queryByText(STOP_ROW)).toBeNull() + }) + + // Codex reports the turn open before it echoes the send, so the record names the provider's key. + it("leaves it to that turn before the provider echoed it, with only that turn's stop rows", () => { + renderJournal( + [ + ...warmUp(), + sent('opened', 'look around'), + item('t2', { + kind: 'turn', + turnId: 't2', + state: 'interrupted', + outcome: 'cancellation', + userItemId: 'codex:thread-1:t2:0', + startedAt: 10_000, + completedAt: 12_000 + }), + item( + 'stop:t2', + { kind: 'status', text: 'Cancellation requested.' }, + { kind: 'turn', turnItemId: 't2' } + ) + ], + [submission('warm-up'), stopped('opened', { resolvedAt: 12_000 })] + ) + + const message = screen.getByText('look around') + expect(follows(screen.getByText('Interrupted after 2s'), message)).toBe(true) + expect(screen.queryByText(STOP_ROW)).toBeNull() + }) + + it("draws its row as the Stop's own status rows are drawn", () => { + renderJournal( + [ + ...warmUp(), + item('stop:t1', { kind: 'status', text: 'Cancellation requested.' }), + sent('never-ran', 'look around') + ], + [submission('warm-up'), stopped('never-ran')] + ) + + const rowOf = (text: string) => screen.getByText(text).closest('.group') + expect(rowOf(STOP_ROW)?.className).toBe(rowOf('Cancellation requested.')?.className) + expect(rowOf(STOP_ROW)?.className).toBeTruthy() + }) + + it('keeps its row on screen under a settled turn on a host that states no turn scopes', () => { + const unscoped = (entry: AgentJournalRenderItem): AgentJournalRenderItem => { + const { turnScope: _none, ...rest } = entry + return rest + } + renderJournal( + [ + ...warmUp(), + sent('steered', 'and check the tests'), + item('t1-more', { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'More warming up.' }] + }) + ].map(unscoped), + [submission('warm-up'), stopped('steered')] + ) + + expect(screen.getByText('and check the tests')).toBeInTheDocument() + expect(screen.getByText(STOP_ROW)).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index a19d41ad2a58..8bd429c51da4 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -16,6 +16,7 @@ import { NativeChatToolRun } from './NativeChatToolRun' import { NativeChatReasoningRow } from './NativeChatReasoningRow' import { NativeChatCodeBlock } from './NativeChatCodeBlock' import { NativeChatNoticeRow } from './NativeChatNoticeRow' +import { nativeChatBlocksInOwnWords } from './native-chat-stopped-before-start-row' import { NativeChatCopyButton } from './NativeChatCopyButton' import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp' import { @@ -122,14 +123,15 @@ export const MessageRow = memo(function MessageRow({ rewind?: NativeChatRewindSurface }): React.JSX.Element | null { const rowRef = useRef(null) + const blocks = nativeChatBlocksInOwnWords(message.blocks) // One pass per block set, shared with the list that decides whether this row // occupies a slot — so "draws nothing" means the same thing to both. const { backgroundTasks, hasImages, markdown, prose, subagentGroups, tools } = - deriveNativeChatRowContent(message.blocks) + deriveNativeChatRowContent(blocks) const isUser = message.role === 'user' const isReasoning = message.role === 'reasoning' const isSystem = message.role === 'system' - const providerFrame = message.blocks.find((block) => block.type === 'text' && block.providerFrame) + const providerFrame = blocks.find((block) => block.type === 'text' && block.providerFrame) const scrollToTop = useCallback(() => { if (rowRef.current) { @@ -151,7 +153,7 @@ export const MessageRow = memo(function MessageRow({ } const notice = isSystem - ? message.blocks.find( + ? blocks.find( (block) => block.type === 'text' && (block.presentation !== undefined || block.tone !== undefined) ) diff --git a/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts b/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts index 755d34123169..a35eaa33ab93 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-rail-items.ts @@ -36,7 +36,12 @@ export function buildNativeChatRailItems( ): readonly NativeChatRailItem[] { const items: NativeChatRailItem[] = [] for (const [slotIndex, slot] of slots.entries()) { - if (slot.kind !== 'message' || slot.message.role !== 'user') { + // A send a Stop took back is no tick, as the host's outline of older history leaves it out. + if ( + slot.kind !== 'message' || + slot.message.role !== 'user' || + slot.message.stoppedBeforeStart === true + ) { continue } const preview = nativeChatUserMessagePreview(slot.message.blocks) 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 6d4e0b23a784..4d8232303e99 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 @@ -9,6 +9,7 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { buildNativeChatRailItems } from './native-chat-message-rail-items' import { createNativeChatMessageListProjection } from './native-chat-message-list-projection' @@ -121,6 +122,35 @@ describe('conversation outline parity with the loaded rail', () => { ]) }) + // The host serves this outline to clients of every version, so it lists what it always listed. + it('leaves out a send a Stop took back, which the transcript still draws', () => { + const stopped: AgentJournalSubmission = { + ...REJECTED, + clientMessageId: 'client-stopped', + reason: DISPATCH_REJECTED_CANCELLED + } + const journal = [ + ...JOURNAL, + user(13, [{ type: 'text', text: 'never ran' }], agentJournalSubmissionKey('client-stopped')) + ] + const outline = projectAgentSessionConversationOutline(journal, [REJECTED, stopped]) + // The rejected message is the desktop's own in-place row, as above. + const rejectedId = agentJournalSubmissionKey(REJECTED.clientMessageId) + const loaded = loadedRailItems(journal, [REJECTED, stopped]).filter( + (item) => item.id !== rejectedId + ) + + expect(outline.map((entry) => entry.itemId)).toEqual(loaded.map((item) => item.id)) + expect(outline.map((entry) => entry.itemId)).not.toContain( + agentJournalSubmissionKey('client-stopped') + ) + expect( + projectStructuredAgentSessionMessages(journal, [], [REJECTED, stopped]).map( + (message) => message.id + ) + ).toContain(agentJournalSubmissionKey('client-stopped')) + }) + it('carries each entry its creation sequence and image count', () => { const outline = projectAgentSessionConversationOutline(JOURNAL, [REJECTED]) expect(outline).toEqual([ diff --git a/src/renderer/src/components/native-chat/native-chat-stopped-before-start-row.ts b/src/renderer/src/components/native-chat/native-chat-stopped-before-start-row.ts new file mode 100644 index 000000000000..4a3fb9daf38e --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-stopped-before-start-row.ts @@ -0,0 +1,20 @@ +import { translate } from '@/i18n/i18n' +import { isStoppedBeforeStartBlock } from '../../../../shared/native-chat-stopped-before-start' +import type { NativeChatBlock } from '../../../../shared/native-chat-types' + +/** The row after a send a Stop took back, in this client's words, drawn as any other status line. */ +export function nativeChatBlocksInOwnWords(blocks: NativeChatBlock[]): NativeChatBlock[] { + return blocks.some(isStoppedBeforeStartBlock) + ? blocks.map((block) => + isStoppedBeforeStartBlock(block) + ? { + type: 'text', + text: translate( + 'components.native-chat.notices.stoppedBeforeStart', + 'Stopped before the agent started' + ) + } + : block + ) + : blocks +} 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 dc5b53fdbd4d..69dca09a72cc 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 @@ -24,6 +24,7 @@ import { type NativeChatTurnFoldRow } from '../../../../shared/native-chat-turn-fold' import { nativeChatRowRendersContent } from '../../../../shared/native-chat-row-content' +import { isStoppedBeforeStartBlock } from '../../../../shared/native-chat-stopped-before-start' import { type NativeChatRowTypography, estimateNativeChatRowHeight, @@ -137,7 +138,10 @@ export function buildNativeChatTranscriptSlots( // The raw blocks, not the renderable ones: a childless roster draws no row // and its plain-text twin is then the only record the spawn happened. outlivesTurn: message.blocks.some( - (block) => isSubagentGroupBlock(block) || isBackgroundTaskBlock(block) + (block) => + isSubagentGroupBlock(block) || + isBackgroundTaskBlock(block) || + isStoppedBeforeStartBlock(block) ), reportsFailure: message.blocks.some((block) => block.type === 'text' && block.tone === 'error'), reportsCompaction: message.blocks.some( diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts index 4d94622cfa99..b12229143019 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -123,6 +123,14 @@ function rows(messages: ReturnType } const SEED = submission('seed', 'seed', 1) +// The one row drawn after a send a Stop took back before the agent started it. +const STOPPED_ROW = `stopped-before-start:${agentJournalSubmissionKey('stopped')}` + +/** The stopped send's own row comes right after it. */ +function expectStopRowRightAfterStopped(messages: readonly { id: string }[]): void { + const ids = messages.map((message) => message.id) + expect(ids.indexOf(STOPPED_ROW)).toBe(ids.indexOf(agentJournalSubmissionKey('stopped')) + 1) +} const SEED_ROWS = [userItem('seed', 1, 'seed'), answer(2)] describe('a message the host accepted and then rejected, on the desktop', () => { @@ -260,11 +268,14 @@ describe('a message the host accepted and then rejected, on the desktop', () => withdrawn('stopped', 'again', 5) ] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ + const messages = projectStructuredAgentSessionMessages(items, [], submissions) + expect(rows(messages)).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, { id: agentJournalSubmissionKey('first'), text: 'again', unsent: false }, + { id: agentJournalSubmissionKey('stopped'), text: 'again', unsent: false }, { id: agentJournalSubmissionKey('failed'), text: 'again', unsent: true } ]) + expectStopRowRightAfterStopped(messages) }) // Its own reply reports the rejection, in the composer, as a command's. @@ -292,13 +303,16 @@ describe('a message the host accepted and then rejected, on the desktop', () => ).toBe(0) }) - it('keeps a message a Stop withdrew hidden: it went back to its sender', () => { + it('draws a message a Stop withdrew, with its stop row, and no delivery notice', () => { const items = [...SEED_ROWS, userItem('stopped', 3, 'never mind')] const submissions = [SEED, withdrawn('stopped', 'never mind', 3)] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ - { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } + const messages = projectStructuredAgentSessionMessages(items, [], submissions) + expect(rows(messages)).toEqual([ + { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, + { id: agentJournalSubmissionKey('stopped'), text: 'never mind', unsent: false } ]) + expectStopRowRightAfterStopped(messages) expect( structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set()).size ).toBe(0) @@ -451,7 +465,7 @@ describe('a rejected message the queue holds', () => { }) describe('the phone', () => { - it('still hides every accepted-then-rejected message: it gives the text back to its composer', () => { + it('still hides a message the host failed to deliver, but draws one a Stop withdrew', () => { const items = [ ...SEED_ROWS, userItem('lost', 3, 'fix the parser'), @@ -463,8 +477,11 @@ describe('the phone', () => { withdrawn('stopped', 'never mind', 4) ] - expect(rows(projectShared(items, [], submissions, { rejectedInPlace: false }))).toEqual([ - { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } + const messages = projectShared(items, [], submissions, { rejectedInPlace: false }) + expect(rows(messages)).toEqual([ + { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, + { id: agentJournalSubmissionKey('stopped'), text: 'never mind', unsent: false } ]) + expectStopRowRightAfterStopped(messages) }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx index 7fe615208c08..6523ff17fa63 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx @@ -1,5 +1,6 @@ -// A message a Stop withdrew never ran, and nothing sends it again. Its sender gets the text and -// images back in the composer, once, after whatever is typed there. +// A message a Stop withdrew never ran, and nothing sends it again. One the host held stays in the +// transcript, so the composer keeps what is typed there. One this client's own Stop took before +// the host held it comes back to the composer, and only to an empty one. // @vitest-environment happy-dom @@ -57,7 +58,6 @@ import { appendNativeChatDraftCache, clearNativeChatDraftCacheForTests, readNativeChatDraftCache, - subscribeToNativeChatDraftAppend, writeNativeChatDraftCache } from './native-chat-draft-cache' import { @@ -161,10 +161,6 @@ async function sendToHost( return result.current.outbox.at(-1)!.clientMessageId } -function occurrences(text: string, of: string): number { - return text.split(of).length - 1 -} - afterEach(() => { cleanup() setLocalRuntimeCapabilitiesForTests(null) @@ -188,30 +184,24 @@ describe('a message the host withdrew at a Stop', () => { it.each([ ['a Stop during the start withdrew it from the host queue', {}], ['Claude cancelled it as a queued follow-up', { handedOverAt: 12 }] - ])( - 'comes back to the composer with its images, after the draft, when %s', - async (_case, handover) => { - answerSendsPending() - writeNativeChatDraftCache(PANE, 'already typed') - appendNativeChatAttachmentCache(PANE, [{ id: 'typed', path: '/tmp/typed.png' }]) - const { result, rerender } = renderOutbox() - const id = await sendToHost(result, 'hello', [ - { path: '/tmp/shot.png', previewUri: '/tmp/shot.png' } - ]) - rerender({ submissions: [submission(id, handover)] }) + ])('leaves the outbox and leaves the composer as it is, when %s', async (_case, handover) => { + answerSendsPending() + writeNativeChatDraftCache(PANE, 'already typed') + appendNativeChatAttachmentCache(PANE, [{ id: 'typed', path: '/tmp/typed.png' }]) + const { result, rerender } = renderOutbox() + const id = await sendToHost(result, 'hello', [ + { path: '/tmp/shot.png', previewUri: '/tmp/shot.png' } + ]) + rerender({ submissions: [submission(id, handover)] }) - rerender({ submissions: [withdrawn(id, handover)] }) + rerender({ submissions: [withdrawn(id, handover)] }) - await waitFor(() => expect(result.current.outbox).toEqual([])) - expect(readNativeChatDraftCache(PANE)).toBe('already typed\n\nhello') - expect(readNativeChatAttachmentCache(PANE)).toEqual([ - { id: 'typed', path: '/tmp/typed.png' }, - { id: expect.any(String), path: '/tmp/shot.png' } - ]) - } - ) + await waitFor(() => expect(result.current.outbox).toEqual([])) + expect(readNativeChatDraftCache(PANE)).toBe('already typed') + expect(readNativeChatAttachmentCache(PANE)).toEqual([{ id: 'typed', path: '/tmp/typed.png' }]) + }) - it('comes back when the host wrote the withdrawal as a typed fact in a sentence', async () => { + it('leaves an empty composer empty when the host wrote the withdrawal as a typed fact', async () => { answerSendsPending() const { result, rerender } = renderOutbox() const id = await sendToHost(result, 'hello') @@ -226,10 +216,10 @@ describe('a message the host withdrew at a Stop', () => { }) await waitFor(() => expect(result.current.outbox).toEqual([])) - expect(readNativeChatDraftCache(PANE)).toBe('hello') + expect(readNativeChatDraftCache(PANE)).toBe('') }) - it('comes back once, whatever replays the journal or shows the chat again', async () => { + it('is never put back by a replay or a second view of the chat', async () => { answerSendsPending() const first = renderOutbox() const id = await sendToHost(first.result, 'hello') @@ -245,30 +235,7 @@ describe('a message the host withdrew at a Stop', () => { await waitFor(() => expect(readOutbox(SESSION)).toEqual([])) const restored = readNativeChatDraftCache(PANE) + readNativeChatDraftCache(OTHER_PANE) - expect(occurrences(restored, 'hello')).toBe(1) - }) - - it('is back in the composer before it leaves storage, so a crash between repeats it', async () => { - answerSendsPending() - const { result, rerender } = renderOutbox() - const id = await sendToHost(result, 'hello') - let storedAtRestore: string[] = [] - const unsubscribe = subscribeToNativeChatDraftAppend(PANE, () => { - storedAtRestore = readOutbox(SESSION).map((entry) => entry.clientMessageId) - }) - const beforeDrop = readOutbox(SESSION, { recoverDispatching: false }) - - rerender({ submissions: [withdrawn(id)] }) - unsubscribe() - - expect(storedAtRestore).toEqual([id]) - // The drop never reached storage: after a crash, which loses the in-memory draft, the next mount - // gives the text back again rather than losing it. - cleanup() - writeNativeChatDraftCache(PANE, '') - writeOutbox(SESSION, beforeDrop) - renderOutbox().rerender({ submissions: [withdrawn(id)] }) - expect(readNativeChatDraftCache(PANE)).toBe('hello') + expect(restored).toBe('') }) it("leaves a message rejected for any other reason to the host's row, and gives nothing back", async () => { @@ -308,7 +275,7 @@ describe('a message the host withdrew at a Stop', () => { }) describe('a message a Stop took out of the outbox before the host held it', () => { - it('comes back to the composer when the Stop follows the send at once', async () => { + it('comes back to an empty composer when the Stop follows the send at once', async () => { mocks.call.mockImplementation(() => new Promise(() => {})) const { result } = renderOutbox() act(() => expect(result.current.send('first')).toBe(true)) @@ -323,7 +290,7 @@ describe('a message a Stop took out of the outbox before the host held it', () = expect(readNativeChatDraftCache(PANE)).toBe('second') }) - it('gives back a send already on its way only when the host withdraws it', async () => { + it('never gives back a send already on its way, even when the host withdraws it', async () => { answerSendsPending() const reply = Promise.withResolvers() const answer = mocks.call.getMockImplementation()! @@ -346,7 +313,76 @@ describe('a message a Stop took out of the outbox before the host held it', () = rerender({ submissions: [withdrawn(id)] }) await waitFor(() => expect(result.current.outbox).toEqual([])) - expect(readNativeChatDraftCache(PANE)).toBe('hello') + expect(readNativeChatDraftCache(PANE)).toBe('') + }) + + it.each([ + ['text', () => writeNativeChatDraftCache(PANE, 'typed since')], + [ + 'an image', + () => appendNativeChatAttachmentCache(PANE, [{ id: 'typed', path: '/tmp/typed.png' }]) + ] + ])( + 'leaves a composer holding %s as it is, and keeps the message as not sent', + async (_holding, fill) => { + mocks.call.mockImplementation(() => new Promise(() => {})) + const { result } = renderOutbox() + act(() => expect(result.current.send('first')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + act(() => + expect( + result.current.send('second', [{ path: '/tmp/shot.png', previewUri: '/tmp/shot.png' }]) + ).toBe(true) + ) + const before = { + draft: readNativeChatDraftCache(PANE), + images: readNativeChatAttachmentCache(PANE) + } + fill() + const filled = { + draft: readNativeChatDraftCache(PANE), + images: readNativeChatAttachmentCache(PANE) + } + expect(filled).not.toEqual(before) + + act(() => result.current.withdrawUnsent()) + + expect({ + draft: readNativeChatDraftCache(PANE), + images: readNativeChatAttachmentCache(PANE) + }).toEqual(filled) + // Its only copy, so it stays as not sent, text and image, on its Retry. + expect( + readOutbox(SESSION, { recoverDispatching: false }).map((entry) => ({ + state: entry.state, + blocks: entry.body.blocks + })) + ).toEqual([ + { state: 'dispatching', blocks: [{ type: 'text', text: 'first' }] }, + { + state: 'rejected', + blocks: [ + { type: 'text', text: 'second' }, + { type: 'image-ref', path: '/tmp/shot.png' } + ] + } + ]) + } + ) + + it('keeps the message as not sent where no composer shows the chat', async () => { + mocks.call.mockImplementation(() => new Promise(() => {})) + const { result } = renderOutbox(null) + act(() => expect(result.current.send('first')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + act(() => expect(result.current.send('second')).toBe(true)) + + act(() => result.current.withdrawUnsent()) + + expect(result.current.outbox.map((entry) => [entry.state, entry.body.blocks])).toEqual([ + ['dispatching', [{ type: 'text', text: 'first' }]], + ['rejected', [{ type: 'text', text: 'second' }]] + ]) }) it('leaves a message waiting on Retry where it is, and gives back only what it withdrew', async () => { @@ -412,7 +448,7 @@ describe('Stop through the chat', () => { ) } - it('gives back what the host withdrew into the pane that pressed it', async () => { + it('keeps what the host withdrew out of the composer of the pane that pressed it', async () => { mocks.call.mockImplementation(async (_target, method) => method === 'agentSession.cancel' ? { ok: true, value: { cancelled: true } } : null ) @@ -425,7 +461,7 @@ describe('Stop through the chat', () => { rerender() await waitFor(() => expect(result.current.outbox).toEqual([])) - expect(readNativeChatDraftCache(PANE)).toBe('hello') + expect(readNativeChatDraftCache(PANE)).toBe('') }) it('says a failed Stop in a toast and gives nothing back', async () => { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts index ee6a1ee824c2..2bfa6a087373 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts @@ -1,13 +1,11 @@ import { useMemo } from 'react' -import type { - AgentJournalMessageItem, - AgentJournalSubmission -} from '../../../../shared/agent-session-journal-types' -import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' +import type { AgentJournalMessageItem } from '../../../../shared/agent-session-journal-types' import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' -import { appendNativeChatDraftCache } from './native-chat-draft-cache' -import { getStructuredAgentSessionOutbox } from './structured-agent-session-outbox-storage' -import { appendNativeChatAttachmentCache } from './use-native-chat-composer-attachments' +import { appendNativeChatDraftCache, readNativeChatDraftCache } from './native-chat-draft-cache' +import { + appendNativeChatAttachmentCache, + readNativeChatAttachmentCache +} from './use-native-chat-composer-attachments' /** Puts a message's text and images into a composer, after whatever is there. */ export function returnMessageToComposer( @@ -31,69 +29,42 @@ export function returnMessageToComposer( } /** - * Gives the sender back what a Stop withdrew: its text and images go into this pane's composer, - * after whatever is there. Called before the entries leave storage, so a failure between the two - * repeats the text rather than losing it. Only this client's outbox holds them, so no other viewer - * gets them. + * Gives the sender back what its own Stop took out of the outbox before the host held it, into + * an empty composer only: the host never had it, so the transcript cannot show it. Returns whether + * they went in; a composer holding text or images keeps what is there, and the caller keeps the + * entries. What the host withdrew stays in the transcript. */ -function restoreWithdrawnMessages( - sessionId: string, +function restoreUnsentMessages( composerScopeKey: string | undefined, withdrawn: readonly StructuredAgentSessionOutboxEntry[] -): void { - if (!composerScopeKey || withdrawn.length === 0) { - return +): boolean { + if ( + !composerScopeKey || + readNativeChatDraftCache(composerScopeKey) !== '' || + readNativeChatAttachmentCache(composerScopeKey).length > 0 + ) { + return false } - // What the outbox no longer holds was already given back by whichever view dropped it first. - const held = new Set( - getStructuredAgentSessionOutbox(sessionId).map((entry) => entry.clientMessageId) - ) for (const entry of withdrawn) { - if (!held.has(entry.clientMessageId)) { - continue - } returnMessageToComposer( composerScopeKey, `withdrawn-${entry.clientMessageId}`, entry.body.blocks ) } + return true } export function useStructuredAgentSessionWithdrawnRestore( - sessionId: string, - /** Absent where no composer shows this session; the entries are then only dropped. */ + /** Absent where no composer shows this session; the caller then keeps the entries. */ composerScopeKey: string | undefined ): { - /** The entries the host settled as withdrawn by a Stop. */ - byHost: ( - entries: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[] - ) => void - /** Entries a Stop took out of the outbox here, before the host held them. */ - byStop: (entries: readonly StructuredAgentSessionOutboxEntry[]) => void + /** Entries a Stop took out of the outbox here, before the host held them; false when the + * composer could not take them. */ + byStop: (entries: readonly StructuredAgentSessionOutboxEntry[]) => boolean } { return useMemo( - () => ({ - byHost: (entries, submissions) => { - // A hand-off of a queued draft is never restored: the Stop that withdrew it put the draft - // back as a card, which carries the text. - const withdrawn = new Set( - submissions - .filter( - (submission) => - dispatchWasWithdrawn(submission) && submission.queuedMessageId === undefined - ) - .map((submission) => submission.clientMessageId) - ) - restoreWithdrawnMessages( - sessionId, - composerScopeKey, - entries.filter((entry) => withdrawn.has(entry.clientMessageId)) - ) - }, - byStop: (entries) => restoreWithdrawnMessages(sessionId, composerScopeKey, entries) - }), - [composerScopeKey, sessionId] + () => ({ byStop: (entries) => restoreUnsentMessages(composerScopeKey, entries) }), + [composerScopeKey] ) } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts index a5928b057e19..7a724b884780 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts @@ -1,6 +1,6 @@ // Who owns an outbox entry's text when it leaves this client's queue without a -// send answer. A Stop hands unsent text back to the composer — a local move; no -// text crosses a wire. A host that visibly holds the entry as a queued draft +// send answer. A Stop hands unsent text back to an empty composer — a local move; no +// text crosses a wire. Text it cannot hand back stays here as not sent, on its Retry. A host that visibly holds the entry as a queued draft // (same id) owns it: those entries retire with no local restore, so the same // words can never come back twice. @@ -40,9 +40,18 @@ export function useStructuredAgentSessionOutboxOwnership(args: { return } // By id: a kept entry comes back marked, as a new object. - const kept = new Set(next.map((entry) => entry.clientMessageId)) - restoreWithdrawn.byStop(current.filter((entry) => !kept.has(entry.clientMessageId))) - commitStructuredAgentSessionOutbox(sessionId, next) + const kept = new Map(next.map((entry) => [entry.clientMessageId, entry])) + const withdrawn = current.filter((entry) => !kept.has(entry.clientMessageId)) + // The outbox is the only copy of what the host never had, so nothing leaves it unplaced. + const restored = restoreWithdrawn.byStop(withdrawn) + commitStructuredAgentSessionOutbox( + sessionId, + restored + ? next + : current.map( + (entry) => kept.get(entry.clientMessageId) ?? { ...entry, state: 'rejected' as const } + ) + ) }, [inFlightIdRef, restoreWithdrawn, sessionId, submissions]) // Drop host-owned entries without a restore: the published card is the text now. diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx index 4e815c9252f0..84eac65b640e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx @@ -111,9 +111,18 @@ describe('a Stop withdrawing what the host does not hold', () => { ) await act(async () => new Promise((resolve) => setTimeout(resolve, 50))) - // The host's answer to it, not this Stop, decides whether it comes back. - expect(result.current.outbox.map((candidate) => candidate.clientMessageId)).toEqual([firstId]) - expect(readOutbox('session-1').map((candidate) => candidate.clientMessageId)).toEqual([firstId]) + // The host's answer to it, not this Stop, decides whether it comes back. With no composer to + // take it, the one behind it stays as not sent, on its Retry, and never goes out by itself. + const states = (entries: readonly { clientMessageId: string; state: string }[]) => + entries.map((candidate) => [candidate.clientMessageId === firstId, candidate.state]) + expect(states(result.current.outbox)).toEqual([ + [true, 'dispatching'], + [false, 'rejected'] + ]) + expect(states(readOutbox('session-1', { recoverDispatching: false }))).toEqual([ + [true, 'dispatching'], + [false, 'rejected'] + ]) expect(sentTexts()).toEqual(['first']) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx index 61fc0685816e..1425deec66a8 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx @@ -141,12 +141,12 @@ describe('an outbox entry the host handed off as a queued draft', () => { expect(readNativeChatDraftCache('scope')).toBe('') }) - it('a withdrawn immediate send still comes back to the composer', async () => { + it('a withdrawn immediate send leaves the outbox and stays in the transcript, not the composer', async () => { seed('plain', false) const view = renderOutbox() view.rerender({ submissions: [submission('plain', WITHDRAWN)] }) await waitFor(() => expect(view.result.current.outbox).toHaveLength(0)) - expect(readNativeChatDraftCache('scope')).toBe('follow-up') + expect(readNativeChatDraftCache('scope')).toBe('') }) it('a withdrawn hand-off is never restored, even one under the id of the entry', async () => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 2a70730b3fce..fb8151e8725c 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -68,7 +68,7 @@ export function useStructuredAgentSessionOutbox(args: { submissions: readonly AgentJournalSubmission[] /** The loaded journal rows: a rejected message stays here until the row that draws it loads. */ journalItems: readonly AgentJournalRenderItem[] - /** The composer that gets back what a Stop withdrew from this client's outbox. */ + /** The composer that gets back, when empty, what a Stop took from this client's outbox. */ composerScopeKey?: string /** The host's queued-messages capability and the user's setting; a send stamped * `delivery: 'queue-if-active'` is held as a draft only while the agent is working. */ @@ -94,7 +94,7 @@ export function useStructuredAgentSessionOutbox(args: { const { capability: queueCapability, enabled: queueEnabled } = queueDelivery // What resends and drops a send in flight besides a Retry or a new send; see the hook. const owner = useStructuredAgentSessionOutboxOwnerChange(target, fence) - const restoreWithdrawn = useStructuredAgentSessionWithdrawnRestore(sessionId, composerScopeKey) + const restoreWithdrawn = useStructuredAgentSessionWithdrawnRestore(composerScopeKey) // The outbox lives in the session's store, shared with every other writer; this view holds it // open and drains it. Loading maps what a previous owner left mid-send. const load = useCallback( @@ -161,7 +161,6 @@ export function useStructuredAgentSessionOutbox(args: { // The reconcile returns `current` itself when no entry changed, so a batch that changes // nothing writes nothing. if (admittedInFlight || next !== current) { - restoreWithdrawn.byHost(current, submissions) commitStructuredAgentSessionOutbox(sessionId, [...next]) } // Keyed on the entry actually in flight, which is no longer always the head: the journal @@ -181,7 +180,7 @@ export function useStructuredAgentSessionOutbox(args: { ) { setError(null) } - }, [journalItems, restoreWithdrawn, sessionId, submissions]) + }, [journalItems, sessionId, submissions]) // The one place that owns the refs, the React state and the storage write. const applyDisposition = useCallback( diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-stop.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-stop.ts index 474d19342490..afa61f965f58 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-stop.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-stop.ts @@ -40,7 +40,7 @@ export function useStructuredAgentSessionStop(input: { stopsConversation, stop: (turnId, withdrawUnsent) => { if (stopsConversation) { - // Unsent text this client still owns goes back to its composer — a local move. + // Unsent text this client still owns goes back to an empty composer — a local move. // Host-held drafts are never withdrawn by a Stop: the host pauses them and // they stay visible as cards, on every device, until the user acts on one. withdrawUnsent() diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d58131e098bb..47727487862a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17919,7 +17919,8 @@ "details": "Details", "plan": "Plan", "historyRepaired": "Part of this chat's history couldn't be loaded.", - "historyItemTooLarge": "This part of the chat was too large to show." + "historyItemTooLarge": "This part of the chat was too large to show.", + "stoppedBeforeStart": "Stopped before the agent started" }, "composer": { "imageUnsupported": "Image paste is not supported for this agent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c5df8232fea4..ca079f86056d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18044,7 +18044,8 @@ "details": "Detalles", "plan": "Plan", "historyRepaired": "No se pudo cargar parte del historial de este chat.", - "historyItemTooLarge": "Esta parte del chat era demasiado grande para mostrarla." + "historyItemTooLarge": "Esta parte del chat era demasiado grande para mostrarla.", + "stoppedBeforeStart": "Detenido antes de que el agente empezara" }, "providerFrame": { "byteLength": "{{value0}} bytes" diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 133c89d94e12..7a6ffaec1fde 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18036,7 +18036,8 @@ "details": "Détails", "plan": "Plan", "historyRepaired": "Une partie de l'historique de ce chat n'a pas pu être chargée.", - "historyItemTooLarge": "Cette partie du chat était trop volumineuse pour être affichée." + "historyItemTooLarge": "Cette partie du chat était trop volumineuse pour être affichée.", + "stoppedBeforeStart": "Arrêté avant le démarrage de l'agent" }, "providerFrame": { "byteLength": "{{value0}} octets" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 65968827443e..03f95d74008a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17972,7 +17972,8 @@ "details": "詳細", "plan": "プラン", "historyRepaired": "このチャットの履歴の一部を読み込めませんでした。", - "historyItemTooLarge": "チャットのこの部分は大きすぎるため表示できません。" + "historyItemTooLarge": "チャットのこの部分は大きすぎるため表示できません。", + "stoppedBeforeStart": "Agent の開始前に停止しました" }, "providerFrame": { "byteLength": "{{value0}}バイト" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a82dc0900bc6..e18b350683ee 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17972,7 +17972,8 @@ "details": "세부", "plan": "계획", "historyRepaired": "이 채팅 기록의 일부를 불러올 수 없습니다.", - "historyItemTooLarge": "채팅의 이 부분은 너무 커서 표시할 수 없습니다." + "historyItemTooLarge": "채팅의 이 부분은 너무 커서 표시할 수 없습니다.", + "stoppedBeforeStart": "에이전트가 시작되기 전에 중지됨" }, "providerFrame": { "byteLength": "{{value0}}바이트" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4bb136cb3280..5416082dcbfb 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17937,7 +17937,8 @@ "details": "细节", "plan": "计划", "historyRepaired": "此聊天的部分历史记录无法加载。", - "historyItemTooLarge": "聊天的这一部分过大,无法显示。" + "historyItemTooLarge": "聊天的这一部分过大,无法显示。", + "stoppedBeforeStart": "在智能体开始前已停止" }, "providerFrame": { "byteLength": "{{value0}} 字节" diff --git a/src/shared/agent-session-conversation-outline.ts b/src/shared/agent-session-conversation-outline.ts index 4d6b814d64d3..aff03ba79353 100644 --- a/src/shared/agent-session-conversation-outline.ts +++ b/src/shared/agent-session-conversation-outline.ts @@ -13,6 +13,8 @@ import type { } from './agent-session-journal-types' import type { NativeChatBlock } from './native-chat-types' import { deriveNativeChatRowContent, nativeChatRowRendersContent } from './native-chat-row-content' +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' import { projectNativeChatTranscriptMessages } from './native-chat-transcript-projection' @@ -87,6 +89,12 @@ export function projectAgentSessionConversationOutline( sequences.set(item.itemId, item.sequence) } } + // Served to clients of every version, so a send a Stop took back stays out, as it always was. + const stopped = new Set( + submissions + .filter((submission) => dispatchWasWithdrawn(submission)) + .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) + ) const entries: AgentSessionConversationOutlineEntry[] = [] const transcript = projectNativeChatTranscriptMessages( // Unchanged on the wire: a desktop's rejected rows tick once their page is loaded. @@ -96,6 +104,7 @@ export function projectAgentSessionConversationOutline( const sequence = sequences.get(message.id) if ( sequence === undefined || + stopped.has(message.id) || message.role !== 'user' || !nativeChatRowRendersContent(message.blocks) ) { diff --git a/src/shared/native-chat-send-order.ts b/src/shared/native-chat-send-order.ts new file mode 100644 index 000000000000..5cd9349d771f --- /dev/null +++ b/src/shared/native-chat-send-order.ts @@ -0,0 +1,24 @@ +// The order a client's sends were made in, as the host published it. + +import type { AgentJournalSubmission } from './agent-session-journal-types' + +type SendTimes = Pick + +/** Entries in the order their sends were made: by the published `submittedSequence` when every one + * carries it, else by accept time. Stable, so ties keep the order given. */ +export function inSendOrder(entries: readonly T[], submissionOf: (entry: T) => SendTimes): T[] { + const keyed = entries.map((entry) => { + const { submittedSequence, submittedAt } = submissionOf(entry) + return { entry, submittedSequence, submittedAt } + }) + const sorted = allPositioned(keyed) + ? [...keyed].sort((left, right) => left.submittedSequence - right.submittedSequence) + : [...keyed].sort((left, right) => left.submittedAt - right.submittedAt) + return sorted.map(({ entry }) => entry) +} + +function allPositioned( + entries: T[] +): entries is (T & { submittedSequence: number })[] { + return entries.every((entry) => entry.submittedSequence !== undefined) +} diff --git a/src/shared/native-chat-stopped-before-start.ts b/src/shared/native-chat-stopped-before-start.ts new file mode 100644 index 000000000000..5b2b6c1fdc21 --- /dev/null +++ b/src/shared/native-chat-stopped-before-start.ts @@ -0,0 +1,285 @@ +// A send a Stop took back before the agent started it: where it is drawn, and the one row after it. + +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import { + agentJournalItemPosition, + compareAgentJournalItems, + compareAgentJournalPositions +} from './agent-session-journal-position' +import type { + AgentJournalPosition, + AgentJournalRenderItem, + AgentJournalSubmission +} from './agent-session-journal-types' +import { inSendOrder } from './native-chat-send-order' +import { stoppedTurnOpeners, structuredAgentTurnAnchors } from './native-chat-turn-membership' +import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' + +/** The row after a send a Stop took back before the agent started it; a client words it by this. */ +export const NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION = 'stopped-before-start' +/** Its words where a client has no catalog of its own (the phone). */ +export const NATIVE_CHAT_STOPPED_BEFORE_START_TEXT = 'Stopped before the agent started' + +/** That row: it outlives any turn it sits in, as the send it follows does. */ +export function isStoppedBeforeStartBlock(block: NativeChatBlock): boolean { + return ( + block.type === 'text' && block.presentation === NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION + ) +} + +/** Where a send a Stop took back is drawn. `opensTurn`: a turn opened for it, whose interrupted + * end is its stop; `position`: where it is drawn, when that is not its own row. */ +export type StoppedSendPlace = { opensTurn: boolean; position?: AgentJournalPosition } + +/** + * A send a Stop took back (`stopped`, by item id) is drawn at its own row, past the end of every + * turn whose opener the journal wrote before it: a host that publishes `submittedSequence` puts + * that row where the send was taken back. One that opened a turn is drawn as that turn's opener; + * when that turn's record is not loaded, before the turn's first loaded row. Journal order only, + * never a clock. + * Temporary: a host that predates `submittedSequence` may leave the row where it was sent, so the + * send is also drawn below the latest row sent before it (`latestRowsSentBefore`); harmless on one + * that moves the row. Dropped once no supported remote host lacks the field. + */ +export function placeStoppedSends( + items: readonly AgentJournalRenderItem[], + submissions: readonly AgentJournalSubmission[], + stopped: ReadonlyMap +): (itemId: string) => StoppedSendPlace { + const anchors = structuredAgentTurnAnchors(items, submissions) + const turnOpenedBy = new Map([...anchors].map(([turnItemId, anchorId]) => [anchorId, turnItemId])) + const itemsById = new Map(items.map((item) => [item.itemId, item])) + const pastTurnsOpenedBefore = turnEndsByOpener(items, anchors, itemsById) + const unloadedTurnOpened = unloadedTurnsOpened(items, itemsById, stopped) + const sentBefore = [...stopped.values()].some( + (submission) => submission.submittedSequence === undefined + ) + ? latestRowsSentBefore(submissions, itemsById) + : undefined + return (itemId) => { + const item = itemsById.get(itemId) + if (!item) { + return { opensTurn: false } + } + const own = agentJournalItemPosition(item) + const record = itemsById.get(turnOpenedBy.get(itemId) ?? '') + const opened = record ? openerPosition(item, record) : unloadedTurnOpened.get(itemId) + if (opened) { + // Drawn where its turn opened, as an opener is. + return compareAgentJournalPositions(own, opened) !== 0 + ? { opensTurn: true, position: opened } + : { opensTurn: true } + } + const floorRow = sentBefore?.get(itemId) + const floor = floorRow ? agentJournalItemPosition(floorRow) : undefined + // Just after the floor row, so a turn that row opened counts as waited on. + const from = + floor && compareAgentJournalPositions(floor, own) > 0 + ? { sequence: floor.sequence, index: floor.index + 0.5 } + : own + const position = pastTurnsOpenedBefore(from) + return compareAgentJournalPositions(position, own) !== 0 + ? { opensTurn: false, position } + : { opensTurn: false } + } +} + +/** Where a turn's opener is drawn: its own row, or just before the record when its row comes after + * it, as the row of a send taken back after its turn opened does. */ +function openerPosition( + opener: AgentJournalRenderItem, + record: AgentJournalRenderItem +): AgentJournalPosition { + const own = agentJournalItemPosition(opener) + const opened = agentJournalItemPosition(record) + return compareAgentJournalPositions(own, opened) > 0 + ? { sequence: opened.sequence, index: opened.index - 0.5 } + : own +} + +/** For each taken-back send that opened a turn whose record is not loaded (a page that starts inside + * that turn), just before the turn's first loaded row; none when a loaded user row is in it. */ +function unloadedTurnsOpened( + items: readonly AgentJournalRenderItem[], + itemsById: ReadonlyMap, + stopped: ReadonlyMap +): ReadonlyMap { + const openers = new Map( + [...stoppedTurnOpeners([...stopped.values()])].filter( + ([turnItemId]) => !itemsById.has(turnItemId) + ) + ) + const first = new Map() + for (const item of items) { + const turnItemId = item.turnScope?.kind === 'turn' ? item.turnScope.turnItemId : undefined + if (turnItemId === undefined || !openers.has(turnItemId) || first.has(turnItemId)) { + continue + } + const userRow = item.body.kind === 'message' && item.body.role === 'user' + const position = agentJournalItemPosition(item) + first.set(turnItemId, userRow ? null : { ...position, index: position.index - 0.5 }) + } + return new Map( + [...openers].flatMap(([turnItemId, itemId]) => { + const position = first.get(turnItemId) + return position ? [[itemId, position] as const] : [] + }) + ) +} + +/** + * For a point in the journal, just after the furthest row of every turn whose opener comes before + * it, or the point itself when none reaches past it. One pass over the items, then a binary search + * per point. A send placed here opened no turn, so no turn is its own. + */ +function turnEndsByOpener( + items: readonly AgentJournalRenderItem[], + anchors: ReadonlyMap, + itemsById: ReadonlyMap +): (from: AgentJournalPosition) => AgentJournalPosition { + const lastOfTurn = new Map() + const reach = (turnItemId: string, position: AgentJournalPosition): void => { + const last = lastOfTurn.get(turnItemId) + if (!last || compareAgentJournalPositions(position, last) > 0) { + lastOfTurn.set(turnItemId, position) + } + } + for (const item of items) { + const position = agentJournalItemPosition(item) + if (anchors.has(item.itemId)) { + reach(item.itemId, position) + } + if (item.turnScope?.kind === 'turn') { + reach(item.turnScope.turnItemId, position) + } + } + const turns = [...anchors].flatMap(([turnItemId, anchorId]) => { + const opener = itemsById.get(anchorId) + const record = itemsById.get(turnItemId) + const last = lastOfTurn.get(turnItemId) + return opener && record && last ? [{ opener: openerPosition(opener, record), last }] : [] + }) + turns.sort((left, right) => compareAgentJournalPositions(left.opener, right.opener)) + // The furthest row of the turns opened up to each one. + const furthest: AgentJournalPosition[] = [] + for (const turn of turns) { + const previous = furthest.at(-1) + furthest.push( + previous && compareAgentJournalPositions(previous, turn.last) > 0 ? previous : turn.last + ) + } + return (from) => { + let low = 0 + let high = turns.length + while (low < high) { + const middle = (low + high) >> 1 + if (compareAgentJournalPositions(turns[middle]!.opener, from) < 0) { + low = middle + 1 + } else { + high = middle + } + } + const last = low > 0 ? furthest[low - 1]! : undefined + return last && compareAgentJournalPositions(last, from) > 0 + ? { sequence: last.sequence, index: last.index + 0.5 } + : from + } +} + +/** For each submission, the latest loaded row of the ones sent before it, in `submittedAt` order + * with ties kept in list order as the client reducer keeps them. Temporary: read only for a host + * that predates `submittedSequence`. */ +function latestRowsSentBefore( + submissions: readonly AgentJournalSubmission[], + itemsById: ReadonlyMap +): ReadonlyMap { + const byAcceptTime = submissions + .map((submission, order) => ({ submission, order })) + .sort( + (left, right) => + left.submission.submittedAt - right.submission.submittedAt || left.order - right.order + ) + const before = new Map() + let latest: AgentJournalRenderItem | undefined + for (const { submission } of byAcceptTime) { + const key = agentJournalSubmissionKey(submission.clientMessageId) + if (latest) { + before.set(key, latest) + } + const row = itemsById.get(key) + if (row && (!latest || compareAgentJournalItems(row, latest) > 0)) { + latest = row + } + } + return before +} + +/** + * Sends a Stop took back (`stopped`, by item id, in list order) and drawn on their own (`shown`) + * keep the order they were sent in: a later one is drawn no earlier than just after an earlier + * one. Sent order is the published `submittedSequence`. + * Temporary, until a host version floor: a host that predates it gives `submittedAt`, its accept + * time, with ties kept in list order as the client reducer keeps them. Returns whether it moved any. + */ +export function keepStoppedSendsInSendOrder( + messages: NativeChatMessage[], + stopped: ReadonlyMap, + shown: ReadonlySet +): boolean { + const indexById = new Map() + messages.forEach((message, index) => { + if (shown.has(message.id)) { + indexById.set(message.id, index) + } + }) + const taken = [...stopped].flatMap(([itemId, submission]) => { + const index = indexById.get(itemId) + return index === undefined ? [] : [{ index, submission }] + }) + const sent = inSendOrder(taken, ({ submission }) => submission) + let floor: AgentJournalPosition | undefined + let moved = false + for (const { index } of sent) { + const message = messages[index]! + const position = message.journalPosition + if (!position) { + continue + } + if (floor && compareAgentJournalPositions(position, floor) <= 0) { + floor = { sequence: floor.sequence, index: floor.index + 1 / 1024 } + messages[index] = { ...message, journalPosition: floor } + moved = true + } else { + floor = position + } + } + return moved +} + +/** One row after each run of sends a Stop took back (`stopped`, by id), placed with the last. */ +export function withStopRowsAfterStoppedSends( + messages: readonly NativeChatMessage[], + stopped: ReadonlySet +): NativeChatMessage[] { + return messages.flatMap((message, index) => + stopped.has(message.id) && !stopped.has(messages[index + 1]?.id ?? '') + ? [ + message, + { + id: `stopped-before-start:${message.id}`, + role: 'system' as const, + source: 'transcript' as const, + timestamp: message.timestamp, + blocks: [ + { + type: 'text' as const, + text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT, + presentation: NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION + } + ], + ...(message.journalPosition ? { journalPosition: message.journalPosition } : {}) + } + ] + : [message] + ) +} diff --git a/src/shared/native-chat-stopped-send-turn.test.ts b/src/shared/native-chat-stopped-send-turn.test.ts new file mode 100644 index 000000000000..c5004cae3cc2 --- /dev/null +++ b/src/shared/native-chat-stopped-send-turn.test.ts @@ -0,0 +1,401 @@ +// Which turn a send a Stop took back before its echo opened, by the turn the host names it answered +// into, and where it is drawn when that turn's record is not loaded. + +import { describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnScope +} from './agent-session-journal-types' +import { projectNativeChatTranscript } from './native-chat-transcript-projection' +import { nativeChatRowsInDrawOrder } from './native-chat-turn-grouping' +import { nativeChatTurnMembership, structuredAgentTurnAnchors } from './native-chat-turn-membership' +import { DISPATCH_REJECTED_CANCELLED } from './structured-agent-session-dispatch-rejection' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +const STOPPED_ROW = 'stopped-before-start:' +const key = agentJournalSubmissionKey + +function row( + itemId: string, + sequence: number, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope = { kind: 'thread' } +): AgentJournalRenderItem { + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } +} + +const sent = (id: string, sequence: number, turnScope?: AgentJournalTurnScope) => + row( + key(id), + sequence, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, + turnScope + ) + +const turn = (itemId: string, sequence: number, userItemId: string, startedAt?: number) => + row(itemId, sequence, { + kind: 'turn', + turnId: itemId, + state: 'interrupted', + outcome: 'cancellation', + userItemId, + ...(startedAt !== undefined ? { startedAt } : {}) + }) + +const stopNote = (turnItemId: string, sequence: number) => + row( + `stop:${turnItemId}`, + sequence, + { kind: 'status', text: 'Cancellation requested.' }, + { + kind: 'turn', + turnItemId + } + ) + +function submission( + clientMessageId: string, + overrides: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2, + ...overrides + } +} + +const stopped = (id: string, overrides: Partial = {}) => + submission(id, { dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED, ...overrides }) + +/** Row ids in the order the transcript draws them, and each row's turn. */ +function drawn(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { + const journal = { items, submissions } + const { conversation } = projectNativeChatTranscript( + projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: false }), + undefined, + journal + ) + const { drawOrder, turnKeys } = nativeChatTurnMembership(conversation, journal) + const turnOf = new Map(conversation.map((message, index) => [message.id, turnKeys[index]])) + return nativeChatRowsInDrawOrder(conversation, drawOrder).map((message) => ({ + id: message.id, + turn: turnOf.get(message.id) + })) +} + +describe('a send a Stop took back before its echo', () => { + it('opens the turn the host names, not an earlier send taken back across it', () => { + // `queued` was sent first and taken back after the record too, but the host names `opener`. + // Both rows sit where they were taken back. + const items = [ + turn('t1', 3, 'codex:thread-1:t1:0'), + stopNote('t1', 4), + sent('queued', 5), + sent('opener', 6) + ] + const submissions = [ + stopped('queued', { submittedSequence: 1, answeredInTurn: null }), + stopped('opener', { + submittedSequence: 2, + answeredInTurn: { turnItemId: 't1', via: 'start' } + }) + ] + + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe(key('opener')) + expect(drawn(items, submissions)).toEqual([ + { id: key('opener'), turn: key('opener') }, + { id: 'stop:t1', turn: key('opener') }, + { id: key('queued'), turn: key('queued') }, + { id: `${STOPPED_ROW}${key('queued')}`, turn: undefined } + ]) + }) + + it('opens no turn whose echoed opener the record names, though the host says it started it', () => { + // A start Codex folded into a turn already running reads as `start`; the echo still decides. + const items = [ + sent('opener', 1), + turn('t1', 2, 'codex:thread-1:t1:0'), + stopNote('t1', 4), + sent('folded', 5) + ] + const submissions = [ + submission('opener', { submittedSequence: 1, providerItemId: 'codex:thread-1:t1:0' }), + stopped('folded', { + submittedSequence: 3, + answeredInTurn: { turnItemId: 't1', via: 'start' } + }) + ] + + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe(key('opener')) + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([ + key('opener'), + 'stop:t1', + key('folded'), + `${STOPPED_ROW}${key('folded')}` + ]) + }) + + it('opens no turn the provider resumed on its own, whose record names itself', () => { + // An older host's clock claim would hand this turn to a send sent before it and taken back after. + const items = [sent('never-ran', 1), turn('wake', 2, 'wake', 5)] + const submissions = [stopped('never-ran', { submittedAt: 4, resolvedAt: 10 })] + + expect(structuredAgentTurnAnchors(items, submissions).get('wake')).toBe('wake') + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([ + key('never-ran'), + `${STOPPED_ROW}${key('never-ran')}` + ]) + }) + + it('opens no turn the host states it named none for, though it was sent before the record and taken back after', () => { + // A page whose turn's echoed opener is on an older page: only the record is loaded. + // Its times would pass an older host's clock claim. + const items = [turn('t1', 3, 'codex:thread-1:t1:0', 5), stopNote('t1', 4), sent('queued', 5)] + const submissions = [ + stopped('queued', { + submittedSequence: 2, + submittedAt: 4, + resolvedAt: 10, + answeredInTurn: null + }) + ] + + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe('t1') + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([ + 'stop:t1', + key('queued'), + `${STOPPED_ROW}${key('queued')}` + ]) + }) + + it('pushes a send taken back inside the turn it opened past that turn, from where it is drawn', () => { + // `follow-up` was taken back between the record and the opener's own take-back. + const items = [ + turn('t1', 2, 'codex:thread-1:t1:0'), + sent('follow-up', 4), + stopNote('t1', 5), + sent('opener', 6) + ] + const submissions = [ + stopped('opener', { + submittedSequence: 1, + answeredInTurn: { turnItemId: 't1', via: 'start' } + }), + stopped('follow-up', { submittedSequence: 3, answeredInTurn: null }) + ] + + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([ + key('opener'), + 'stop:t1', + key('follow-up'), + `${STOPPED_ROW}${key('follow-up')}` + ]) + }) +}) + +// Rows a host wrote before it named turns carry neither the turn nor `null`. A newer host still +// publishes where each was sent and has moved its row to the take-back, so journal order places it. +describe('a send a Stop took back before its echo, in history written before the host named turns', () => { + it('opens the turn recorded after it was sent and before it was taken back', () => { + const items = [turn('t1', 2, 'codex:thread-1:t1:0'), stopNote('t1', 3), sent('opener', 4)] + const submissions = [stopped('opener', { submittedSequence: 1 })] + + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([key('opener'), 'stop:t1']) + }) + + it('opens it as the first sent, leaving a later send its own row after the turn', () => { + const items = [ + turn('t1', 3, 'codex:thread-1:t1:0'), + stopNote('t1', 4), + sent('opener', 5), + sent('follow-up', 6) + ] + const submissions = [ + stopped('follow-up', { submittedSequence: 2, submittedAt: 1 }), + stopped('opener', { submittedSequence: 1, submittedAt: 2 }) + ] + + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([ + key('opener'), + 'stop:t1', + key('follow-up'), + `${STOPPED_ROW}${key('follow-up')}` + ]) + }) + + it('opens no turn recorded before it was sent', () => { + const items = [turn('t1', 1, 'codex:thread-1:t1:0'), stopNote('t1', 3), sent('late', 4)] + const submissions = [stopped('late', { submittedSequence: 2 })] + + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe('t1') + }) +}) + +describe('a send a Stop took back that the host stated was answered into no turn', () => { + it('opens no turn the provider resumed on its own, as a Claude rejection reads', () => { + const items = [ + turn('wake', 2, 'wake', 5), + sent('never-ran', 3), + row( + 'wake-note', + 4, + { kind: 'status', text: 'Background task finished.' }, + { + kind: 'turn', + turnItemId: 'wake' + } + ) + ] + const submissions = [ + stopped('never-ran', { + submittedSequence: 1, + submittedAt: 4, + resolvedAt: 10, + answeredInTurn: null + }) + ] + + expect(structuredAgentTurnAnchors(items, submissions).get('wake')).toBe('wake') + expect(drawn(items, submissions).map(({ id }) => id)).toContain( + `${STOPPED_ROW}${key('never-ran')}` + ) + }) + + it('opens no turn a later Codex record names by key', () => { + const items = [turn('t1', 2, 'codex:thread-1:t1:0'), stopNote('t1', 3), sent('queued', 4)] + const submissions = [stopped('queued', { submittedSequence: 1, answeredInTurn: null })] + + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe('t1') + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([ + 'stop:t1', + key('queued'), + `${STOPPED_ROW}${key('queued')}` + ]) + }) + + it('opens no turn when it names a way of joining this build does not know', () => { + const items = [turn('t1', 2, 'codex:thread-1:t1:0'), stopNote('t1', 3), sent('later', 4)] + const submissions = [ + stopped('later', { + submittedSequence: 1, + // A newer host's value, which this build's type does not name. + answeredInTurn: JSON.parse('{"turnItemId":"t1","via":"resume"}') + }) + ] + + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe('t1') + }) +}) + +describe('a steer a Stop took back before its echo', () => { + const started = { turnItemId: 't1', via: 'start' } as const + const steered = { turnItemId: 't1', via: 'steer' } as const + + it('opens no turn whose echoed opener the record names, and is drawn after it', () => { + const items = [ + sent('opener', 1), + turn('t1', 2, 'codex:thread-1:t1:0'), + stopNote('t1', 4), + sent('steer', 5) + ] + const submissions = [ + submission('opener', { submittedSequence: 1, providerItemId: 'codex:thread-1:t1:0' }), + stopped('steer', { submittedSequence: 3, answeredInTurn: steered }) + ] + + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe(key('opener')) + expect(drawn(items, submissions)).toEqual([ + { id: key('opener'), turn: key('opener') }, + { id: 'stop:t1', turn: key('opener') }, + { id: key('steer'), turn: key('steer') }, + { id: `${STOPPED_ROW}${key('steer')}`, turn: undefined } + ]) + }) + + it('leaves the turn to the send that started it when both were taken back', () => { + const items = [ + turn('t1', 2, 'codex:thread-1:t1:0'), + stopNote('t1', 4), + sent('steer', 5), + sent('opener', 6) + ] + const submissions = [ + stopped('opener', { submittedSequence: 1, answeredInTurn: started }), + stopped('steer', { submittedSequence: 3, answeredInTurn: steered }) + ] + + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe(key('opener')) + expect(drawn(items, submissions)).toEqual([ + { id: key('opener'), turn: key('opener') }, + { id: 'stop:t1', turn: key('opener') }, + { id: key('steer'), turn: key('steer') }, + { id: `${STOPPED_ROW}${key('steer')}`, turn: undefined } + ]) + }) + + it('opens no turn whose echoed opener is on an older page, and is drawn after it', () => { + // Only the record is loaded: the opener's row and submission are on the older page. + const items = [turn('t1', 2, 'codex:thread-1:t1:0'), stopNote('t1', 4), sent('steer', 5)] + const submissions = [stopped('steer', { submittedSequence: 1, answeredInTurn: steered })] + + expect(structuredAgentTurnAnchors(items, submissions).get('t1')).toBe('t1') + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([ + 'stop:t1', + key('steer'), + `${STOPPED_ROW}${key('steer')}` + ]) + }) + + it('keeps its own row on a page that starts inside the turn it joined', () => { + const items = [stopNote('t1', 4), sent('steer', 5)] + const submissions = [stopped('steer', { submittedSequence: 1, answeredInTurn: steered })] + + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([ + 'stop:t1', + key('steer'), + `${STOPPED_ROW}${key('steer')}` + ]) + }) +}) + +describe('a send a Stop took back on a page that starts inside the turn it opened', () => { + it("is drawn before that turn's first loaded row, with no row of its own", () => { + const items = [stopNote('t1', 4), sent('opener', 5)] + const submissions = [ + stopped('opener', { + submittedSequence: 1, + answeredInTurn: { turnItemId: 't1', via: 'start' } + }) + ] + + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([key('opener'), 'stop:t1']) + }) + + it('keeps its own row when a loaded message of that turn opened it instead', () => { + const items = [ + sent('steered', 3, { kind: 'turn', turnItemId: 't1' }), + stopNote('t1', 4), + sent('late', 5) + ] + const submissions = [ + submission('steered', { submittedSequence: 3 }), + stopped('late', { submittedSequence: 2, answeredInTurn: { turnItemId: 't1', via: 'start' } }) + ] + + expect(drawn(items, submissions).map(({ id }) => id)).toEqual([ + key('steered'), + 'stop:t1', + key('late'), + `${STOPPED_ROW}${key('late')}` + ]) + }) +}) diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index 98c2d2c24f96..cd8e50e7af60 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -26,6 +26,8 @@ import { } from './native-chat-turn-grouping' import type { NativeChatRole } from './native-chat-types' import { isStructuredAgentSessionCommandTurn } from './structured-agent-session-command-entry' +import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' +import { inSendOrder } from './native-chat-send-order' import { liveStructuredAgentSessionTurnScope, runningStructuredAgentSessionTurnScope @@ -52,7 +54,8 @@ export type NativeChatTurnJournal = { * 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. + * it echoes the send — or one a Stop took back before that echo, which the host names as answered + * into the turn; with none, the turn anchors on its own record. */ export function structuredAgentTurnAnchors( items: readonly AgentJournalRenderItem[], @@ -78,6 +81,29 @@ export function structuredAgentTurnAnchors( .filter((submission) => submission.dispatchState === 'pending' && !submission.providerItemId) .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) + // A send a Stop took back after its turn opened but before the provider echoed it: the record + // still names the provider's key, and the host names the turn the send was answered into, or + // none (null). A rejection written before that field is placed by journal order instead. + const takenBack = submissions.filter( + (submission) => + !submission.providerItemId && + submission.queuedMessageId === undefined && + submission.resolvedAt !== null && + dispatchWasWithdrawn(submission) + ) + const itemsById = + takenBack.length > 0 ? new Map(items.map((item) => [item.itemId, item])) : undefined + const openedBy = stoppedTurnOpeners(takenBack) + const unstated = inSendOrder( + takenBack + .filter((submission) => submission.answeredInTurn === undefined) + .flatMap((submission) => { + const sent = itemsById?.get(agentJournalSubmissionKey(submission.clientMessageId)) + return sent ? [{ sent, submission }] : [] + }), + ({ submission }) => submission + ) + const claimed = new Set() const anchors = new Map() let precedingUserItemId: string | null = null let inFlightSinceLastTurn: string | null = null @@ -93,10 +119,34 @@ export function structuredAgentTurnAnchors( if (!turn || !isRootAgentJournalItem(item)) { continue } - anchors.set( + let anchor = anchorOf( item.itemId, - anchorOf(item.itemId, turn, userItemIds, aliases, precedingUserItemId, inFlightSinceLastTurn) + turn, + userItemIds, + aliases, + precedingUserItemId, + inFlightSinceLastTurn ) + const key = turn.userItemId + const unresolved = + key !== undefined && !userItemIds.has(key) && !userItemIds.has(aliases.get(key) ?? '') + // A record naming itself is a turn the provider resumed on its own: no send opened it. + if (unresolved && key !== item.itemId) { + const named = openedBy.get(item.itemId) + if (named !== undefined && itemsById?.has(named)) { + anchor = named + } else if (anchor === item.itemId) { + const opener = unstated.find( + ({ sent, submission }) => + !claimed.has(sent.itemId) && sentBeforeAndTakenBackAfter(item, turn, submission, sent) + ) + if (opener) { + claimed.add(opener.sent.itemId) + anchor = opener.sent.itemId + } + } + } + anchors.set(item.itemId, anchor) inFlightSinceLastTurn = null } // Only while running: an ended turn's tail would otherwise regroup when the next turn opens. @@ -109,6 +159,41 @@ export function structuredAgentTurnAnchors( return anchors } +/** For each turn a taken-back send started (`answeredInTurn.via` is `start`), that send's item id. + * One steered into a turn opened none, nor one joined in a way this build does not know. */ +export function stoppedTurnOpeners( + takenBack: readonly AgentJournalSubmission[] +): ReadonlyMap { + const openers = new Map() + for (const { answeredInTurn, clientMessageId } of takenBack) { + if (answeredInTurn?.via === 'start' && !openers.has(answeredInTurn.turnItemId)) { + openers.set(answeredInTurn.turnItemId, agentJournalSubmissionKey(clientMessageId)) + } + } + return openers +} + +/** For a rejection written before the host named turns: whether its send was sent before a turn + * record and taken back after it. By journal order where the host publishes where it was sent (it + * has moved the row to the take-back): kept for good, as old chats keep those rows, and it draws + * them exactly as before. Else, on a host that publishes neither field, by its row's place and by + * times, which a resume rewrites to whole seconds; Temporary, until a host version floor. */ +function sentBeforeAndTakenBackAfter( + record: AgentJournalRenderItem, + turn: AgentJournalTurnLifecycle, + submission: AgentJournalSubmission, + sent: AgentJournalRenderItem +): boolean { + if (submission.submittedSequence !== undefined) { + return submission.submittedSequence < record.sequence && record.sequence < sent.sequence + } + if (turn.startedAt === undefined || submission.resolvedAt === null) { + return false + } + const sentBefore = sent.sequence < record.sequence || submission.submittedAt <= turn.startedAt + return sentBefore && turn.startedAt <= submission.resolvedAt +} + function anchorOf( turnItemId: string, turn: AgentJournalTurnLifecycle, diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 9fe391485202..6b271cb122bd 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -240,6 +240,9 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { unsent?: true /** This client's send, made while the chat read Stopping, that the host has not recorded yet. */ sentWhileStopping?: true + /** A send a Stop took back (its submission withdrawn): no rail tick, as the conversation + * outline the host serves leaves it out. */ + stoppedBeforeStart?: 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-dispatch-rejection.ts b/src/shared/structured-agent-session-dispatch-rejection.ts index b02e1a66fe41..a67b76cd3b81 100644 --- a/src/shared/structured-agent-session-dispatch-rejection.ts +++ b/src/shared/structured-agent-session-dispatch-rejection.ts @@ -170,7 +170,7 @@ export function classifyDispatchRejection( return { category: KIND_CATEGORY[kind], verdict: KIND_VERDICT[kind], kind } } -/** A Stop withdrew it before it ran: it will not land, and only its sender can send it again. */ +/** A Stop withdrew it before it ran: it will not land, and only a person can send it again. */ export function dispatchWasWithdrawn( submission: Pick | undefined ): boolean { diff --git a/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts new file mode 100644 index 000000000000..73e93915dfd5 --- /dev/null +++ b/src/shared/structured-agent-session-message-projection-stopped-before-start.test.ts @@ -0,0 +1,750 @@ +// A send a Stop took back before the agent started it stays where it was sent, with one row after +// it saying so, on every client: the journal already holds the send and why it was refused. + +import { describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import type { + AgentJournalAnsweredTurn, + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnScope +} from './agent-session-journal-types' +import { + NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION, + NATIVE_CHAT_STOPPED_BEFORE_START_TEXT +} from './native-chat-stopped-before-start' +import { + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_WRITE_FAILED +} from './structured-agent-session-dispatch-rejection' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +import { + projectNativeChatTranscript, + projectNativeChatTranscriptMessages +} from './native-chat-transcript-projection' +import { nativeChatRowsInDrawOrder } from './native-chat-turn-grouping' +import { nativeChatTurnMembership } from './native-chat-turn-membership' + +let sequence = 0 + +function entry( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope = { kind: 'thread' } +): AgentJournalRenderItem { + sequence += 1 + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } +} + +// A client that draws no other rejected send in place; a send a Stop took back is drawn either way. +const NO_IN_PLACE = { rejectedInPlace: false } + +const said = (itemId: string, text: string, turnItemId: string) => + entry( + itemId, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] }, + { kind: 'turn', turnItemId } + ) + +const sent = (id: string, text: string) => + entry(agentJournalSubmissionKey(id), { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text }] + }) + +function submission( + clientMessageId: string, + overrides: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2, + ...overrides + } +} + +const stopped = (id: string, overrides: Partial = {}) => + submission(id, { dispatchState: 'rejected', reason: DISPATCH_REJECTED_CANCELLED, ...overrides }) + +/** Each row as id and role, in the order the transcript draws them. */ +function rows(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { + return projectNativeChatTranscriptMessages( + projectStructuredAgentSessionMessages(items, [], submissions, NO_IN_PLACE) + ).map((message) => ({ id: message.id, role: message.role })) +} + +const user = (id: string) => ({ id: agentJournalSubmissionKey(id), role: 'user' }) +const stopRow = (id: string) => ({ + id: `stopped-before-start:${agentJournalSubmissionKey(id)}`, + role: 'system' +}) + +/** A host that publishes where each send was sent, and one that predates it (the Temporary + * fallback). */ +const HOSTS = [ + ['a host that publishes where it was sent', true], + ['an older host', false] +] as const + +/** A host that publishes `submittedSequence` also moves a taken-back send's row to the row that + * took it back (`at`), in no turn, and states the turn it was answered into, null for none; an + * older host leaves the row where it was sent. */ +function takenBackOn( + published: boolean, + items: AgentJournalRenderItem[], + id: string, + at: number, + answeredInTurn: AgentJournalAnsweredTurn | null = null +): { items: AgentJournalRenderItem[]; sent: Partial } { + const key = agentJournalSubmissionKey(id) + const row = items.find((item) => item.itemId === key)! + if (!published) { + return { items, sent: {} } + } + const moved = items.map((item) => + item.itemId === key + ? { ...item, sequence: at, observedAt: at, turnScope: { kind: 'thread' as const } } + : item + ) + return { + items: moved.sort((left, right) => left.sequence - right.sequence), + sent: { + submittedSequence: row.sequence, + answeredInTurn + } + } +} + +describe('a send a Stop took back before the agent started it', () => { + it('stays where it was sent, with one row after it', () => { + const items = [sent('warm-up', 'warm up'), sent('never-ran', 'look around')] + + expect(rows(items, [submission('warm-up'), stopped('never-ran')])).toEqual([ + user('warm-up'), + user('never-ran'), + stopRow('never-ran') + ]) + const [, , row] = projectStructuredAgentSessionMessages( + items, + [], + [stopped('never-ran')], + NO_IN_PLACE + ) + expect(row?.blocks).toEqual([ + { + type: 'text', + text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT, + presentation: NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION + } + ]) + expect(row?.journalPosition).toEqual( + projectStructuredAgentSessionMessages(items, [], [stopped('never-ran')], NO_IN_PLACE)[1] + ?.journalPosition + ) + }) + + it('reads the same from the typed fact as from the older marker', () => { + const items = [sent('never-ran', 'look around')] + const typed = stopped('never-ran', { + reason: 'This message was withdrawn before the agent started it.', + rejection: { kind: 'cancelled' } + }) + + expect(rows(items, [typed])).toEqual(rows(items, [stopped('never-ran')])) + }) + + it('shares one row with the sends taken back right before it', () => { + const items = [sent('first', 'one'), sent('second', 'two')] + + expect(rows(items, [stopped('first'), stopped('second')])).toEqual([ + user('first'), + user('second'), + stopRow('second') + ]) + }) + + it('stays in the turn that opened for it, whose end already says it was stopped', () => { + const items = [ + sent('opened', 'look around'), + entry('turn-1', { + kind: 'turn', + turnId: 'turn-1', + state: 'interrupted', + outcome: 'cancellation', + userItemId: agentJournalSubmissionKey('opened') + }) + ] + + expect(rows(items, [stopped('opened')])).toEqual([user('opened')]) + }) + + // Codex reports the turn open before it echoes the send, so the record names the provider's key. + it('stays in the turn that opened for it before the provider echoed it', () => { + const items = [ + sent('opened', 'look around'), + entry('turn-1', { + kind: 'turn', + turnId: 'turn-1', + state: 'interrupted', + outcome: 'cancellation', + userItemId: 'codex:thread-1:turn-1:0', + startedAt: 5 + }) + ] + + expect(rows(items, [stopped('opened', { resolvedAt: 10 })])).toEqual([user('opened')]) + }) + + // A steer joined the running turn, and the host takes a rejected send out of its turn. It names + // the turn the steer joined, but it is drawn after that turn, with its own row, as before. + it.each(HOSTS)( + 'draws a steer it took back after the turn it joined, with its own row, on %s', + (_host, published) => { + const steered = [ + sent('first', 'look around'), + entry('t1', { + kind: 'turn', + turnId: 't1', + state: 'interrupted', + outcome: 'cancellation', + userItemId: agentJournalSubmissionKey('first') + }), + entry( + agentJournalSubmissionKey('steer'), + { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'and check the tests' }] + }, + { kind: 'turn', turnItemId: 't1' } + ), + entry( + 'stop:t1', + { kind: 'status', text: 'Cancellation requested.' }, + { kind: 'turn', turnItemId: 't1' } + ) + ] + const { items, sent: steer } = takenBackOn(published, steered, 'steer', sequence + 1, { + turnItemId: 't1', + via: 'steer' + }) + + expect(rows(items, [submission('first'), stopped('steer', steer)])).toEqual([ + user('first'), + { id: 'stop:t1', role: 'system' }, + user('steer'), + stopRow('steer') + ]) + } + ) + + // The host moved the send's row past its turn's record to the row that took it back, and names + // the turn it was answered into: it is still that turn's opener. + it('stays in the turn it opened when its row moved past that turn to the take-back', () => { + const opened = [ + sent('opened', 'look around'), + entry('turn-1', { + kind: 'turn', + turnId: 'turn-1', + state: 'interrupted', + outcome: 'cancellation', + userItemId: 'codex:thread-1:turn-1:0' + }), + entry( + 'stop:turn-1', + { kind: 'status', text: 'Cancellation requested.' }, + { kind: 'turn', turnItemId: 'turn-1' } + ) + ] + const { items, sent: position } = takenBackOn(true, opened, 'opened', sequence + 1, { + turnItemId: 'turn-1', + via: 'start' + }) + + expect(rows(items, [stopped('opened', position)])).toEqual([ + user('opened'), + { id: 'stop:turn-1', role: 'system' } + ]) + }) + + // A turn restored after a restart, whose start reads as before the Stop in whole seconds, but + // whose record the journal wrote after the row that took the send back. + it('keeps its row when the turn record came after the row that took it back', () => { + const before = [sent('never-ran', 'look around')] + sequence += 1 + const takenBackAt = sequence + before.push( + entry('restored', { + kind: 'turn', + turnId: 'turn-9', + state: 'completed', + userItemId: 'codex:thread-1:turn-9:0', + startedAt: 5 + }) + ) + const { items, sent: position } = takenBackOn(true, before, 'never-ran', takenBackAt) + + expect(rows(items, [stopped('never-ran', { resolvedAt: 10, ...position })])).toEqual([ + user('never-ran'), + stopRow('never-ran') + ]) + }) + + // A turn the provider opened on its own, recorded before the send was made: the send did not + // open it, though the take-back moved the send's row past its record. + it('keeps its row when the turn record came before it was sent', () => { + const before = [ + entry('wake', { + kind: 'turn', + turnId: 'wake', + state: 'completed', + userItemId: 'codex:thread-1:wake:0' + }), + sent('never-ran', 'look around') + ] + const { items, sent: position } = takenBackOn(true, before, 'never-ran', sequence + 1) + + expect(rows(items, [stopped('never-ran', position)])).toEqual([ + user('never-ran'), + stopRow('never-ran') + ]) + }) + + it('keeps its row when a turn starts only after the Stop took it back', () => { + const items = [ + sent('never-ran', 'look around'), + entry('wake', { + kind: 'turn', + turnId: 'wake', + state: 'completed', + userItemId: 'claude:wake', + startedAt: 20 + }), + said('wake-note', 'Checking the build.', 'wake') + ] + + expect(rows(items, [stopped('never-ran', { resolvedAt: 10 })])).toEqual([ + user('never-ran'), + stopRow('never-ran'), + { id: 'wake-note', role: 'assistant' } + ]) + }) + + it('is drawn after the turn it waited behind when it was never handed over', () => { + const items = [ + sent('first', 'first prompt'), + entry('t1', { + kind: 'turn', + turnId: 't1', + state: 'interrupted', + outcome: 'cancellation', + userItemId: agentJournalSubmissionKey('first') + }), + said('before', 'working on it', 't1'), + sent('queued', 'queued while t1 ran'), + said('after', 'more t1 output', 't1'), + entry( + 'note', + { kind: 'status', text: 'Cancellation requested.' }, + { kind: 'turn', turnItemId: 't1' } + ) + ] + + expect( + rows(items, [submission('first'), stopped('queued', { handoverRecorded: true })]) + ).toEqual([ + user('first'), + { id: 'before', role: 'assistant' }, + { id: 'after', role: 'assistant' }, + { id: 'note', role: 'system' }, + user('queued'), + stopRow('queued') + ]) + }) + + // A follow-up made before Codex opened the turn, then stopped: the turn opened for the first + // send, and the follow-up waited on it, so that turn's own rows come before the follow-up's. + it("is drawn after a turn that opened while it waited, with that turn's rows above it", () => { + const items = [ + sent('first', 'look around'), + sent('follow-up', 'and check the tests'), + entry('turn-1', { + kind: 'turn', + turnId: 'turn-1', + state: 'interrupted', + outcome: 'cancellation', + userItemId: 'codex:thread-1:turn-1:0', + startedAt: 5 + }), + entry( + 'stop:turn-1', + { kind: 'status', text: 'Cancellation requested.' }, + { kind: 'turn', turnItemId: 'turn-1' } + ) + ] + const submissions = [ + stopped('first', { resolvedAt: 10 }), + stopped('follow-up', { resolvedAt: 10 }) + ] + const journal = { items, submissions } + const { conversation } = projectNativeChatTranscript( + projectStructuredAgentSessionMessages(items, [], submissions, NO_IN_PLACE), + undefined, + journal + ) + const { drawOrder } = nativeChatTurnMembership(conversation, journal) + + expect(nativeChatRowsInDrawOrder(conversation, drawOrder).map((row) => row.id)).toEqual([ + agentJournalSubmissionKey('first'), + 'stop:turn-1', + agentJournalSubmissionKey('follow-up'), + `stopped-before-start:${agentJournalSubmissionKey('follow-up')}` + ]) + }) + + // Two sends queued during /compact: the first was handed over (its row moved to the handover), + // the second never was, and one Stop took both back. + it('keeps two sends it took back in the order they were sent', () => { + const compactTurn = 'compact-turn' + const items = [ + sent('compact', '/compact'), + entry(compactTurn, { + kind: 'turn', + turnId: compactTurn, + state: 'completed', + userItemId: agentJournalSubmissionKey('compact') + }), + sent('queued-second', 'queued second'), + said('compacted', 'Context compacted', compactTurn), + sent('queued-first', 'queued first') + ] + + expect( + rows(items, [ + submission('compact', { submittedAt: 1 }), + stopped('queued-first', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), + stopped('queued-second', { submittedAt: 3, handoverRecorded: true }) + ]) + ).toEqual([ + user('compact'), + { id: 'compacted', role: 'assistant' }, + user('queued-first'), + user('queued-second'), + stopRow('queued-second') + ]) + }) + + // The host's clock stepped back between the two accepts; the journal's order still holds. + it('keeps two sends in journal order where the host publishes it, whatever the clock says', () => { + const compactTurn = 'compact-turn' + const queued = [ + sent('compact', '/compact'), + entry(compactTurn, { + kind: 'turn', + turnId: compactTurn, + state: 'completed', + userItemId: agentJournalSubmissionKey('compact') + }), + sent('queued-second', 'queued second'), + said('compacted', 'Context compacted', compactTurn), + sent('queued-first', 'queued first') + ] + const accepted = queued.find( + (item) => item.itemId === agentJournalSubmissionKey('queued-second') + )!.sequence + // Taken back in the opposite order: the second's row lands first. + const second = takenBackOn(true, queued, 'queued-second', sequence + 1) + const first = takenBackOn(true, second.items, 'queued-first', sequence + 2) + + expect( + rows(first.items, [ + submission('compact', { submittedAt: 1 }), + stopped('queued-first', { + submittedAt: 9, + submittedSequence: accepted - 1, + handoverRecorded: true, + handedOverAt: 5 + }), + stopped('queued-second', { + submittedAt: 3, + submittedSequence: accepted, + handoverRecorded: true + }) + ]) + ).toEqual([ + user('compact'), + { id: 'compacted', role: 'assistant' }, + user('queued-first'), + user('queued-second'), + stopRow('queued-second') + ]) + }) + + // Three queued during /compact: the first ran and finished, the second was left in doubt, and the + // Stop took back only the third, which was never handed over. + it.each(HOSTS)( + 'stays below everything sent before it, a finished exchange included, on %s', + (_host, published) => { + const compactTurn = 'compact-turn' + const items = [ + sent('compact', '/compact'), + entry(compactTurn, { + kind: 'turn', + turnId: compactTurn, + state: 'completed', + userItemId: agentJournalSubmissionKey('compact') + }), + sent('queued-b', 'queued B'), + said('compacted', 'Context compacted', compactTurn), + sent('queued-zero', 'queued zero'), + sent('queued-a', 'queued A'), + entry('turn-2', { + kind: 'turn', + turnId: 'turn-2', + state: 'completed', + userItemId: agentJournalSubmissionKey('queued-zero') + }), + said('answer', 'answer to zero', 'turn-2') + ] + + const host = takenBackOn(published, items, 'queued-b', sequence + 1) + + expect( + rows(host.items, [ + submission('compact', { submittedAt: 1 }), + submission('queued-zero', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), + submission('queued-a', { + submittedAt: 3, + dispatchState: 'unknown', + handoverRecorded: true, + handedOverAt: 6 + }), + stopped('queued-b', { + submittedAt: 4, + handoverRecorded: true, + ...host.sent + }) + ]) + ).toEqual([ + user('compact'), + { id: 'compacted', role: 'assistant' }, + user('queued-zero'), + user('queued-a'), + { id: 'answer', role: 'assistant' }, + user('queued-b'), + stopRow('queued-b') + ]) + } + ) + + /** Two queued during /compact; the first handed over, its turn opened and streamed, and the Stop + * took back the second (never handed over) while that turn ran. */ + function streamingTurnThenStop() { + const compactTurn = 'compact-turn' + const before = [ + sent('compact', '/compact'), + entry(compactTurn, { + kind: 'turn', + turnId: compactTurn, + state: 'completed', + userItemId: agentJournalSubmissionKey('compact') + }), + sent('queued-second', 'queued second'), + said('compacted', 'Context compacted', compactTurn), + sent('queued-first', 'queued first'), + entry('turn-2', { + kind: 'turn', + turnId: 'turn-2', + state: 'interrupted', + outcome: 'cancellation', + userItemId: agentJournalSubmissionKey('queued-first') + }), + said('working', 'working on the first', 'turn-2') + ] + // The row that took the second back: a dispatch row, so no item sits on it. + sequence += 1 + const takenBackAt = sequence + const after = [ + entry( + 'stop:turn-2', + { kind: 'status', text: 'Cancellation requested.' }, + { kind: 'turn', turnItemId: 'turn-2' } + ) + ] + return { items: [...before, ...after], takenBackAt } + } + + it.each(HOSTS)( + "stays below the earlier send's whole exchange when that send opened the turn, on %s", + (_host, published) => { + const { items, takenBackAt } = streamingTurnThenStop() + + const host = takenBackOn(published, items, 'queued-second', takenBackAt) + + expect( + rows(host.items, [ + submission('compact', { submittedAt: 1 }), + submission('queued-first', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), + stopped('queued-second', { + submittedAt: 3, + handoverRecorded: true, + ...host.sent + }) + ]) + ).toEqual([ + user('compact'), + { id: 'compacted', role: 'assistant' }, + user('queued-first'), + { id: 'working', role: 'assistant' }, + { id: 'stop:turn-2', role: 'system' }, + user('queued-second'), + stopRow('queued-second') + ]) + } + ) + + // As the finished-exchange case without the send left in doubt. On an older host the latest row + // sent before it is that exchange's opener itself. + it.each(HOSTS)( + 'stays below a finished exchange whose opener was the last thing sent before it, on %s', + (_host, published) => { + const compactTurn = 'compact-turn' + const items = [ + sent('compact', '/compact'), + entry(compactTurn, { + kind: 'turn', + turnId: compactTurn, + state: 'completed', + userItemId: agentJournalSubmissionKey('compact') + }), + sent('queued-b', 'queued B'), + said('compacted', 'Context compacted', compactTurn), + sent('queued-zero', 'queued zero'), + entry('turn-2', { + kind: 'turn', + turnId: 'turn-2', + state: 'completed', + userItemId: agentJournalSubmissionKey('queued-zero') + }), + said('answer', 'answer to zero', 'turn-2') + ] + + const host = takenBackOn(published, items, 'queued-b', sequence + 1) + + expect( + rows(host.items, [ + submission('compact', { submittedAt: 1 }), + submission('queued-zero', { submittedAt: 2, handoverRecorded: true, handedOverAt: 5 }), + stopped('queued-b', { + submittedAt: 4, + handoverRecorded: true, + ...host.sent + }) + ]) + ).toEqual([ + user('compact'), + { id: 'compacted', role: 'assistant' }, + user('queued-zero'), + { id: 'answer', role: 'assistant' }, + user('queued-b'), + stopRow('queued-b') + ]) + } + ) + + // Accept times tie at the clock's resolution, and the client can hold the stopped send first: + // only its row, which the host moved to the take-back, says which exchange came before the Stop. + it('stays below a finished exchange when accept times tie, by the row the host moved', () => { + const compactTurn = 'compact-turn' + const items = [ + sent('compact', '/compact'), + entry(compactTurn, { + kind: 'turn', + turnId: compactTurn, + state: 'completed', + userItemId: agentJournalSubmissionKey('compact') + }), + sent('queued-b', 'queued B'), + said('compacted', 'Context compacted', compactTurn), + sent('queued-zero', 'queued zero'), + entry('turn-2', { + kind: 'turn', + turnId: 'turn-2', + state: 'completed', + userItemId: agentJournalSubmissionKey('queued-zero') + }), + said('answer', 'answer to zero', 'turn-2') + ] + + const host = takenBackOn(true, items, 'queued-b', sequence + 1) + expect( + rows(host.items, [ + stopped('queued-b', { handoverRecorded: true, ...host.sent }), + submission('compact'), + submission('queued-zero', { handoverRecorded: true, handedOverAt: 5 }) + ]) + ).toEqual([ + user('compact'), + { id: 'compacted', role: 'assistant' }, + user('queued-zero'), + { id: 'answer', role: 'assistant' }, + user('queued-b'), + stopRow('queued-b') + ]) + }) + + // The first taken back by one Stop, a later send by another: each stays where it was sent. + it('keeps a send a later Stop took back below one an earlier Stop took', () => { + const compactTurn = 'compact-turn' + const items = [ + sent('compact', '/compact'), + entry(compactTurn, { + kind: 'turn', + turnId: compactTurn, + state: 'completed', + userItemId: agentJournalSubmissionKey('compact') + }), + sent('queued', 'queued during compact'), + said('compacted', 'Context compacted', compactTurn), + sent('later', 'sent after the first Stop') + ] + + expect( + rows(items, [ + submission('compact', { submittedAt: 1 }), + stopped('queued', { submittedAt: 2, handoverRecorded: true }), + stopped('later', { submittedAt: 9, handoverRecorded: true, handedOverAt: 10 }) + ]) + ).toEqual([ + user('compact'), + { id: 'compacted', role: 'assistant' }, + user('queued'), + user('later'), + stopRow('later') + ]) + }) + + it('leaves a queued card to hold its own text', () => { + const items = [sent('card-send', 'from a card')] + + expect(rows(items, [stopped('card-send', { queuedMessageId: 'card-1' })])).toEqual([]) + }) + + it('keeps any other refused send out of the conversation', () => { + const items = [sent('refused', 'hello')] + + expect( + rows(items, [ + submission('refused', { dispatchState: 'rejected', reason: DISPATCH_REJECTED_WRITE_FAILED }) + ]) + ).toEqual([]) + }) +}) diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 733ab6e397d4..f308dc7e98af 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -2,6 +2,12 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-ses import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' import { collapseProviderRetryRuns } from './native-chat-provider-retry-runs' +import { + keepStoppedSendsInSendOrder, + placeStoppedSends, + withStopRowsAfterStoppedSends +} from './native-chat-stopped-before-start' +import { compareNativeChatTranscriptMessages } from './native-chat-transcript-projection' import type { NativeChatMessage } from './native-chat-types' import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' @@ -28,7 +34,7 @@ export function structuredAgentSessionCommandItemIds( /** * The rejected submissions the host's history shows in place as not sent, by item id; `submissions` - * in submission order, as the client keeps them. A withdrawn one went back to its sender, one the + * in submission order, as the client keeps them. A withdrawn one is drawn as a stopped send, one the * queue holds (a draft's hand-off, or a send kept as a card) is drawn as its card, and a command * such as `/compact` has its rejection reported as its own reply. */ @@ -36,7 +42,7 @@ export function structuredAgentSessionRejectedShownInPlace( submissions: readonly AgentJournalSubmission[], commandItemIds: ReadonlySet ): Set { - // Each body's copies, as positions in submission order. A withdrawn one is hidden too, so it + // Each body's copies, as positions in submission order. A withdrawn one is no failed copy, so it // supersedes nothing. const copies = new Map() for (const [index, submission] of submissions.entries()) { @@ -83,12 +89,26 @@ export function projectStructuredAgentSessionMessages( projectItems = projectStructuredItemsToNativeChat ): NativeChatMessage[] { const optimistic = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions, items) - // Refused sends are ledger evidence, not conversation history, unless drawn in place as not sent. + // A send a Stop took back before the agent started it stays where it was sent, as the + // conversation's own history. A queued card's hand-off is left out: the card holds its text. + const stoppedBeforeStart = new Map( + submissions + .filter( + (submission) => dispatchWasWithdrawn(submission) && submission.queuedMessageId === undefined + ) + .map((submission) => [agentJournalSubmissionKey(submission.clientMessageId), submission]) + ) + // Other refused sends are ledger evidence, not conversation history, unless drawn in place as + // not sent. const rejected = new Set( submissions .filter((submission) => submission.dispatchState === 'rejected') .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) + .filter((itemId) => !stoppedBeforeStart.has(itemId)) ) + // Runs on every streaming commit, so a chat with no such send pays nothing for placing one. + const placement = + stoppedBeforeStart.size > 0 ? placeStoppedSends(items, submissions, stoppedBeforeStart) : null const inPlace = options.rejectedInPlace ? structuredAgentSessionRejectedShownInPlace( submissions, @@ -114,16 +134,38 @@ export function projectStructuredAgentSessionMessages( ) const delivered: NativeChatMessage[] = [] const held: NativeChatMessage[] = [] + const shownStopped = new Set() + let moved = false for (const message of projectItems(visibleItems)) { if (queued.has(message.id)) { held.push({ ...message, queued: true }) - } else { + } else if (!placement || !stoppedBeforeStart.has(message.id)) { delivered.push(message) + } else { + const { opensTurn, position } = placement(message.id) + moved ||= position !== undefined + // A turn opened for it: that turn's interrupted end is its stop, so it gets no row of its own. + if (!opensTurn) { + shownStopped.add(message.id) + } + delivered.push({ + ...message, + stoppedBeforeStart: true, + ...(position ? { journalPosition: position } : {}) + }) } } + if (shownStopped.size > 0) { + moved = keepStoppedSendsInSendOrder(delivered, stoppedBeforeStart, shownStopped) || moved + } + const conversation = moved + ? Array.from(collapseProviderRetryRuns(delivered)).sort(compareNativeChatTranscriptMessages) + : collapseProviderRetryRuns(delivered) return [ // After the held sends leave: they are drawn after the conversation, never inside a run. - ...collapseProviderRetryRuns(delivered), + ...(shownStopped.size > 0 + ? withStopRowsAfterStoppedSends(conversation, shownStopped) + : conversation), ...held, // In no turn, like the outbox's not-sent rows; the journal position keeps their place. ...projectItems(unsentItems).map((message) => ({ ...message, unsent: true as const })), diff --git a/src/shared/structured-agent-session-outbox-reconcile.ts b/src/shared/structured-agent-session-outbox-reconcile.ts index abcd34d9d67c..30357ac1d06c 100644 --- a/src/shared/structured-agent-session-outbox-reconcile.ts +++ b/src/shared/structured-agent-session-outbox-reconcile.ts @@ -22,8 +22,7 @@ export function reconcileStructuredAgentSessionOutbox( // none changed: a caller re-reading every journal batch writes nothing then. const next = entries.flatMap((entry) => { const submission = settled.get(entry.clientMessageId) - // Settled by the host: its history shows one delivered; a withdrawn one goes back to the - // composer. + // Settled by the host: its history shows one delivered, and one a Stop took back in place. if (submission?.dispatchState === 'accepted' || dispatchWasWithdrawn(submission)) { return [] } diff --git a/src/shared/structured-agent-session-outbox-stop-withdrawal.ts b/src/shared/structured-agent-session-outbox-stop-withdrawal.ts index 5606ae0c9ceb..c418d0ed8386 100644 --- a/src/shared/structured-agent-session-outbox-stop-withdrawal.ts +++ b/src/shared/structured-agent-session-outbox-stop-withdrawal.ts @@ -50,7 +50,7 @@ function markedOutlivingStop( /** * What a Stop leaves in the outbox: nothing the journal does not already hold may go out after it, - * so every such entry goes, as a message the host withdraws leaves the chat. The send on its way + * so every such entry leaves it, as any send a Stop withdraws does. The send on its way * stays: it reaches the host ahead of the Stop, and it comes back from the host's answer, since * the agent may already have it. One waiting on Retry keeps it, and so does an issued queue send * that has gone out (a queued receipt or hand-off retires it against the published card), and it