diff --git a/mobile/src/session/MobileNativeChatFullContent.test.tsx b/mobile/src/session/MobileNativeChatFullContent.test.tsx new file mode 100644 index 00000000000..6788b5af2ee --- /dev/null +++ b/mobile/src/session/MobileNativeChatFullContent.test.tsx @@ -0,0 +1,157 @@ +import { createElement, useState } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { RpcClient } from '../transport/rpc-client' +import { MobileNativeChatItemSourceContext } from './mobile-native-chat-item-read' +import { useMobileNativeChatFullMessage } from './MobileNativeChatFullContent' +import { MobileNativeChatUserMessageFold } from './MobileNativeChatUserMessageFold' + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + Text: 'Text', + View: 'View', + StyleSheet: { create: (value: unknown) => value, hairlineWidth: 1 } +})) +const message: NativeChatMessage = { + id: 'message', + role: 'assistant', + source: 'transcript', + timestamp: 1, + shortened: true, + blocks: [{ type: 'text', text: 'head' }] +} +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only client identity is used; this fixture performs no RPC. +const first = {} as RpcClient +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only client identity is used; this fixture performs no RPC. +const second = {} as RpcClient +let renderer: ReactTestRenderer | undefined +afterEach(() => { + act(() => renderer?.unmount()) +}) +function Probe() { + return useMobileNativeChatFullMessage(message).showFull +} + +it.each(['client', 'session'] as const)( + 'keeps a pending or failed request with its original %s owner', + async (changed) => { + const pending = Promise.withResolvers() + const oldLoad = vi.fn(() => pending.promise) + const newLoad = vi.fn(async () => {}) + const source = (client: RpcClient, sessionId: string, load: () => Promise) => ({ + client, + sessionId, + messageDetail: { raw: () => message, state: () => 'available' as const, load } + }) + const render = (value: ReturnType) => + createElement(MobileNativeChatItemSourceContext.Provider, { value }, createElement(Probe)) + await act(async () => { + renderer = create(render(source(first, 'session', oldLoad))) + }) + const button = () => renderer?.root.findByProps({ accessibilityRole: 'button' }) + await act(async () => { + button()?.props.onPress() + }) + expect(button()?.props.disabled).toBe(true) + await act(async () => { + renderer?.update( + render( + source( + changed === 'client' ? second : first, + changed === 'session' ? 'other' : 'session', + newLoad + ) + ) + ) + }) + expect(button()?.props.disabled).toBe(false) + await act(async () => { + pending.reject(new Error('old host failed')) + await pending.promise.catch(() => {}) + }) + expect(JSON.stringify(renderer?.toJSON())).not.toContain("couldn't load") + await act(async () => { + button()?.props.onPress() + }) + expect(newLoad).toHaveBeenCalledOnce() + } +) + +it('opens and retries missing user text without losing local disclosure', async () => { + let text = 'head\n'.repeat(200) + const raw: NativeChatMessage = { ...message, role: 'user', blocks: [{ type: 'text', text }] } + let pending = Promise.withResolvers() + const load = vi.fn(() => + pending.promise.then(() => { + text += 'FULL TAIL' + }) + ) + const value = { + client: first, + sessionId: 'session', + messageDetail: { + raw: () => raw, + state: () => (text.endsWith('FULL TAIL') ? ('complete' as const) : ('available' as const)), + load + } + } + function UserProbe() { + const [open, setOpen] = useState(false) + const full = useMobileNativeChatFullMessage({ ...raw, blocks: [{ type: 'text', text }] }) + return ( + setOpen((held) => !held)} + recovery={full.recovery} + status={full.showFull} + > + {createElement('Text', null, text)} + + ) + } + await act(async () => { + renderer = create( + createElement(MobileNativeChatItemSourceContext.Provider, { value }, createElement(UserProbe)) + ) + }) + await act(async () => { + renderer?.root + .find((node) => typeof node.props.onLayout === 'function') + .props.onLayout({ nativeEvent: { layout: { height: 400 } } }) + }) + const buttons = () => renderer?.root.findAllByProps({ accessibilityRole: 'button' }) ?? [] + const press = async (index = 0) => { + await act(async () => { + buttons()[index].props.onPress() + }) + } + expect(buttons()).toHaveLength(1) + await press() + expect(buttons()[0].props.accessibilityState.expanded).toBe(true) + expect(load).toHaveBeenCalledOnce() + await act(async () => { + pending.reject(new Error('host unavailable')) + await pending.promise.catch(() => {}) + }) + expect(JSON.stringify(renderer?.toJSON())).toContain('head') + expect(JSON.stringify(renderer?.toJSON())).toContain("couldn't load") + pending = Promise.withResolvers() + await press(1) + expect(buttons()[0].props.accessibilityState.expanded).toBe(true) + await act(async () => { + pending.resolve() + await pending.promise + }) + expect(JSON.stringify(renderer?.toJSON())).toContain('FULL TAIL') + expect(buttons()).toHaveLength(1) + await press() + expect(buttons()[0].props.accessibilityState.expanded).toBe(false) + await press() + expect(buttons()[0].props.accessibilityState.expanded).toBe(true) + expect(load).toHaveBeenCalledTimes(2) +}) diff --git a/mobile/src/session/MobileNativeChatFullContent.tsx b/mobile/src/session/MobileNativeChatFullContent.tsx new file mode 100644 index 00000000000..17847a53a60 --- /dev/null +++ b/mobile/src/session/MobileNativeChatFullContent.tsx @@ -0,0 +1,191 @@ +import { useContext, useEffect, useState } from 'react' +import { Pressable, Text, View, type TextStyle } from 'react-native' +import { + truncateToolDetail, + MAX_TOOL_DETAIL_LENGTH +} from '../../../src/shared/native-chat-tool-summary' +import type { + NativeChatMessage, + NativeChatToolResultBlock +} from '../../../src/shared/native-chat-types' +import type { + AgentJournalBoundedPayload, + AgentJournalItemBody +} from '../../../src/shared/agent-session-journal-types' +import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection' +import { styles } from './mobile-native-chat-message-styles' +import { toolStyles } from './mobile-native-chat-tool-styles' +import { MobileNativeChatOwnedScrollView } from './MobileNativeChatOwnedScrollView' +import { + MobileNativeChatItemSourceContext, + readMobileNativeChatItem +} from './mobile-native-chat-item-read' + +// "Show full output" and "Show full message": a row the phone was sent a head of, read whole from +// the host that keeps it. State is the row's own, so a live revision never fights it. + +type Load = 'idle' | 'loading' | 'failed' + +function ShowFull({ + label, + load, + onPress +}: { + label: string + load: Load + onPress: () => void +}): React.JSX.Element { + return ( + + + {load === 'failed' ? `${label} — couldn't load, tap to retry` : label} + + + ) +} + +function outputOf(body: AgentJournalItemBody | null): AgentJournalBoundedPayload | null { + if (body?.kind === 'tool-call') { + return body.output ?? null + } + return body?.kind === 'diff' ? body.patch : null +} + +/** A tool's output in an opened line: its head, and the whole of it on request. */ +export function MobileNativeChatToolOutput({ + body, + type, + rootIdentity, + positionKey +}: { + body: NativeChatToolResultBlock + type: TextStyle + rootIdentity: string + positionKey: string +}): React.JSX.Element { + const source = useContext(MobileNativeChatItemSourceContext) + const { output, isError, fullOutputItemId } = body + const client = source?.client, + sessionId = source?.sessionId + const [expanded, setExpanded] = useState(false) + const [detail, setDetail] = useState<{ + body: typeof body + client: typeof client + sessionId: typeof sessionId + full?: AgentJournalBoundedPayload + load: Load + } | null>(null) + useEffect( + () => () => + setDetail((held) => + held?.body === body && held.client === client && held.sessionId === sessionId ? null : held + ), + [body, client, sessionId] + ) + const current = + detail?.body === body && detail.client === client && detail.sessionId === sessionId + ? detail + : null + const full = current?.full + const clipped = + !full && + (fullOutputItemId !== undefined || (!expanded && output.length > MAX_TOOL_DETAIL_LENGTH)) + const showFull = (): void => { + if (fullOutputItemId === undefined || source === null) { + setExpanded(true) + return + } + setDetail({ body, client, sessionId, load: 'loading' }) + const finish = (full?: AgentJournalBoundedPayload): void => { + setDetail((held) => + held?.body === body && held.client === client && held.sessionId === sessionId + ? { body, client, sessionId, full, load: full ? 'idle' : 'failed' } + : held + ) + } + readMobileNativeChatItem(source, fullOutputItemId) + .then((item) => + finish(item?.itemId === fullOutputItemId ? (outputOf(item.body) ?? undefined) : undefined) + ) + .catch(() => finish()) + } + return ( + + + + {full ? full.head : expanded ? output : truncateToolDetail(output)} + + + {full?.truncated ? ( + + Showing {new TextEncoder().encode(full.head).length} of {full.byteLength} bytes. + + ) : null} + {clipped ? ( + + ) : null} + + ) +} + +/** `message` as drawn: whole once the reader asked for a reply the phone was sent shortened. */ +export function useMobileNativeChatFullMessage(message: NativeChatMessage): { + shown: NativeChatMessage + showFull: React.JSX.Element | null + recovery: { load: Load; onPress: () => void } | null +} { + const source = useContext(MobileNativeChatItemSourceContext) + const detail = source?.messageDetail + const client = source?.client, + sessionId = source?.sessionId + const raw = detail?.raw(message.id) + const [request, setRequest] = useState<{ + raw: typeof raw + client: typeof client + sessionId: typeof sessionId + load: Load + } | null>(null) + const load = + request?.raw === raw && request?.client === client && request?.sessionId === sessionId + ? (request?.load ?? 'idle') + : 'idle' + const state = detail?.state(message.id) + const showFull = (): void => { + setRequest({ raw, client, sessionId, load: 'loading' }) + const finish = (load: Load): void => + setRequest((held) => + held?.raw === raw && held?.client === client && held?.sessionId === sessionId + ? { raw, client, sessionId, load } + : held + ) + void detail + ?.load(message.id) + .then(() => finish('idle')) + .catch(() => finish('failed')) + } + return { + shown: message, + recovery: state === 'available' ? { load, onPress: showFull } : null, + showFull: + state === 'partial' ? ( + Showing part of this message. + ) : state === 'available' ? ( + + ) : null + } +} diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index cea4d2baf74..c6d4e9a81b2 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -21,7 +21,10 @@ import type { import { MobileSourceCopyButton } from '../components/MobileSourceCopyButton' import { nativeChatMessagePlainText } from './mobile-native-chat-message-plain-text' import { deriveNativeChatRowContent } from '../../../src/shared/native-chat-row-content' -import { MobileNativeChatReasoningRow } from './MobileNativeChatReasoningRow' +import { + MobileNativeChatGroupedThought, + MobileNativeChatReasoningRow +} from './MobileNativeChatReasoningRow' import { mobileNativeChatRowId } from './MobileNativeChatScrollHold' import { nativeChatWorkRunEntries } from '../../../src/shared/native-chat-work-run' import { nativeChatReasoningDisclosureKey } from '../../../src/shared/native-chat-reasoning-row' @@ -43,11 +46,12 @@ import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status' import { styles } from './mobile-native-chat-message-styles' import { agentMessageAttribution } from './mobile-agent-message-attribution' import { MobileNativeChatVisualContext } from './mobile-native-chat-visual-context' +import { useMobileNativeChatFullMessage } from './MobileNativeChatFullContent' import { MobileNativeChatPacedProse } from './MobileNativeChatPacedProse' import { Prose } from './MobileNativeChatProse' function MobileNativeChatMessageImpl({ - message, + message: rowMessage, rowKey, runOwner, mayStillGrow = false, @@ -132,6 +136,7 @@ function MobileNativeChatMessageImpl({ userMessageOpen?: boolean onToggleUserMessage?: (messageId: string) => void }): React.JSX.Element { + const { shown: message, showFull, recovery } = useMobileNativeChatFullMessage(rowMessage) // Another agent's message is set apart from the person's bubble, left-aligned and named. const attribution = agentMessageAttribution('Message from', message.from) const isUser = message.role === 'user' && attribution === null @@ -151,10 +156,9 @@ function MobileNativeChatMessageImpl({ } const thought = (thoughtMessage: NativeChatMessage): React.JSX.Element => ( // Keyed by the thought, so it stays mounted as calls arrive after it. - + {showFull} ) : null} {turnStatusAbove ? null : statusRow} @@ -322,6 +327,8 @@ function MobileNativeChatMessageImpl({ fontScale={fontScale} open={userMessageOpen === true} onToggle={() => onToggleUserMessage?.(message.id)} + recovery={recovery} + status={showFull} > {proseRows(drawnProse.filter((block) => !isImageRefBlock(block)))} @@ -336,6 +343,7 @@ function MobileNativeChatMessageImpl({ {(block, index, shown) => proseRow(block, index, shown.text, shown.revealing)} )} + {isUser ? null : showFull} {askRow} {showToolRun ? ( { const React = await import('react') @@ -172,3 +176,82 @@ it("opens a grouped thought's own actions on a long press, not the run's first r }) expect(openActions).toHaveBeenCalledWith(thought) }) + +it('loads a shortened grouped thought from its own source between two tool rows', async () => { + const fullText = 'x'.repeat(99_991) + 'FULL TAIL' + const shortened: NativeChatMessage = { + ...thought, + shortened: true, + blocks: [{ type: 'text', text: fullText.slice(0, 70_000) }] + } + const item: AgentJournalRenderItem = { + itemId: thought.id, + sequence: 1, + revision: 2, + observedAt: 2_000, + body: { + kind: 'message', + role: 'reasoning', + state: 'completed', + completedAt: 2_000, + blocks: [{ type: 'text', text: fullText }] + } + } + const sendRequest = vi.fn(async () => ({ id: 'detail', ok: true as const, result: { item } })) + const client: RpcClient = { + sendRequest, + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => 0, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + const source = { client, sessionId: 'session' } + const messages = [read, shortened, command] + function Transcript() { + const detail = useMobileNativeChatMessageDetail(messages, source) + return createElement( + MobileNativeChatItemSourceContext.Provider, + { value: { ...source, messageDetail: detail.detail } }, + createElement(MobileNativeChatMessage, { + message: read, + workRun: detail.messages, + openReasoning: new Set([nativeChatReasoningDisclosureKey(thought.id)]), + onToggleReasoning: vi.fn(), + structuredActivityUi: true, + activeTurnIsWorking: false + }) + ) + } + await act(async () => { + renderer = create(createElement(Transcript)) + }) + const tree = renderer! + act(() => + tree.root + .find((node) => String(node.type) === 'Pressable' && node.props.accessibilityLiveRegion) + .props.onPress() + ) + expect(textIn(tree.root)).toContain('Read 1 file and ran 1 command') + const showFull = tree.root.find( + (node) => + String(node.type) === 'Pressable' && + node.props.accessibilityRole === 'button' && + textIn(node).includes('Show full message') + ) + await act(async () => { + showFull.props.onPress() + }) + expect(sendRequest).toHaveBeenCalledExactlyOnceWith( + 'agentSession.item', + { sessionId: source.sessionId, itemId: thought.id }, + expect.any(Object) + ) + const markdown = tree.root.find((node) => String(node.type) === 'MobileMarkdown') + expect(markdown.props.content).toBe(fullText) + expect(textIn(tree.root)).not.toContain('Show full message') + expect(textIn(tree.root)).toEqual(expect.arrayContaining(['Read', 'a.ts', 'pnpm test'])) +}) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index ca39a56db28..04a45600963 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -10,6 +10,8 @@ import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-str import { useMobileNativeChatComposerTray } from './use-mobile-native-chat-composer-tray' import { useMobileNativeChatVisualRenderer } from './MobileNativeChatVisual' import { MobileNativeChatVisualContext } from './mobile-native-chat-visual-context' +import { MobileNativeChatItemSourceContext } from './mobile-native-chat-item-read' +import { useMobileNativeChatMessageDetail } from './use-mobile-native-chat-message-detail' import { MobileNativeChatReplyRevealsContext, useMobileNativeChatReplyReveals @@ -70,8 +72,15 @@ export function MobileNativeChatOverlay({ const session = controller.nativeChatSession // The chat is drawn only with a resolved agent; the fallback never reaches the screen. const agent = controller.nativeChatAgent ?? '' + const fullContent = useMobileNativeChatMessageDetail( + session.messages, + controller.nativeChatItemSource + ) const folded = useMobileNativeChatFoldReuse( - useMemo(() => foldMobileNativeChatMessages(session.messages, agent), [session.messages, agent]), + useMemo( + () => foldMobileNativeChatMessages(fullContent.messages, agent), + [fullContent.messages, agent] + ), sendSurfaceId ) const streaming = useMobileNativeChatStreamingBubble( @@ -100,6 +109,11 @@ export function MobileNativeChatOverlay({ }) const visuals = useMobileNativeChatVisualRenderer(controller.nativeChatVisualSource) const replyReveals = useMobileNativeChatReplyReveals(folded, streaming !== null, sendSurfaceId) + const itemSource = controller.nativeChatItemSource + const detailSource = useMemo( + () => (itemSource ? { ...itemSource, messageDetail: fullContent.detail } : null), + [itemSource, fullContent.detail] + ) if (!controller.showNativeChat) { return null } @@ -107,75 +121,77 @@ export function MobileNativeChatOverlay({ - - void images.attachImage('library') - : undefined - } - attachments={images.attachments} - onRemoveAttachment={images.removeAttachment} - isAttaching={images.isAttaching} - onMicPress={onMicPress} - micActive={micActive} - dictationMode={dictationMode} - onMicPressIn={onMicPressIn} - onMicPressOut={onMicPressOut} - inputLockReason={inputLockReason} - sendErrorMessage={sendErrorMessage} - onClearSendError={onClearSendError} - filePaths={controller.nativeChatFilePaths} - onNeedFiles={controller.loadNativeChatFiles} - sessionOptions={controller.nativeChatSessionOptions} - structuredComposer={structuredComposer} - keyboardInset={keyboardInset} - /> - + + + void images.attachImage('library') + : undefined + } + attachments={images.attachments} + onRemoveAttachment={images.removeAttachment} + isAttaching={images.isAttaching} + onMicPress={onMicPress} + micActive={micActive} + dictationMode={dictationMode} + onMicPressIn={onMicPressIn} + onMicPressOut={onMicPressOut} + inputLockReason={inputLockReason} + sendErrorMessage={sendErrorMessage} + onClearSendError={onClearSendError} + filePaths={controller.nativeChatFilePaths} + onNeedFiles={controller.loadNativeChatFiles} + sessionOptions={controller.nativeChatSessionOptions} + structuredComposer={structuredComposer} + keyboardInset={keyboardInset} + /> + + diff --git a/mobile/src/session/MobileNativeChatReasoningRow.tsx b/mobile/src/session/MobileNativeChatReasoningRow.tsx index 07a0ce9b832..522b6aae334 100644 --- a/mobile/src/session/MobileNativeChatReasoningRow.tsx +++ b/mobile/src/session/MobileNativeChatReasoningRow.tsx @@ -9,11 +9,33 @@ import { nativeChatReasoningHeadlineText } from '../../../src/shared/native-chat-reasoning-row' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { deriveNativeChatRowContent } from '../../../src/shared/native-chat-row-content' +import { useMobileNativeChatFullMessage } from './MobileNativeChatFullContent' import { MobileMarkdown } from '../components/MobileMarkdown' import { colors } from '../theme/mobile-theme' import { MobileNativeChatLongPressContent } from './MobileNativeChatLongPressContent' import { CHAT_PROSE_SCALE, styles } from './mobile-native-chat-message-styles' +/** A grouped thought keeps its own source recovery when its original transcript row is hidden. */ +export function MobileNativeChatGroupedThought({ + message, + ...props +}: Omit, 'message' | 'markdown'> & { + message: NativeChatMessage +}): React.JSX.Element { + const { shown, showFull } = useMobileNativeChatFullMessage(message) + return ( + <> + + {showFull} + + ) +} + /** A reasoning row, collapsed to its headline; its text mounts only once opened. Desktop parity: * `NativeChatReasoningRow`. */ export function MobileNativeChatReasoningRow({ diff --git a/mobile/src/session/MobileNativeChatToolLine.tsx b/mobile/src/session/MobileNativeChatToolLine.tsx index d76bf9a9d66..744cdf735f3 100644 --- a/mobile/src/session/MobileNativeChatToolLine.tsx +++ b/mobile/src/session/MobileNativeChatToolLine.tsx @@ -16,7 +16,6 @@ import { NATIVE_CHAT_TOOL_ROW_VERB_COPY, nativeChatToolLineLabel } from '../../../src/shared/native-chat-tool-line-label' -import { truncateToolDetail } from '../../../src/shared/native-chat-tool-summary' import { isToolCallBlock, type NativeChatToolCallBlock, @@ -28,6 +27,7 @@ import { openExternalLink } from '../platform/external-link' import { colors } from '../theme/mobile-theme' import { highlightMobileCode } from './mobile-file-syntax' import { MobileNativeChatToolGlyph } from './MobileNativeChatToolIcon' +import { MobileNativeChatToolOutput } from './MobileNativeChatFullContent' import { useMobileNativeChatOpenedDetail } from './mobile-native-chat-opened-detail' import { useMobileNativeChatDisclosure } from './mobile-native-chat-disclosure-store' import { toolStyles as styles, toolType } from './mobile-native-chat-tool-styles' @@ -138,20 +138,12 @@ function ToolLineDetail({ ) : null} {body ? ( - - - {truncateToolDetail(body.output)} - - + ) : null} ) diff --git a/mobile/src/session/MobileNativeChatUserMessageFold.tsx b/mobile/src/session/MobileNativeChatUserMessageFold.tsx index e42c45ba2a9..aa4af99df52 100644 --- a/mobile/src/session/MobileNativeChatUserMessageFold.tsx +++ b/mobile/src/session/MobileNativeChatUserMessageFold.tsx @@ -7,6 +7,7 @@ import { import { styles as markdownStyles } from '../components/mobile-markdown-styles' import { onBrightColors, spacing, typography } from '../theme/mobile-theme' import { CHAT_PROSE_SCALE } from './mobile-native-chat-message-styles' +import type { useMobileNativeChatFullMessage } from './MobileNativeChatFullContent' const FOLDED_HEIGHT = NATIVE_CHAT_USER_MESSAGE_MAX_UNFOLDED_LINES * @@ -20,6 +21,8 @@ export function MobileNativeChatUserMessageFold({ fontScale, open, onToggle, + recovery, + status, children }: { text: string @@ -27,34 +30,49 @@ export function MobileNativeChatUserMessageFold({ /** The transcript's state, which outlives this row when the list recycles it. */ open: boolean onToggle: () => void + recovery?: ReturnType['recovery'] + status?: React.ReactNode children: React.ReactNode }): React.JSX.Element { const longText = useMemo(() => nativeChatUserMessageFolds(text), [text]) // The text rule can pick a message that already fits the fold; that one gets no toggle. const [height, setHeight] = useState(null) - if (!longText) { - return <>{children} - } const clip = FOLDED_HEIGHT * fontScale // Native text inside the clip is laid out to fit it, so a cut message measures at the clip, not // its full height: reaching it means cut (1pt for rounding). Unfolding on `>` looped. - const folds = height === null || height >= clip - 1 + const folds = longText && (height === null || height >= clip - 1) return ( <> - - setHeight(event.nativeEvent.layout.height)}>{children} - - {folds && height !== null ? ( + {longText ? ( + + setHeight(event.nativeEvent.layout.height)}>{children} + + ) : ( + children + )} + {(!longText || height !== null) && (folds || recovery) ? ( { + onToggle() + if (!open && recovery && recovery.load !== 'loading') { + recovery.onPress() + } + }} hitSlop={8} accessibilityRole="button" accessibilityState={{ expanded: open }} > - {open ? 'Show less' : 'Show full message'} + + {open + ? 'Show less' + : recovery?.load === 'failed' + ? "Show full message — couldn't load, tap to retry" + : 'Show full message'} + ) : null} + {!recovery || (open && recovery.load === 'failed') ? status : null} ) } diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index f99c233c22b..5f66db04627 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -42,6 +42,8 @@ export type MobileNativeChatController = { nativeChatStructured: boolean /** Structured lane with a live client: where the transcript's visuals are read from. */ nativeChatVisualSource: MobileNativeChatVisualSource | null + /** The same chat, where its host serves whole items; null before that host. */ + nativeChatItemSource: MobileNativeChatVisualSource | null nativeChatAgentWorking: boolean /** What labels the live turn's one indicator row; null off the structured lane. */ nativeChatTurnIndicator: NativeChatLiveTurnIndicator | null diff --git a/mobile/src/session/mobile-native-chat-item-read.ts b/mobile/src/session/mobile-native-chat-item-read.ts new file mode 100644 index 00000000000..e61753902ef --- /dev/null +++ b/mobile/src/session/mobile-native-chat-item-read.ts @@ -0,0 +1,50 @@ +import { createContext } from 'react' +import { z } from 'zod' +import { isAdmissibleAgentJournalRenderItem } from '../../../src/shared/agent-session-journal-schemas' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { rpcResultVariant } from '../transport/rpc-operation-result-reader' +import type { MobileNativeChatVisualSource } from './mobile-native-chat-visual-read' +import type { MobileNativeChatMessageDetail } from './use-mobile-native-chat-message-detail' + +// One chat item whole, for "Show full output" and "Show full message": every other read sends the +// phone a head of long text, and the host keeps the rest. + +const itemReplySchema = z.object({ + item: z.custom(isAdmissibleAgentJournalRenderItem).nullable() +}) + +const nativeChatItemRead = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'agentSession.item', + method: 'agentSession.item', + acceptance: 'object-result-or-null', + barrier: 'after-caller-barrier', + read: rpcResultVariant('native-chat-item', itemReplySchema) + }) +) + +const READ_TIMEOUT_MS = 30_000 + +/** Where whole items are read from; null where the host predates `agentSession.item`. */ +export const MobileNativeChatItemSourceContext = createContext< + (MobileNativeChatVisualSource & { messageDetail?: MobileNativeChatMessageDetail | null }) | null +>(null) + +/** The item's body as the host keeps it, fitted to a phone frame; null when the host no longer has + * it. Rejects when the host could not be asked or answered with an error. */ +export async function readMobileNativeChatItem( + source: MobileNativeChatVisualSource, + itemId: string +): Promise { + const response = await nativeChatItemRead.request( + source.client, + { sessionId: source.sessionId, itemId }, + { timeoutMs: READ_TIMEOUT_MS, budgetSpansConnect: true } + ) + const reply = nativeChatItemRead.interpret(response) + if (reply === null) { + throw new Error('The host could not read this item.') + } + return reply.item +} 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 index 2c0d9ebaf57..d4f35813b35 100644 --- 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 @@ -96,6 +96,7 @@ const HOST_SUPPORT = { sendOptions: false, conversationStop: false, permissionModes: false, + itemDetail: false, phoneAttachments: false } diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index 3739840de3f..0ef0c91a948 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -80,6 +80,15 @@ export const styles = StyleSheet.create({ color: colors.statusRed, fontSize: typography.metaSize }, + showFull: { + alignSelf: 'flex-start', + paddingTop: spacing.xs + }, + showFullText: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontWeight: '600' + }, imageRef: { color: colors.textSecondary, fontSize: TEXT_SIZE diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts index 355f576e3ff..a7bddb950ca 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.test.ts @@ -30,6 +30,7 @@ describe('structuredAgentSessionHostSupport', () => { sendOptions: false, conversationStop: false, permissionModes: false, + itemDetail: false, phoneAttachments: false } expect(structuredAgentSessionHostSupport([])).toEqual(none) diff --git a/mobile/src/session/mobile-structured-agent-session-host-support.ts b/mobile/src/session/mobile-structured-agent-session-host-support.ts index fd3fe34f54a..d3eb1f47e7c 100644 --- a/mobile/src/session/mobile-structured-agent-session-host-support.ts +++ b/mobile/src/session/mobile-structured-agent-session-host-support.ts @@ -1,5 +1,6 @@ import { AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, + AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY, AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_COMMANDS_RUNTIME_CAPABILITY, @@ -36,6 +37,8 @@ export type StructuredAgentSessionHostSupport = { conversationStop: boolean /** The host applies each chat's permission mode; 1.4.225–1.4.226 do without advertising it. */ permissionModes: boolean + /** The host keeps long output whole and serves it with `agentSession.item`. */ + itemDetail: boolean /** The phone may upload files into the host's chat attachment store. */ phoneAttachments: boolean } @@ -57,6 +60,7 @@ export function structuredAgentSessionHostSupport( sendOptions: capabilities.includes(AGENT_SESSION_SEND_OPTIONS_RUNTIME_CAPABILITY), conversationStop: capabilities.includes(AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY), permissionModes: capabilities.includes(AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY), + itemDetail: capabilities.includes(AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY), phoneAttachments: capabilities.includes(AGENT_SESSION_PHONE_ATTACHMENTS_RUNTIME_CAPABILITY) } } diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 04c711fd477..7d1b238bf79 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -289,6 +289,10 @@ export function useMobileNativeChatController(args: { /** Structured lane: drives the per-turn status row and live tool progress. */ nativeChatStructured: activeChatStructured, nativeChatVisualSource: activeChatStructured ? structuredNativeChat.visualSource : null, + nativeChatItemSource: + activeChatStructured && agentSessionHostSupport?.itemDetail === true + ? structuredNativeChat.visualSource + : null, nativeChatAgentWorking, nativeChatTurnIndicator: activeChatStructured ? structuredNativeChat.turnIndicator : null, nativeChatWorkingStartedAt: activeChatStructured ? structuredNativeChat.workingStartedAt : null, diff --git a/mobile/src/session/use-mobile-native-chat-message-detail.test.tsx b/mobile/src/session/use-mobile-native-chat-message-detail.test.tsx new file mode 100644 index 00000000000..236d2bc98b9 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-message-detail.test.tsx @@ -0,0 +1,90 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { RpcClient } from '../transport/rpc-client' +import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data' +import { useMobileNativeChatMessageDetail } from './use-mobile-native-chat-message-detail' + +const reads = vi.hoisted(() => vi.fn()) +vi.mock('./mobile-native-chat-item-read', () => ({ readMobileNativeChatItem: reads })) +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: RPC is mocked; only this client's object identity is used. +const source = { client: {} as RpcClient, sessionId: 'session' } +let latest: ReturnType | undefined +let renderer: ReactTestRenderer | undefined +function Probe({ messages }: { messages: NativeChatMessage[] }) { + latest = useMobileNativeChatMessageDetail(messages, source) + return null +} +afterEach(() => { + act(() => renderer?.unmount()) + reads.mockReset() +}) + +it('prepares full raw user rows with their neighboring images and fences late same-head detail', async () => { + const row = (id: string, text: string): NativeChatMessage => ({ + id, + role: 'user', + source: 'transcript', + timestamp: 1, + blocks: [{ type: 'text', text }] + }) + const image = row('image', '[Image: source: /repo/p.png]') + const prompt: NativeChatMessage = { ...row('prompt', '[Image #1] head…'), shortened: true } + const skill: NativeChatMessage = { + ...row( + 'skill', + '/plugin:examplehead…' + ), + shortened: true + } + const messages = [image, prompt, skill] + const item = (id: string, text: string, shortened = false): AgentJournalRenderItem => ({ + itemId: id, + sequence: 1, + observedAt: 1, + revision: 2, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }, + ...(shortened ? { shortened: true as const } : {}) + }) + await act(async () => { + renderer = create(createElement(Probe, { messages })) + }) + reads.mockResolvedValueOnce(item('prompt', '[Image #1] head TAIL')) + await act(async () => { + await latest?.detail?.load('prompt') + }) + const folded = foldMobileNativeChatMessages(latest?.messages ?? [], 'claude') + expect(folded[0]?.blocks).toEqual([ + { type: 'image-ref', path: '/repo/p.png' }, + { type: 'text', text: 'head TAIL' } + ]) + reads.mockResolvedValueOnce( + item( + 'skill', + '/plugin:examplefull', + true + ) + ) + await act(async () => { + await latest?.detail?.load('skill') + }) + expect(foldMobileNativeChatMessages(latest?.messages ?? [], 'claude')[1]?.blocks).toEqual([ + { type: 'text', text: '/example full' } + ]) + expect(latest?.detail?.state('skill')).toBe('partial') + const pending = Promise.withResolvers() + reads.mockReturnValueOnce(pending.promise) + const stale = latest?.detail?.load('prompt') + const newer = { ...prompt } + await act(async () => renderer?.update(createElement(Probe, { messages: [image, newer, skill] }))) + await act(async () => { + pending.resolve(item('prompt', 'STALE')) + await stale + }) + expect(latest?.messages[1]).toBe(newer) + reads.mockResolvedValueOnce(null) + await expect(latest?.detail?.load('prompt')).rejects.toThrow('Message unavailable') + expect(latest?.detail?.state('prompt')).toBe('available') +}) diff --git a/mobile/src/session/use-mobile-native-chat-message-detail.ts b/mobile/src/session/use-mobile-native-chat-message-detail.ts new file mode 100644 index 00000000000..fba81c189e8 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-message-detail.ts @@ -0,0 +1,84 @@ +import { useEffect, useMemo, useReducer } from 'react' +import { isTextBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' +import { projectStructuredItemToNativeChat } from '../../../src/shared/structured-agent-session-projection' +import { readMobileNativeChatItem } from './mobile-native-chat-item-read' +import type { MobileNativeChatVisualSource } from './mobile-native-chat-visual-read' + +export type MobileNativeChatMessageDetail = { + raw(id: string): NativeChatMessage | undefined + state(id: string): 'available' | 'partial' | 'complete' | null + load(id: string): Promise +} + +/** Substitute the original raw row; image, skill and tool preparation then run once as usual. */ +export function useMobileNativeChatMessageDetail( + messages: NativeChatMessage[], + source: MobileNativeChatVisualSource | null +): { messages: NativeChatMessage[]; detail: MobileNativeChatMessageDetail | null } { + const client = source?.client, + sessionId = source?.sessionId + const cache = useMemo( + () => ({ + live: true, + raw: new Set(), + entries: new Map() + }), + [client, sessionId] + ) + const [revision, refresh] = useReducer((revision: number) => revision + 1, 0) + useEffect(() => { + cache.live = true + return () => { + cache.live = false + cache.entries.clear() + } + }, [cache]) + useEffect(() => { + cache.raw = new Set(messages) + for (const raw of cache.entries.keys()) { + if (!cache.raw.has(raw)) { + cache.entries.delete(raw) + } + } + }, [cache, messages]) + const byId = useMemo(() => new Map(messages.map((message) => [message.id, message])), [messages]) + const raw = (id: string): NativeChatMessage | undefined => byId.get(id) + return { + messages: useMemo( + () => messages.map((message) => cache.entries.get(message) ?? message), + [cache, messages, revision] + ), + detail: + source === null + ? null + : { + raw, + state: (id) => { + const original = raw(id) + if (!original?.shortened || !original.blocks.some(isTextBlock)) { + return null + } + const loaded = cache.entries.get(original) + return loaded ? (loaded.shortened ? 'partial' : 'complete') : 'available' + }, + load: async (id) => { + const original = raw(id) + if (!original?.shortened || !original.blocks.some(isTextBlock)) { + throw new Error('Message unavailable') + } + const item = await readMobileNativeChatItem(source, id) + const projected = + item?.itemId === id && item.body.kind === 'message' + ? projectStructuredItemToNativeChat(item) + : null + if (!projected?.blocks.some(isTextBlock)) { + throw new Error('Message unavailable') + } + if (cache.live && cache.raw.has(original)) { + cache.entries.set(original, projected) + refresh() + } + } + } + } +} diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx index 478a4d1e121..ee97a0aaf1b 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -128,6 +128,7 @@ function Harness({ sendOptions: false, conversationStop: false, permissionModes: false, + itemDetail: false, phoneAttachments: false }, onSendError: vi.fn() diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts index 08d38dbce27..6d42a315860 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test-fixture.ts @@ -23,6 +23,7 @@ export const CAPABLE: StructuredAgentSessionHostSupport = { sendOptions: false, conversationStop: false, permissionModes: false, + itemDetail: false, phoneAttachments: false } export const LEGACY: StructuredAgentSessionHostSupport = { diff --git a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx index 33f358828f6..19fb71df6fd 100644 --- a/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx +++ b/mobile/src/session/use-mobile-structured-turn-indicator.test.tsx @@ -271,6 +271,7 @@ describe("useMobileStructuredAgentSession and the host's Stopping", () => { sendOptions: false, conversationStop: false, permissionModes: false, + itemDetail: false, phoneAttachments: false }, agent: 'codex', diff --git a/mobile/src/terminal/terminal-live-input-text-write.web.test.ts b/mobile/src/terminal/terminal-live-input-text-write.web.test.ts index 141e3edc5b0..b4773bcee63 100644 --- a/mobile/src/terminal/terminal-live-input-text-write.web.test.ts +++ b/mobile/src/terminal/terminal-live-input-text-write.web.test.ts @@ -107,7 +107,7 @@ describe('against a real react-native-web TextInput', () => { expect(field).toBeInstanceOf(HTMLInputElement) expect(field?.value).toBe('') } finally { - root.unmount() + await act(async () => root.unmount()) container.remove() } }) diff --git a/src/main/acp/acp-background-task-timeline.ts b/src/main/acp/acp-background-task-timeline.ts index 5b2a3c2f9f9..f489ff8a1a9 100644 --- a/src/main/acp/acp-background-task-timeline.ts +++ b/src/main/acp/acp-background-task-timeline.ts @@ -4,10 +4,6 @@ import { isSettledBackgroundTaskState } from '../../shared/native-chat-background-task-row' import type { NativeChatBackgroundTaskBlock } from '../../shared/native-chat-types' -import { - boundInlineText, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' import type { ProviderTimelineEvent, ProviderTimelineJoin @@ -42,12 +38,6 @@ export class AcpBackgroundTaskTimeline { label: update.label ?? previous?.label ?? fallbackLabel ?? update.taskId, state: update.state } - for (const key of ['label', 'summary', 'error', 'outputFile'] as const) { - const text = block[key] - if (text !== undefined) { - block[key] = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text - } - } if (isSettledBackgroundTaskState(block.state)) { this.running.delete(block.taskId) this.settled.set(block.taskId, block) diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index 1a5e92bcfe5..c36d286e5ef 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -1,8 +1,4 @@ import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' -import { - boundInlineText, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' import { acpWindowUsage } from './acp-context-usage' import type { AcpDialect } from './acp-dialects/acp-dialect' import type { AcpBackgroundTaskTimeline } from './acp-background-task-timeline' @@ -80,15 +76,12 @@ export function acpSessionUpdate( body: { kind: 'status', presentation: 'plan-document', - text: boundInlineText( - update.entries - .map( - (entry) => - `- [${entry.status === 'completed' ? 'x' : entry.status === 'in_progress' ? '~' : ' '}] ${entry.content}` - ) - .join('\n'), - DEFAULT_JOURNAL_PAYLOAD_LIMITS - ).text + text: update.entries + .map( + (entry) => + `- [${entry.status === 'completed' ? 'x' : entry.status === 'in_progress' ? '~' : ' '}] ${entry.content}` + ) + .join('\n') }, ...join } diff --git a/src/main/acp/acp-subagents.ts b/src/main/acp/acp-subagents.ts index e4b53b2f439..487fd38c7c9 100644 --- a/src/main/acp/acp-subagents.ts +++ b/src/main/acp/acp-subagents.ts @@ -14,10 +14,6 @@ import type { import { AGENT_CHILD_WORK_ID_MAX_LENGTH } from '../../shared/agent-status-child-work-value-guards' import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary' import type { NativeChatSubagentState } from '../../shared/native-chat-types' -import { - boundInlineText, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' import { boundSubagentLabel } from '../native-chat/agent-session-journal/journal-subagent-item' import type { ProviderTimelineJoin } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpSubagentUpdate } from './acp-dialects/acp-dialect' @@ -105,16 +101,15 @@ export class AcpSubagents { } private reply(id: string, subagent: KnownSubagent, text: string): AcpTimelineEvent[] { - const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text - if (subagent.reply === bounded) { + if (subagent.reply === text) { return [] } - subagent.reply = bounded + subagent.reply = text return [ { type: 'item.update', item: `subagent-result:${id}`, - body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: bounded }] }, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] }, producer: { agentId: id, producerKind: 'agent' }, join: subagent.placement } diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts index b7f6fc8a666..c1ce3116b92 100644 --- a/src/main/acp/acp-timeline-generic.test.ts +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -67,7 +67,7 @@ describe('generic ACP translation', () => { ).toEqual(['- [x] Read']) }) - it('merges tool snapshots, bounds input/output and emits a separately keyed valid diff', async () => { + it('merges tool snapshots, settles input and output whole and keys a valid diff', async () => { const { rig, translator, apply, update } = await genericRig() apply(translator.openPrompt('send-1', 1000).events) const oldText = 'same\nbefore\n' @@ -104,8 +104,9 @@ describe('generic ACP translation', () => { expect(body).toMatchObject({ name: 'edit_file', state: 'completed', - input: { truncated: true }, - output: { truncated: true, byteLength: 40000 } + // The input came only while the call ran; the settled row still keeps it whole. + input: { text: 'x'.repeat(40000) }, + output: { head: 'y'.repeat(40000), truncated: false, byteLength: 40000 } }) const diff = (await rig.rows()).find((row) => row.body.kind === 'diff')?.body if (diff?.kind !== 'diff') { diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts index 842eafd2af9..19a66bc6ed3 100644 --- a/src/main/acp/acp-timeline-requests.ts +++ b/src/main/acp/acp-timeline-requests.ts @@ -1,8 +1,4 @@ import { z } from 'zod' -import { - boundInlineText, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpDialect, AcpRequestPresentation } from './acp-dialects/acp-dialect' import type { AcpToolTimeline } from './acp-tool-timeline' @@ -102,7 +98,7 @@ export function translateAcpRequest( body: { kind: 'status', presentation: 'plan-document', - text: boundInlineText(plan, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + text: plan }, join } diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts index f6b4a22df2f..7134d4caaee 100644 --- a/src/main/acp/acp-tool-timeline.ts +++ b/src/main/acp/acp-tool-timeline.ts @@ -7,9 +7,10 @@ import { MAX_PROVIDER_TIMELINE_OPEN_ENTRIES } from '../native-chat/agent-session-timeline/provider-timeline-budget' import { - boundPayload, boundToolInput, - DEFAULT_JOURNAL_PAYLOAD_LIMITS + JOURNAL_RUNNING_PREVIEW_LIMITS, + journalPayload, + journalToolPayload } from '../native-chat/agent-session-journal/journal-payload-bounds' import type { ProviderTimelineEvent, @@ -88,6 +89,18 @@ export class AcpToolTimeline { private readonly turns = new BoundedMap({ maxEntries: MAX_PROVIDER_TIMELINE_OPEN_ENTRIES }) + // One latest snapshot per live call; the closing event takes ownership. Bytes are a ledger, + // not an RSS ceiling: provider frame limits and the live-entry count bound this finite carry. + private readonly whole = new BoundedMap({ + maxEntries: MAX_PROVIDER_TIMELINE_OPEN_ENTRIES, + sizeOf: (value, key) => value.inputBytes + value.outputBytes + Buffer.byteLength(key) + 64 + }) + + get retainedToolBytes(): number { + return this.whole.retainedBytes + } translate( raw: ToolCallUpdate, @@ -96,7 +109,10 @@ export class AcpToolTimeline { ): ProviderTimelineEvent[] { const update = dialect.normalizeToolUpdate?.(raw) ?? raw const previous = this.tools.get(update.toolCallId) - const output = outputText(update) + const held = this.whole.get(update.toolCallId) + const input = update.rawInput === undefined ? held?.input : update.rawInput + const suppliedOutput = outputText(update) + const output = suppliedOutput ?? held?.output const exitCode = rawExitCodeSchema.safeParse(update.rawOutput).data?.exitCode const state = update.status === 'completed' @@ -106,6 +122,22 @@ export class AcpToolTimeline { : update.status === 'pending' || update.status === 'in_progress' ? 'running' : (previous?.body.state ?? 'running') + if (state === 'running') { + if (!held && this.whole.size >= MAX_PROVIDER_TIMELINE_OPEN_ENTRIES) { + throw new Error('ACP tool stream capacity exceeded') + } + if (!this.whole.set(update.toolCallId, { + input, output, + inputBytes: update.rawInput === undefined ? (held?.inputBytes ?? 0) + : Buffer.byteLength(JSON.stringify(input) ?? ''), + outputBytes: suppliedOutput === undefined ? (held?.outputBytes ?? 0) + : Buffer.byteLength(JSON.stringify(output) ?? '') + })) { + throw new Error('ACP tool snapshot could not be retained') + } + } else { + this.whole.delete(update.toolCallId) + } const body: AgentJournalToolCallItem = { kind: 'tool-call', callId: update.toolCallId, @@ -117,16 +149,25 @@ export class AcpToolTimeline { update.kind ?? 'tool', input: - update.rawInput === undefined + input === undefined ? (previous?.body.input ?? null) - : boundToolInput(update.rawInput, DEFAULT_JOURNAL_PAYLOAD_LIMITS), + : state === 'running' + ? update.rawInput === undefined && previous + ? previous.body.input + : boundToolInput(input, JOURNAL_RUNNING_PREVIEW_LIMITS) + : input, state, ...(exitCode === undefined ? {} : { exitCode }), ...(output === undefined ? previous?.body.output ? { output: previous.body.output } : {} - : { output: boundPayload(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) }) + : { + output: + state === 'running' && suppliedOutput === undefined && previous?.body.output + ? previous.body.output + : journalToolPayload(output, state === 'running') + }) } const turn = join.turn ?? this.turn(update.toolCallId) this.turns.delete(update.toolCallId) @@ -147,9 +188,8 @@ export class AcpToolTimeline { body: { kind: 'diff', path: content.path, - patch: boundPayload( - boundedDiffPatch(content.path, content.oldText ?? '', content.newText), - DEFAULT_JOURNAL_PAYLOAD_LIMITS + patch: journalPayload( + boundedDiffPatch(content.path, content.oldText ?? '', content.newText) ) }, join @@ -175,6 +215,7 @@ export class AcpToolTimeline { if (snapshot.turn === turn) { this.bytes -= this.size(key, snapshot) this.retire(key, snapshot) + this.whole.delete(key) } } } diff --git a/src/main/claude/claude-child-work-evidence-retention.test.ts b/src/main/claude/claude-child-work-evidence-retention.test.ts index 257710e2c02..efe8cc1d8a4 100644 --- a/src/main/claude/claude-child-work-evidence-retention.test.ts +++ b/src/main/claude/claude-child-work-evidence-retention.test.ts @@ -78,12 +78,15 @@ describe('Claude child operation output retention', () => { if (result?.kind !== 'tool-call') { throw new Error('Result did not produce a journal tool-call body') } - expect(result.output).toEqual({ - head: 'a'.repeat(16 * 1024), - byteLength: first.length + second.length + 1, - digest: createHash('sha256').update(first).update('\n').update(second).digest('hex'), - truncated: true - }) + expect(result.output?.truncated).toBe(false) + expect(result.output?.byteLength).toBe(first.length + second.length + 1) + expect(result.output?.digest).toBe( + createHash('sha256').update(first).update('\n').update(second).digest('hex') + ) + expect(result.output?.head.length).toBe(first.length + second.length + 1) + expect(result.output?.head.startsWith(first)).toBe(true) + expect(result.output?.head.charAt(first.length)).toBe('\n') + expect(result.output?.head.endsWith(second)).toBe(true) expect(joinedOutputUnits).toBe(0) } finally { vi.restoreAllMocks() diff --git a/src/main/claude/claude-informational-row.ts b/src/main/claude/claude-informational-row.ts index 2b4b28be278..0a8e113a7de 100644 --- a/src/main/claude/claude-informational-row.ts +++ b/src/main/claude/claude-informational-row.ts @@ -2,10 +2,6 @@ // own words; `info`, `notice` (a hook's systemMessage) and `suggestion` are session chrome, not chat rows. import type { AgentJournalStatusItem } from '../../shared/agent-session-journal-types' -import { - boundInlineText, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' import { claudeText } from './claude-structured-item-translation' export const CLAUDE_INFORMATIONAL_FRAME_KIND = 'message:system:informational' @@ -19,7 +15,7 @@ export function claudeInformationalRowBody( ? { kind: 'status', tone: 'warning', - text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + text } : null } diff --git a/src/main/claude/claude-structured-item-translation.ts b/src/main/claude/claude-structured-item-translation.ts index e482c0fe901..e9e8cd69d9d 100644 --- a/src/main/claude/claude-structured-item-translation.ts +++ b/src/main/claude/claude-structured-item-translation.ts @@ -4,10 +4,7 @@ import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' -import { - boundInlineText, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { journalPayload } from '../native-chat/agent-session-journal/journal-payload-bounds' export type ClaudeMessageEnvelope = { sessionId: string @@ -186,9 +183,7 @@ export function claudeToolBody(input: { input: input.tool.input, callId: input.tool.id, state: input.result ? (input.result.failed ? 'failed' : 'completed') : 'running', - ...(input.result - ? { output: boundInlineText(input.result.output, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded } - : {}) + ...(input.result ? { output: journalPayload(input.result.output) } : {}) } } diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 7a9a8d64f56..126e2e5a451 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -15,10 +15,6 @@ import { createDeferredStructuredAgentSessionEventSink, type StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { - boundInlineText, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' import type { ClaudePendingPrompt } from './claude-structured-prompt-replies' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' @@ -622,7 +618,7 @@ describe('Claude structured journal translation', () => { expect(lifecycleAppends(state.items).at(-1)).toEqual(['turn-lifecycle:user-1', 'completed']) }) - it('bounds persisted thinking text to the shared journal payload limit', () => { + it('saves long thinking text whole', () => { const state = sinkState() const translator = createClaudeJournalTranslator({ sink: state.sink }) const thinking = 'considering '.repeat(20_000) @@ -636,9 +632,7 @@ describe('Claude structured journal translation', () => { expect(reasoning?.body).toEqual({ kind: 'message', role: 'reasoning', - blocks: [ - { type: 'text', text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } - ], + blocks: [{ type: 'text', text: thinking }], state: 'completed' }) }) diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index 53da9cab6f1..96f557aa4c9 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -4,10 +4,7 @@ import type { } from '../../shared/agent-session-journal-types' import type { AgentSessionFailureRowWords } from '../../shared/agent-session-failure-words' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' -import { - boundInlineText, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { PROVIDER_FRAME_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' import { CLAUDE_STREAM_JSON_FRAME_KINDS } from '../native-chat/agent-session-wire/claude-stream-json-frame-schema' import { type UnhandledProviderFrameJournalItemOptions, @@ -188,17 +185,13 @@ export function createClaudeProviderFrameFallback( 'claude', kind, payload, - DEFAULT_JOURNAL_PAYLOAD_LIMITS, + PROVIDER_FRAME_PAYLOAD_LIMITS, options ) if (!translated) { return false } beforeAppend?.() - const bounded = - typeof display === 'string' && display - ? boundInlineText(display, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text - : null const identity = { provider: 'orca', clientMessageId: `provider-frame:claude:${acquisitionId}:${sequence}` @@ -206,8 +199,8 @@ export function createClaudeProviderFrameFallback( const body: AgentJournalStatusItem = display && typeof display === 'object' ? { ...translated.body, ...display } - : bounded - ? { ...translated.body, text: bounded } + : typeof display === 'string' && display + ? { ...translated.body, text: display } : translated.body sink.appendItem(identity, body, stamp?.(identity, body) ?? { turnScope: turnScope() }) sink.publish() diff --git a/src/main/claude/claude-thread-goal.ts b/src/main/claude/claude-thread-goal.ts index d049b66d834..d9a86adabdc 100644 --- a/src/main/claude/claude-thread-goal.ts +++ b/src/main/claude/claude-thread-goal.ts @@ -8,10 +8,6 @@ import type { AgentJournalItemIdentity, AgentJournalThreadGoal } from '../../shared/agent-session-journal-types' -import { - boundInlineText, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' import { claudeRecord, claudeText } from './claude-structured-item-translation' import { isRootClaudeFrame } from './claude-turn-opening' import type { ClaudeGoalBackgroundWork } from './claude-background-task-tracker' @@ -58,15 +54,7 @@ function goalRow(text: string, goal: AgentJournalThreadGoal | null): GoalStatusB return { kind: 'status', text, - threadGoal: goal - ? { - state: 'set', - goal: { - ...goal, - objective: boundInlineText(goal.objective, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text - } - } - : { state: 'cleared' } + threadGoal: goal ? { state: 'set', goal } : { state: 'cleared' } } } diff --git a/src/main/codex/codex-child-work-translation-retention.test.ts b/src/main/codex/codex-child-work-translation-retention.test.ts index 7ab4e135a10..b895cd5bde0 100644 --- a/src/main/codex/codex-child-work-translation-retention.test.ts +++ b/src/main/codex/codex-child-work-translation-retention.test.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto' import { describe, expect, it, vi } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' @@ -10,10 +9,6 @@ import { } from './codex-child-work-translation' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import { CodexSubagentExecutions } from './codex-subagent-executions' -import { - DEFAULT_JOURNAL_PAYLOAD_LIMITS, - journalTruncationMarker -} from '../native-chat/agent-session-journal/journal-payload-bounds' describe('Codex child message preview ownership', () => { it('owns only the preview after journaling a large completed child message, through its ending', () => { @@ -101,18 +96,11 @@ describe('Codex child message preview ownership', () => { } } ]) - const digest = createHash('sha256').update(text, 'utf8').digest('hex') + // The translator hands the journal the whole message; the sink applies the storage ceiling. expect(rows).toContainEqual({ kind: 'message', role: 'assistant', - blocks: [ - { - type: 'text', - text: - 'a'.repeat(DEFAULT_JOURNAL_PAYLOAD_LIMITS.inlineHeadBytes) + - journalTruncationMarker(text.length, digest) - } - ] + blocks: [{ type: 'text', text }] }) evidence.length = 0 send('turn/completed', 'child', { diff --git a/src/main/codex/codex-collab-agent-item-translation.ts b/src/main/codex/codex-collab-agent-item-translation.ts index f12d22b7339..9ffc50368c3 100644 --- a/src/main/codex/codex-collab-agent-item-translation.ts +++ b/src/main/codex/codex-collab-agent-item-translation.ts @@ -6,11 +6,7 @@ import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import { CODEX_COLLAB_ROW_AGENTS_KEY } from '../../shared/codex-collab-agent-tools' -import { - boundInlineText, - boundToolInput, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { journalPayload } from '../native-chat/agent-session-journal/journal-payload-bounds' import { codexCollabHelperLabel, codexCollabToolName, @@ -106,16 +102,8 @@ function outputText(call: CodexCollabAgentToolCall, helperName?: CodexHelperName .join('\n') } -/** The prompt is clipped on its own: bounded with the rest, a long one would clip away the - * helper's name and ids the row is read by. */ function collabRowInput(fields: Record, prompt: string | null): unknown { - const bounded = boundToolInput(fields, DEFAULT_JOURNAL_PAYLOAD_LIMITS) - if (bounded !== fields) { - return bounded - } - const input = prompt - ? { ...fields, prompt: boundInlineText(prompt, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } - : fields + const input = prompt ? { ...fields, prompt } : fields return Object.keys(input).length > 0 ? input : null } @@ -142,13 +130,12 @@ export function codexCollabAgentToolCallBody( : {}) } const text = outputText(call, helperName) - const output = text === null ? null : boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS) return { kind: 'tool-call', name: codexCollabToolName(call), callId: call.id, input: collabRowInput(fields, call.prompt), state: codexItemRunState(item), - ...(output === null ? {} : { output: output.bounded }) + ...(text === null ? {} : { output: journalPayload(text) }) } } diff --git a/src/main/codex/codex-collab-call-delegation.test.ts b/src/main/codex/codex-collab-call-delegation.test.ts index 96131248ddc..d3d2cf96a07 100644 --- a/src/main/codex/codex-collab-call-delegation.test.ts +++ b/src/main/codex/codex-collab-call-delegation.test.ts @@ -88,7 +88,7 @@ describe("a Codex default-mode collab call row as the parent's delegation", () = ).toBeNull() }) - it('keeps naming the helper when its prompt is past the journal limit', async () => { + it('keeps naming the helper and saves a long prompt whole', async () => { const prompt = `Review lane a. ${'x'.repeat(20_000)}` const longSpawn = [ turn('turn/started', THREAD_ID, PARENT_TURN), @@ -106,7 +106,7 @@ describe("a Codex default-mode collab call row as the parent's delegation", () = const input = row?.kind === 'tool-call' ? row.input : undefined expect(input).toMatchObject({ description: expect.stringMatching(/^Review lane a\. x+…$/), - prompt: expect.stringContaining('[Orca: output truncated') + prompt }) expect(codexCollabRowAgentIds(input)).toEqual([HELPER]) expect(await newestRunDelegation(longSpawn)).toEqual({ kind: 'call', agentId: HELPER }) diff --git a/src/main/codex/codex-image-item-translation.ts b/src/main/codex/codex-image-item-translation.ts index 7d5e70a0c51..de993b467cc 100644 --- a/src/main/codex/codex-image-item-translation.ts +++ b/src/main/codex/codex-image-item-translation.ts @@ -1,12 +1,11 @@ import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' import { buildImageDataUri } from '../../shared/image-data-uri' -import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' import { readString } from './codex-item-field-readers' import type { CodexThreadItem } from './codex-thread-item-identity' // Leave room for operation text and the journal envelope beside inline image bytes. -const MAX_IMAGE_REFERENCE_BYTES = DEFAULT_JOURNAL_PAYLOAD_LIMITS.inlineHeadBytes / 2 +const MAX_IMAGE_REFERENCE_BYTES = 8 * 1024 function imagePath(item: CodexThreadItem, key: string): string | null { const value = readString(item, key) diff --git a/src/main/codex/codex-provider-retry-row.ts b/src/main/codex/codex-provider-retry-row.ts index 48b0c8eef40..070b33d9a94 100644 --- a/src/main/codex/codex-provider-retry-row.ts +++ b/src/main/codex/codex-provider-retry-row.ts @@ -12,7 +12,7 @@ import type { AgentJournalStatusItem } from '../../shared/agent-session-journal- import { TUI_AGENT_DISPLAY_NAMES } from '../../shared/tui-agent-display-names' import { boundPayload, - DEFAULT_JOURNAL_PAYLOAD_LIMITS + PROVIDER_FRAME_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import { @@ -56,7 +56,7 @@ export function codexProviderRetryRowBody(payload: unknown): AgentJournalStatusI providerFrame: { provider: 'codex', kind: 'notification:error', - payload: boundPayload(JSON.stringify(payload), DEFAULT_JOURNAL_PAYLOAD_LIMITS) + payload: boundPayload(JSON.stringify(payload), PROVIDER_FRAME_PAYLOAD_LIMITS) } } } diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index 45a6ca6e9df..c47cdfce560 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -515,14 +515,14 @@ describe('codex item bodies', () => { state: 'completed', output: { byteLength: 1_100_000, - truncated: true, + truncated: false, digest: expect.any(String) } }) if (body?.kind !== 'tool-call' || !body.output) { - throw new Error('expected bounded command output') + throw new Error('expected command output') } - expect(body.output.head.length).toBeLessThan(20_000) + expect(body.output.head).toBe(output) }) it('continues to accept camel-case command completion output', () => { @@ -540,19 +540,19 @@ describe('codex item bodies', () => { }) }) - it('aggregates assistant content parts before bounding the message body', () => { + it('saves a long assistant reply whole, its content parts joined', () => { + const parts = Array.from({ length: 200 }, () => 'a'.repeat(10_000)) const body = codexItemBody({ type: 'agentMessage', id: 'assistant-parts', - content: Array.from({ length: 200 }, () => ({ type: 'text', text: 'a'.repeat(10_000) })) + content: parts.map((text) => ({ type: 'text', text })) }) const text = body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' expect(body).toMatchObject({ kind: 'message', role: 'assistant' }) expect(body?.kind === 'message' ? body.blocks : []).toHaveLength(1) - expect(text).toContain('output truncated') - expect(Buffer.byteLength(JSON.stringify(body), 'utf8')).toBeLessThan(20 * 1024) + expect(text).toBe(parts.join('\n')) }) it('calls a nonzero exit a failure even though codex calls the status completed', () => { diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 50ca92b80ee..ef0ae52af59 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -1,11 +1,7 @@ import { toolExecutionMetadata, toolWebSearchResults } from '../../shared/native-chat-tool-identity' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' -import { - boundInlineText, - boundToolInput, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { journalPayload } from '../native-chat/agent-session-journal/journal-payload-bounds' import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' import { codexImageItemBody } from './codex-image-item-translation' import { commandActionFacts } from './codex-command-action-class' @@ -43,7 +39,7 @@ export function codexMessageBlocks(item: CodexThreadItem): NativeChatBlock[] { ? (readString(item, 'text') ?? readTextContent(item, 'content')) : readString(item, 'text') if (text !== null) { - return [{ type: 'text', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }] + return [{ type: 'text', text }] } const content = item.content if (!Array.isArray(content)) { @@ -56,10 +52,7 @@ export function codexMessageBlocks(item: CodexThreadItem): NativeChatBlock[] { } const partText = readString(part as Record, 'text') if (partText !== null) { - blocks.push({ - type: 'text', - text: boundInlineText(partText, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text - }) + blocks.push({ type: 'text', text: partText }) continue } const record = part as Record @@ -79,7 +72,6 @@ export type CodexJournalItem = { function commandItem(item: CodexThreadItem): CodexJournalItem { const output = readFirstString(item, ['aggregatedOutput', 'aggregated_output']) - const bounded = output === null ? null : boundInlineText(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) const parsed = commandActionFacts(item) return { body: { @@ -87,13 +79,10 @@ function commandItem(item: CodexThreadItem): CodexJournalItem { name: parsed?.name ?? 'shell', callId: item.id, // Raw command and cwd stay so the expanded view still shows what ran. - input: boundToolInput( - { command: item.command ?? null, cwd: item.cwd ?? null, ...parsed?.fields }, - DEFAULT_JOURNAL_PAYLOAD_LIMITS - ), + input: { command: item.command ?? null, cwd: item.cwd ?? null, ...parsed?.fields }, state: codexItemRunState(item), ...toolExecutionMetadata(item), - ...(bounded === null ? {} : { output: bounded.bounded }) + ...(output === null ? {} : { output: journalPayload(output) }) }, handled: true } @@ -114,19 +103,18 @@ function fileChangeItem(item: CodexThreadItem): CodexJournalItem { kind: 'tool-call', name: 'apply_patch', callId: item.id, - input: boundToolInput({ changes: item.changes ?? null }, DEFAULT_JOURNAL_PAYLOAD_LIMITS), + input: { changes: item.changes ?? null }, state: codexItemRunState(item) }, handled: true } } const patch = changes.map((change) => change.diff).join('\n') - const bounded = boundInlineText(patch, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded return { body: { kind: 'diff', path: changes.length === 1 ? changes[0]!.path : `${changes.length} files`, - patch: bounded + patch: journalPayload(patch) }, handled: true } @@ -160,16 +148,15 @@ function mcpToolCallItem(item: CodexThreadItem): CodexJournalItem { const tool = readString(item, 'tool') const failure = readString(readRecord(item.error), 'message') const text = failure ?? readTextContent(readRecord(item.result), 'content') - const bounded = text === null ? null : boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS) return { body: { kind: 'tool-call', name: mcpToolCallName(item), callId: item.id, ...(server && tool ? { mcpIdentity: { server, tool } } : {}), - input: boundToolInput(mcpToolArguments(item.arguments), DEFAULT_JOURNAL_PAYLOAD_LIMITS), + input: mcpToolArguments(item.arguments), state: failure === null ? codexItemRunState(item) : 'failed', - ...(bounded === null ? {} : { output: bounded.bounded }) + ...(text === null ? {} : { output: journalPayload(text) }) }, handled: true } @@ -203,16 +190,15 @@ function webSearchInput(item: CodexThreadItem): Record | null { function webSearchItem(item: CodexThreadItem): CodexJournalItem { const results = toolWebSearchResults(item.results) const hits = Array.isArray(item.results) && item.results.length > 0 ? item.results : null - const bounded = hits && boundInlineText(JSON.stringify(hits), DEFAULT_JOURNAL_PAYLOAD_LIMITS) return { body: { kind: 'tool-call', name: 'web_search', callId: item.id, ...(results.length > 0 ? { webSearchResults: results } : {}), - input: boundToolInput(webSearchInput(item), DEFAULT_JOURNAL_PAYLOAD_LIMITS), + input: webSearchInput(item), state: item.action === null || item.action === undefined ? 'running' : 'completed', - ...(bounded === null ? {} : { output: bounded.bounded }) + ...(hits === null ? {} : { output: journalPayload(JSON.stringify(hits)) }) }, handled: true } @@ -267,7 +253,7 @@ export function codexJournalItem( ? null : { kind: 'status', - text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text, + text, presentation: 'plan-document' }, handled: true @@ -293,7 +279,7 @@ export function codexStreamingMessageBody(text: string): AgentJournalItemBody { return { kind: 'message', role: 'assistant', - blocks: [{ type: 'text', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }] + blocks: [{ type: 'text', text }] } } @@ -312,9 +298,8 @@ export function codexStreamingJournalItem(item: CodexThreadItem, text: string): const path = Array.isArray(item.changes) ? readString(readRecord(item.changes[0]), 'path') : null - const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded return { - body: { kind: 'diff', path: path ?? 'pending patch', patch: bounded }, + body: { kind: 'diff', path: path ?? 'pending patch', patch: journalPayload(text) }, handled: true } } @@ -322,14 +307,14 @@ export function codexStreamingJournalItem(item: CodexThreadItem, text: string): return { body: { kind: 'status', - text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text, + text, presentation: 'plan-document' }, handled: true } } return { - body: { kind: 'status', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }, + body: { kind: 'status', text }, handled: true } } diff --git a/src/main/codex/codex-structured-journal-exit-convergence.test.ts b/src/main/codex/codex-structured-journal-exit-convergence.test.ts index 187d130e464..91895b1bdd4 100644 --- a/src/main/codex/codex-structured-journal-exit-convergence.test.ts +++ b/src/main/codex/codex-structured-journal-exit-convergence.test.ts @@ -96,9 +96,7 @@ it('settles a refused Codex exit through the host in one commit, then permits a const deferred = createDeferredStructuredAgentSessionEventSink({ sessionId: SESSION, logger: recording.logger, - onFailed, - // Small budget exercises the production refusal without multi-megabyte fixtures. - watermarks: { maxLifecycleQueuedBytes: 16_384 } + onFailed }) deferred.bind({ journal, fence: 7, publish: () => undefined }) const translator = createCodexJournalTranslator({ @@ -183,20 +181,27 @@ it('settles a refused Codex exit through the host in one commit, then permits a options: new Map(), reportedOptions: {} } - expect( - handleCodexSessionExit({ - sessions: new Map([[SESSION, codexSession]]), - sessionId: SESSION, - connection: codexSession.connection, - error: new Error('codex exited'), - logger: recording.logger, - onEvent: (event) => { - if (event.type === 'ended' && 'cause' in event) { - events.push(event) - } + const gate = Promise.withResolvers() + const blocked = journal.readInOrder(() => gate.promise) + // A count-refused exit still delegates settlement to the host, as on main. + for (let index = 0; index < 1_024; index++) { + deferred.sink.publish({ lifecycle: true, coalescingKey: `limit-${index}` }) + } + const exited = handleCodexSessionExit({ + sessions: new Map([[SESSION, codexSession]]), + sessionId: SESSION, + connection: codexSession.connection, + error: new Error('codex exited'), + logger: recording.logger, + onEvent: (event) => { + if (event.type === 'ended' && 'cause' in event) { + events.push(event) } - }) - ).toBe(true) + } + }) + gate.resolve() + await blocked + expect(exited).toBe(true) expect(admission).toHaveReturnedWith({ accepted: false, reason: 'backpressure' }) expect(recording.entries).toContainEqual({ level: 'warn', diff --git a/src/main/codex/codex-structured-journal-translation-settlement.test.ts b/src/main/codex/codex-structured-journal-translation-settlement.test.ts index 5f51a17623f..29840d172c3 100644 --- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts +++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts @@ -410,7 +410,7 @@ describe('codex journal translation', () => { expectLifecycleBatchBounds(batches) }) - it('bounds one streamed assistant settlement before lifecycle batching', () => { + it('settles an oversized streamed reply whole, as its own row outside a lifecycle batch', () => { const tap = recorder() const batches: LifecycleBatch[] = [] tap.sink.appendLifecycleBatch = (settlementId, mutations) => { @@ -454,13 +454,12 @@ describe('codex journal translation', () => { ? settled.body.blocks[0].text : '' - expect(checkpointText).toContain('output truncated') - expect(settledText).toContain('output truncated') - expect(Buffer.byteLength(settledText, 'utf8')).toBeLessThan(20 * 1024) + expect(oversized.startsWith(checkpointText)).toBe(true) + expect(settledText).toBe(oversized) expectLifecycleBatchBounds(batches) }) - it('bounds authoritative completed assistant text before the journal append', () => { + it('saves authoritative completed assistant text whole', () => { const { translator, tap } = translatorWith() const oversized = 'b'.repeat(MAX_JOURNAL_LIFECYCLE_BATCH_BYTES + 1_024) @@ -474,8 +473,7 @@ describe('codex journal translation', () => { const body = tap.rows[0]?.body const text = body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' - expect(text).toContain('output truncated') - expect(Buffer.byteLength(JSON.stringify(body), 'utf8')).toBeLessThan(20 * 1024) + expect(text).toBe(oversized) }) it('terminalizes an active tool when its turn completes', () => { diff --git a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts index 0f51961b64d..784e1b148ab 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts @@ -467,7 +467,7 @@ describe('codex turn lifecycle rows', () => { ]) }) - it('replays a backpressured turn boundary with its original receipt time', async () => { + it('replays an oversized backpressured notification with its original receipt time', async () => { vi.useFakeTimers() const connection = { pauseReading: vi.fn(), @@ -483,7 +483,7 @@ describe('codex turn lifecycle rows', () => { }) expect( - retries.handle(SESSION_ID, 'turn/started', { turn: { id: TURN_ID } }, 1_000, -1) + retries.handle(SESSION_ID, 'turn/started', { turn: { id: TURN_ID }, output: 'x'.repeat(9 * 1024 * 1024) }, 1_000, -1) ).toEqual({ accepted: false, reason: 'backpressure' }) await vi.advanceTimersByTimeAsync(50) diff --git a/src/main/codex/codex-structured-notification-retry.ts b/src/main/codex/codex-structured-notification-retry.ts index 15af01da375..9d5ec0ac05e 100644 --- a/src/main/codex/codex-structured-notification-retry.ts +++ b/src/main/codex/codex-structured-notification-retry.ts @@ -3,7 +3,6 @@ import type { CodexJournalTranslationAdmission } from './codex-structured-journa import type { CodexSession } from './codex-structured-session-state' const MAX_RETRY_EVENTS = 256 -const MAX_RETRY_BYTES = 8 * 1024 * 1024 const RETRY_DELAY_MS = 25 type PendingNotification = { @@ -122,7 +121,7 @@ export function createCodexStructuredNotificationRetry(deps: { state = { connection, events: [], bytes: 0, timer: null, running: false, failed: false } states.set(sessionId, state) } - if (state.events.length >= MAX_RETRY_EVENTS || state.bytes + bytes > MAX_RETRY_BYTES) { + if (state.events.length >= MAX_RETRY_EVENTS) { // A bounded queue cannot retain more traffic. Fail it truthfully so the // provider's generation enters host recovery instead of stranding a pause. fail(sessionId, state, 'notification retry queue overflow') diff --git a/src/main/codex/codex-structured-prompt-items.test.ts b/src/main/codex/codex-structured-prompt-items.test.ts index e5d996f05d5..359a913cdb5 100644 --- a/src/main/codex/codex-structured-prompt-items.test.ts +++ b/src/main/codex/codex-structured-prompt-items.test.ts @@ -101,7 +101,7 @@ describe('codex approval items', () => { }) expect(item.detail).toContain('output truncated') - expect(Buffer.byteLength(JSON.stringify(item), 'utf8')).toBeLessThan(32 * 1024) + expect(Buffer.byteLength(JSON.stringify(item), 'utf8')).toBeLessThan(160 * 1024) }) }) diff --git a/src/main/codex/codex-structured-prompt-items.ts b/src/main/codex/codex-structured-prompt-items.ts index d088c6684fc..2562c1cbfc7 100644 --- a/src/main/codex/codex-structured-prompt-items.ts +++ b/src/main/codex/codex-structured-prompt-items.ts @@ -4,10 +4,8 @@ import type { AgentJournalPromptOption, AgentJournalQuestionItem } from '../../shared/agent-session-journal-types' -import { - boundInlineText, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { boundInlineText } from '../native-chat/agent-session-journal/journal-payload-bounds' +import { boundJournalPromptText } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' import { CODEX_APPROVAL_DECISIONS, CODEX_COMMAND_APPROVAL_METHOD, @@ -40,7 +38,7 @@ const PENDING = { const MAX_CODEX_PROMPT_QUESTIONS = 64 const MAX_CODEX_PROMPT_OPTIONS = 64 -const PROMPT_OPTION_LIMITS = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 1024 } +const PROMPT_OPTION_LIMITS = { inlineHeadBytes: 1024 } function readParams(params: unknown): Record { return typeof params === 'object' && params !== null ? (params as Record) : {} @@ -51,8 +49,8 @@ function readString(source: Record, key: string): string | null return typeof value === 'string' && value.length > 0 ? value : null } -function boundPromptText(value: string): string { - return boundInlineText(value, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text +function boundNullablePromptText(value: string | null): string | null { + return value === null ? null : boundJournalPromptText(value) } function boundPromptOptionLabel(value: string): string { @@ -98,10 +96,6 @@ export function codexApprovalItem(input: { } } -function boundNullablePromptText(value: string | null): string | null { - return value === null ? null : boundPromptText(value) -} - function approvalDetail(params: Record): string | null { const command = params.command if (typeof command === 'string' && command.length > 0) { @@ -152,7 +146,7 @@ export function codexQuestionItems(input: { identity: codexPromptIdentity({ ...input, questionId }), body: { kind: 'question', - question: boundPromptText(prompt), + question: boundJournalPromptText(prompt), options: questionOptions(question, questionId), ...(questionAllowsFreeText(question) ? { freeTextQuestionId: codexJournalPromptIdPart(questionId) } diff --git a/src/main/jsonl-rpc/timeline-final-tail.test.ts b/src/main/jsonl-rpc/timeline-final-tail.test.ts index f39f9e78991..b5688ab41e4 100644 --- a/src/main/jsonl-rpc/timeline-final-tail.test.ts +++ b/src/main/jsonl-rpc/timeline-final-tail.test.ts @@ -81,16 +81,18 @@ describe('Pi final tail before host eviction', () => { expect(append('lifecycle-refused', true)).toEqual({ accepted: false, reason: 'backpressure' }) const before = journal.snapshot().items.length vi.useFakeTimers() + const reply = `Final reply${'r'.repeat(2 * 1024 * 1024)}` + const status = `Final status${'s'.repeat(17 * 1024 * 1024)}` lane.apply([ - { type: 'text.delta', item: { id: 'reply' }, channel: 'assistant', text: 'Final reply' }, + { type: 'text.delta', item: { id: 'reply' }, channel: 'assistant', text: reply }, { type: 'item.update', item: 'tail', - body: { kind: 'status', tone: 'info', text: 'Final status' } + body: { kind: 'status', tone: 'info', text: status } } ]) - lane.finalize() - lane.apply([{ type: 'session.ended', verdict: { state: 'interrupted', completedAt: 2 } }]) + lane.finalize({ type: 'session.ended', verdict: { state: 'interrupted', completedAt: 2 } }) + lane.apply([{ type: 'item.close', item: 'after-seal', body: { kind: 'status', tone: 'info', text: 'must not land' } }]) let landed = false const barrier = deferred.drained().then((result) => { landed = true @@ -112,14 +114,15 @@ describe('Pi final tail before host eviction', () => { discardSink: vi.fn() }) expect(acknowledgeRelease).toHaveBeenCalledOnce() + expect(journal.snapshot().items.some((item) => item.body.kind === 'status' && item.body.text === 'must not land')).toBe(false) expect(journal.snapshot().items.map((item) => item.body)).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: 'message', role: 'assistant', - blocks: [{ type: 'text', text: 'Final reply' }] + blocks: [{ type: 'text', text: reply }] }), - expect.objectContaining({ kind: 'status', text: 'Final status' }), + expect.objectContaining({ kind: 'status', text: status }), expect.objectContaining({ kind: 'approval', resolution: expect.objectContaining({ state: 'cancelled' }) @@ -129,28 +132,19 @@ describe('Pi final tail before host eviction', () => { ) }) - it('fails excessive final output explicitly instead of leaving a refused tail waiting for eviction', async () => { + it('keeps the final transfer operation-count limit', async () => { const failures: unknown[] = [] const deferred = createDeferredStructuredAgentSessionEventSink({ ...testEventSinkLogging(SESSION), onFailed: (error) => failures.push(error) }) cleanups.push(() => deferred.close()) - expect( - deferred.sink.tryAppendTransition?.({ - finalTail: true, - lifecycle: true, - publish: false, - steps: [ - { - kind: 'settlement', - settlementId: 'oversize', - reservedBytes: 16 * 1024 * 1024 + 1, - resolve: () => [] - } - ] - }) - ).toEqual({ accepted: false, reason: 'failed' }) + for (let index = 0; index <= 512; index += 1) { + expect(deferred.sink.tryAppendTransition?.({ + finalTail: true, lifecycle: true, publish: false, + steps: [{ kind: 'settlement', settlementId: `tail:${index}`, reservedBytes: 1, resolve: () => [] }] + })).toEqual(index < 512 ? { accepted: true } : { accepted: false, reason: 'failed' }) + } expect(failures).toEqual([ expect.objectContaining({ message: 'structured agent-session final tail exceeded its reserved capacity' diff --git a/src/main/jsonl-rpc/timeline-lane.ts b/src/main/jsonl-rpc/timeline-lane.ts index 36c759e42de..ffae11df83b 100644 --- a/src/main/jsonl-rpc/timeline-lane.ts +++ b/src/main/jsonl-rpc/timeline-lane.ts @@ -27,7 +27,9 @@ export type JsonlRpcTimelineLaneDeps = { export class JsonlRpcTimelineLane { private readonly assembler: ProviderTimelineAssembler private readonly identity: ReturnType - private readonly held: { event: ProviderTimelineEvent; bytes: number }[] = [] + private readonly held: { events: { event: ProviderTimelineEvent; bytes: number }[]; bytes: number }[] = [] + private heldEvents = 0 + private sealed = false private heldBytes = 0 private draining = false private ended = false @@ -55,7 +57,7 @@ export class JsonlRpcTimelineLane { beginCommand(command: StructuredAgentSessionCommandRun): void { this.retry() - if (this.ended || this.held.length > 0) { + if (this.ended || this.sealed || this.held.length > 0) { throw new Error('Provider timeline has not drained for this command') } this.assembler.beginCommand(command) @@ -66,19 +68,47 @@ export class JsonlRpcTimelineLane { } apply(events: readonly ProviderTimelineEvent[]): void { - if (this.ended) { + if (this.ended || this.sealed) { return } - for (const event of events) { - const bytes = Buffer.byteLength(JSON.stringify(event)) - if (this.held.length >= MAX_HELD_EVENTS || this.heldBytes + bytes > MAX_HELD_BYTES) { - this.fail('Agent timeline queue capacity exceeded') - return + const pending = events.map((event) => ({ event, bytes: Buffer.byteLength(JSON.stringify(event)) })) + // Try the current frame first; only a refused suffix becomes retained head work. + while (this.held.length === 0 && pending.length > 0 && !this.ended) { + if (!this.consume(pending[0].event)) { + break } - this.held.push({ event, bytes }) - this.heldBytes += bytes - this.retry() + pending.shift() } + this.notify() + if (pending.length === 0 || this.ended) { + return + } + const bytes = pending.reduce((total, entry) => total + entry.bytes, 0) + if ( + this.heldEvents + pending.length > MAX_HELD_EVENTS || + (this.held.length > 0 && this.heldBytes - this.held[0].bytes + bytes > MAX_HELD_BYTES) + ) { + this.fail('Agent timeline queue capacity exceeded') + return + } + this.held.push({ events: pending, bytes }) + this.heldEvents += pending.length + this.heldBytes += bytes + this.retry() + } + + private consume(event: ProviderTimelineEvent): boolean { + const result = this.assembler.apply(event) + if (!result.admission.accepted) { + if (result.admission.reason !== 'backpressure') { + this.fail(result.admission.reason) + } + return false + } + if (event.type === 'input.accepted' && !result.dropped) { + this.deps.onInputAccepted(event.clientMessageId) + } + return true } retry(): void { @@ -90,29 +120,31 @@ export class JsonlRpcTimelineLane { this.draining = true try { while (this.held.length > 0 && !this.ended) { - const entry = this.held[0] - const result = this.assembler.apply(entry.event) - if (!result.admission.accepted) { - if (result.admission.reason !== 'backpressure') { - this.fail(result.admission.reason) - } else { + const batch = this.held[0] + const entry = batch.events[0] + if (!this.consume(entry.event)) { + if (!this.ended) { this.deps.pauseReading() this.retryTimer = setTimeout(() => this.retry(), 250) this.retryTimer.unref() } return } - this.held.shift() + batch.events.shift() + batch.bytes -= entry.bytes this.heldBytes -= entry.bytes - if (entry.event.type === 'input.accepted' && !result.dropped) { - this.deps.onInputAccepted(entry.event.clientMessageId) + this.heldEvents -= 1 + if (batch.events.length === 0) { + this.held.shift() } } } finally { this.draining = false this.notify() } - this.deps.resumeReading() + if (!this.sealed && !this.ended) { + this.deps.resumeReading() + } } whenTurnLeaves(turnId: string): Promise { @@ -161,11 +193,23 @@ export class JsonlRpcTimelineLane { this.assembler.flush() } - finalize(): void { - // Admission must precede the host's exit barrier even when lifecycle writes are full. + finalize(end?: Extract): void { + if (this.sealed || this.ended) { + return + } + // At most 256 held events + 128 dirty streams + this internal end: below 512 tail slots. + this.sealed = true this.finalizing = true this.retry() + if (this.ended) { + return + } + if (this.held.length > 0 || (end && !this.consume(end))) { + this.fail('Agent final timeline could not be transferred') + return + } this.flush() + this.assembler.dispose() } dispose(): void { @@ -173,6 +217,7 @@ export class JsonlRpcTimelineLane { clearTimeout(this.retryTimer) this.held.length = 0 this.heldBytes = 0 + this.heldEvents = 0 this.assembler.dispose() this.notify() } diff --git a/src/main/native-chat/agent-session-journal/journal-completed-content.test.ts b/src/main/native-chat/agent-session-journal/journal-completed-content.test.ts new file mode 100644 index 00000000000..814928051b9 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-completed-content.test.ts @@ -0,0 +1,89 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { AcpToolTimeline } from '../../acp/acp-tool-timeline' +import { PiRpcMessages } from '../../pi/rpc-messages' +import { PiRpcContextUsage } from '../../pi/rpc-context-usage' +import { JsonlRpcTimelineLane } from '../../jsonl-rpc/timeline-lane' +import { closeProviderTimelineRigs, openProviderTimelineRig, SESSION, GENERATION, NAMESPACE } from '../agent-session-timeline/provider-timeline-assembler-test-support' +import { estimateStructuredAgentSessionItemBytes } from '../agent-session-wire/structured-agent-session-event-sink-estimate' +import { journalPayload } from './journal-payload-bounds' + +const cleanups: (() => void)[] = [] +afterEach(async () => { cleanups.splice(0).forEach((cleanup) => cleanup()); await closeProviderTimelineRigs() }) + +it.each(['direct', 'transition'] as const)('persists complete content through %s admission and SQLite reopen', async (path) => { + const rig = await openProviderTimelineRig() + const gate = Promise.withResolvers() + cleanups.push(() => gate.resolve()) + const identity = { provider: 'legacy' as const, agent: 'grok', sessionId: SESSION, recordId: 'full' } + const options = { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } + const text = `${'\u0001'.repeat(3 * 1024 * 1024)}MIDDLE${'x'.repeat(5 * 1024 * 1024)}TAIL` + const body = { kind: 'tool-call' as const, name: 'bash', input: { large: 'i'.repeat(1024 * 1024) }, state: 'completed' as const, output: journalPayload(text) } + const cursor = rig.journal.cursor() + const snapshot = rig.journal.snapshot() + const blocked = rig.journal.readInOrder(() => { + expect(rig.eventSink.tryAppendItem?.({ ...identity, recordId: 'before' }, { kind: 'status', tone: 'info', text: 'before' }, options)).toEqual({ accepted: true }) + return gate.promise + }) + expect(rig.journal.cursor()).toEqual(cursor) + expect(rig.journal.snapshot()).toEqual(snapshot) + const append = () => path === 'direct' + ? rig.eventSink.tryAppendItem?.(identity, body, options) + : rig.sink.tryAppendTransition({ lifecycle: true, publish: true, steps: [{ kind: 'item', reservedBytes: estimateStructuredAgentSessionItemBytes(identity, body), options, resolve: () => ({ identity, body }) }] }) + expect(append()).toEqual({ accepted: true }) + let landed = false + const written = rig.eventSink.written?.().then((result) => { landed = true; return result }) + await new Promise((resolve) => setImmediate(resolve)) + expect(rig.journal.cursor()).toEqual(cursor) + expect(rig.journal.snapshot()).toEqual(snapshot) + expect(landed).toBe(false) + gate.resolve() + await blocked + expect(await written).toEqual({ ok: true }) + const reopened = await rig.reopenJournal() + expect(reopened.snapshot().items.map((item) => item.body)).toEqual([ + { kind: 'status', tone: 'info', text: 'before' }, body + ]) +}) + +it.each(['acp', 'pi'] as const)('transfers %s full fields at an omitted-field close without saving whole running revisions', async (agent) => { + const rig = await openProviderTimelineRig() + const onFailed = vi.fn() + const lane = new JsonlRpcTimelineLane({ sink: rig.sink, sessionId: SESSION, generation: GENERATION, namespace: NAMESPACE, agent: 'grok', pauseReading: vi.fn(), resumeReading: vi.fn(), onInputAccepted: vi.fn(), onFailed }) + cleanups.push(() => lane.dispose()) + const acp = new AcpToolTimeline(), pi = new PiRpcMessages(GENERATION, new PiRpcContextUsage()) + const size = (agent === 'acp' ? 9 : 2) * 1024 * 1024 + const input = { text: 'i'.repeat(size) }, output = 'o'.repeat(size) + for (const id of ['one', 'two']) { + lane.apply(agent === 'acp' + ? acp.translate({ toolCallId: id, name: 'bash', status: 'in_progress', rawInput: input }, {}, {}) + : pi.tool({ type: 'tool_execution_start', toolCallId: id, toolName: 'bash', args: input })) + await rig.rows(); lane.retry(); await lane.drained() + lane.apply(agent === 'acp' ? acp.translate({ toolCallId: id, rawOutput: output }, {}, {}) + : pi.tool({ type: 'tool_execution_update', toolCallId: id, toolName: 'bash', partialResult: { content: [{ type: 'text', text: output }] } })) + await rig.rows(); lane.retry(); await lane.drained() + const update = agent === 'acp' ? acp.translate({ toolCallId: id, status: 'in_progress' }, {}, {}) + : pi.tool({ type: 'tool_execution_update', toolCallId: id, toolName: 'bash' }) + const changed = update.find((event) => event.type === 'item.update') + const again = agent === 'acp' ? acp.translate({ toolCallId: id, status: 'in_progress' }, {}, {}) + : pi.tool({ type: 'tool_execution_update', toolCallId: id, toolName: 'bash' }) + const repeated = again.find((event) => event.type === 'item.update') + if (changed?.type !== 'item.update' || repeated?.type !== 'item.update' || + changed.body.kind !== 'tool-call' || repeated.body.kind !== 'tool-call') { + throw new Error('Expected running tool updates') + } + expect(repeated.body.input).toBe(changed.body.input) + expect(repeated.body.output).toBe(changed.body.output) + } + expect(agent === 'acp' ? acp.retainedToolBytes : pi.retainedToolBytes).toBeGreaterThan(4 * size) + expect((await rig.rows()).filter((row) => row.body.kind === 'tool-call').every((row) => JSON.stringify(row.body).length < 40_000)).toBe(true) + for (const id of ['one', 'two']) { + lane.apply(agent === 'acp' ? acp.translate({ toolCallId: id, status: 'completed' }, {}, {}) + : pi.tool({ type: 'tool_execution_end', toolCallId: id, toolName: 'bash' })) + await rig.rows(); lane.retry(); await lane.drained() + } + expect(agent === 'acp' ? acp.retainedToolBytes : pi.retainedToolBytes).toBe(0) + expect(onFailed).not.toHaveBeenCalled() + const reopened = await rig.reopenJournal() + expect(reopened.snapshot().items.map((row) => row.body)).toEqual(['one', 'two'].map((callId) => expect.objectContaining({ kind: 'tool-call', callId, input, state: 'completed', output: journalPayload(output) }))) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-legacy-reason.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-legacy-reason.test.ts index 75783309d18..d674129e962 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-legacy-reason.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-legacy-reason.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { applyJournalRow, createJournalReducerState } from './journal-reducer' -import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' +import { JOURNAL_DISPATCH_REASON_LIMITS } from './journal-payload-bounds' import type { JournalRow } from './journal-row-schema' const EPOCH = 'epoch-1' @@ -41,7 +41,7 @@ describe("a dispatch row's reason", () => { expect(reason?.startsWith('Write failed: ')).toBe(true) expect(reason).toContain('[Orca: output truncated') expect(Buffer.byteLength(reason ?? '')).toBeLessThan( - DEFAULT_JOURNAL_PAYLOAD_LIMITS.inlineHeadBytes + 1024 + JOURNAL_DISPATCH_REASON_LIMITS.inlineHeadBytes + 1024 ) }) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.spec.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.spec.ts index 391a786dcb4..626362e91ae 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.spec.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.spec.ts @@ -8,7 +8,7 @@ import { DISPATCH_REJECTED_WRITE_FAILED, isWriteFailureSubmission } from '../../../shared/structured-agent-session-dispatch-rejection' -import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' +import { JOURNAL_DISPATCH_REASON_LIMITS } from './journal-payload-bounds' import type { openAgentSessionJournal } from './journal-store-factory' import { createTrackedJournalOpener } from './journal-host-database-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' @@ -22,7 +22,7 @@ export function registerJournalDispatchReasonBoundTests(): void { providerHandle: codexProviderHandle('thread-1') } - const HUGE = 'x'.repeat(4 * DEFAULT_JOURNAL_PAYLOAD_LIMITS.inlineHeadBytes) + const HUGE = 'x'.repeat(4 * JOURNAL_DISPATCH_REASON_LIMITS.inlineHeadBytes) let root: string let clock = 1_000 diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index ff0f763ef7f..43377a923dd 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -8,7 +8,7 @@ import { import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import type { AgentJournalAnsweredTurn } from '../../../shared/agent-session-journal-types' import { journalDispatchRowApplies } from './journal-dispatch-settlement' -import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' +import { boundInlineText, JOURNAL_DISPATCH_REASON_LIMITS } from './journal-payload-bounds' import type { JournalReducerState } from './journal-reducer' import { noteTurnAccepted, @@ -31,7 +31,7 @@ export function applyJournalDispatchRow( submission.providerItemId = row.providerItemId // Rows written before the write-side bound (#20654) carry the full text; bound them on read too. submission.reason = - row.reason === null ? null : boundInlineText(row.reason, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + row.reason === null ? null : boundInlineText(row.reason, JOURNAL_DISPATCH_REASON_LIMITS).text const rejection = row.state === 'rejected' ? readStoredRejectionFact(row.rejection) : undefined if (rejection) { submission.rejection = rejection diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts index 993c1531303..cec1ff6d8ea 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -15,12 +15,15 @@ import type * as TranscriptLineDecoders from '../transcript-line-decoders' import { createLegacyIdentityTracker } from './journal-legacy-identity' import { importLegacyTranscriptIntoJournal, - prepareLegacyTranscriptImport + prepareLegacyTranscriptImport, + LEGACY_IMPORT_BLOCK_LIMITS } from './journal-legacy-import' -import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import { openAgentSessionJournal } from './journal-store-factory' import type { AgentSessionJournal } from './journal-store' -import { openTestJournalHostDatabase } from './journal-host-database-test-support' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from './journal-host-database-test-support' import { claudeProviderHandle, codexProviderHandle @@ -45,6 +48,7 @@ const CODEX_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' let root: string let clock = 1_000 +const journals = createTrackedJournalOpener() function tick(): number { clock += 1 @@ -77,13 +81,15 @@ async function open( sessionId: string, overrides: Partial[0]> = {} ): Promise { - return openAgentSessionJournal({ - identity: identity(agent, sessionId), - database: openTestJournalHostDatabase(root), - now: tick, - mintEpoch: () => `epoch-${clock}`, - ...overrides - }) + return journals.track( + await openAgentSessionJournal({ + identity: identity(agent, sessionId), + database: openTestJournalHostDatabase(root), + now: tick, + mintEpoch: () => `epoch-${clock}`, + ...overrides + }) + ) } function legacyKey(recordId: string): string { @@ -210,6 +216,7 @@ beforeEach(async () => { afterEach(async () => { decodedClaudeOverride.message = null + await journals.closeAll() await rm(root, { recursive: true, force: true }) }) @@ -430,7 +437,7 @@ describe('payload bounds on import', () => { agent: 'claude', sessionId: CLAUDE_SESSION, fence: 1, - options: { filePath, limits: { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 1_024 } } + options: { filePath, limits: { ...LEGACY_IMPORT_BLOCK_LIMITS, inlineHeadBytes: 1_024 } } }) const item = journal.snapshot().items[0] @@ -559,7 +566,7 @@ describe('import failures', () => { it('bounds oversized legacy tool-call input before journal publication', async () => { const journalDir = join(root, 'bounded-tool-input-journal') - const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 64 } + const limits = { ...LEGACY_IMPORT_BLOCK_LIMITS, inlineHeadBytes: 64 } const journal = await open('claude', CLAUDE_SESSION, { database: openTestJournalHostDatabase(journalDir) }) @@ -670,7 +677,7 @@ describe('import failures', () => { // row that also has text, and omp's execution cells always pair the invocation // with its output — so the multi-block path carries untrusted tool input too. describe('multi-block legacy messages', () => { - const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 64 } + const limits = { ...LEGACY_IMPORT_BLOCK_LIMITS, inlineHeadBytes: 64 } const oversized = 'x'.repeat(10_000) /** The tool-call block of the first imported multi-block message. */ diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index 232db150283..01e1d246628 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -41,11 +41,13 @@ import type { JournalReplacementItem } from './journal-epoch-replacement' import { boundInlineText, boundToolInput, - DEFAULT_JOURNAL_PAYLOAD_LIMITS, type JournalPayloadLimits } from './journal-payload-bounds' import type { AgentSessionJournal } from './journal-store' +/** Import preserves the bounded history window; the transcript remains the complete source. */ +export const LEGACY_IMPORT_BLOCK_LIMITS: JournalPayloadLimits = { inlineHeadBytes: 16 * 1024 } + export type LegacyImportOptions = ResolveSessionFileOptions & { /** Resolve directly to this file, skipping path discovery. */ filePath?: string @@ -103,7 +105,7 @@ export async function prepareLegacyTranscriptImport(input: { options?: LegacyImportOptions }): Promise { const options = input.options ?? {} - const limits = options.limits ?? DEFAULT_JOURNAL_PAYLOAD_LIMITS + const limits = options.limits ?? LEGACY_IMPORT_BLOCK_LIMITS // OMP is a Pi fork that kept Pi's session format, so OMP's decoder reads Pi's sessions too. const transcriptAgent = input.agent === 'pi' ? 'omp' : resolveNativeChatTranscriptAgent(input.agent) diff --git a/src/main/native-chat/agent-session-journal/journal-payload-bounds-retention.test.ts b/src/main/native-chat/agent-session-journal/journal-payload-bounds-retention.test.ts index 08a8f92cbee..34a99f038a4 100644 --- a/src/main/native-chat/agent-session-journal/journal-payload-bounds-retention.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-payload-bounds-retention.test.ts @@ -3,10 +3,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalBoundedPayload } from '../../../shared/agent-session-journal-types' import { codexItemBody } from '../../codex/codex-structured-item-translation' import { - boundInlineText, - boundPayload, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from './journal-payload-bounds' + AGENT_SESSION_WIRE_PAYLOAD_HEAD_BYTES, + agentJournalItemForWire +} from '../agent-session-wire/agent-session-item-wire-form' +import { boundInlineText, boundPayload } from './journal-payload-bounds' function previousBoundPayload( payload: string, @@ -44,11 +44,8 @@ function expectPreviousBounds(payload: string, inlineHeadBytes: number): void { afterEach(() => vi.restoreAllMocks()) describe('journal payload allocation bounds', () => { - it('bounds a large completed Codex command without encoding its full output into a Buffer', () => { + it('saves a large Codex command whole and sends a client only its head, encoding only that', () => { const payload = 'x'.repeat(50 * 1024 * 1024) - const digest = createHash('sha256').update(payload, 'utf8').digest('hex') - const from = vi.spyOn(Buffer, 'from') - const allocUnsafe = vi.spyOn(Buffer, 'allocUnsafe') const body = codexItemBody({ type: 'commandExecution', id: 'command', @@ -58,6 +55,24 @@ describe('journal payload allocation bounds', () => { aggregatedOutput: payload, exitCode: 0 }) + if (body?.kind !== 'tool-call') { + throw new Error('Completed command did not produce a tool-call journal body') + } + expect(body.output).toMatchObject({ + head: payload, + byteLength: payload.length, + truncated: false + }) + + const from = vi.spyOn(Buffer, 'from') + const allocUnsafe = vi.spyOn(Buffer, 'allocUnsafe') + const sent = agentJournalItemForWire({ + itemId: 'command', + revision: 1, + body, + sequence: 1, + observedAt: 1 + }).item const encodedStringBytes = from.mock.calls.reduce( (largest, [value]) => typeof value === 'string' ? Math.max(largest, Buffer.byteLength(value, 'utf8')) : largest, @@ -69,23 +84,22 @@ describe('journal payload allocation bounds', () => { ) vi.restoreAllMocks() - if (body?.kind !== 'tool-call') { - throw new Error('Completed command did not produce a tool-call journal body') - } - expect(body.state).toBe('completed') - expect(body.input).toMatchObject({ command: 'cat large.log', cwd: '/workspace' }) - expect(body.output).toEqual({ - head: 'x'.repeat(DEFAULT_JOURNAL_PAYLOAD_LIMITS.inlineHeadBytes), - byteLength: payload.length, - digest, - truncated: true + expect(sent.shortened).toBe(true) + expect(sent.body).toMatchObject({ + kind: 'tool-call', + input: { command: 'cat large.log', cwd: '/workspace' }, + output: { + head: 'x'.repeat(AGENT_SESSION_WIRE_PAYLOAD_HEAD_BYTES), + byteLength: payload.length, + truncated: true + } }) - expect(encodedStringBytes).toBeLessThanOrEqual(DEFAULT_JOURNAL_PAYLOAD_LIMITS.inlineHeadBytes) - expect(largestAllocation).toBeLessThanOrEqual(DEFAULT_JOURNAL_PAYLOAD_LIMITS.inlineHeadBytes) + expect(encodedStringBytes).toBeLessThanOrEqual(AGENT_SESSION_WIRE_PAYLOAD_HEAD_BYTES) + expect(largestAllocation).toBeLessThanOrEqual(AGENT_SESSION_WIRE_PAYLOAD_HEAD_BYTES) }) it('preserves UTF-8 clipping, replacement characters, metadata and exotic byte limits', () => { - const cap = DEFAULT_JOURNAL_PAYLOAD_LIMITS.inlineHeadBytes + const cap = AGENT_SESSION_WIRE_PAYLOAD_HEAD_BYTES const payloads = [ '', 'ascii\n\0text', diff --git a/src/main/native-chat/agent-session-journal/journal-payload-bounds.ts b/src/main/native-chat/agent-session-journal/journal-payload-bounds.ts index 1012273989d..bb18251398a 100644 --- a/src/main/native-chat/agent-session-journal/journal-payload-bounds.ts +++ b/src/main/native-chat/agent-session-journal/journal-payload-bounds.ts @@ -1,22 +1,26 @@ -// Payload bounds for tool output and diffs. -// -// A 40 MB tool result must not be inlined into a row that every reconnecting -// client replays. A bounded payload keeps a head plus the original byte length -// and digest; the remainder is discarded. Crossing a bound is always marked — -// never a silent drop. - +// Completed content is canonical; running revisions keep previews until the full close. import { createHash } from 'node:crypto' import type { AgentJournalBoundedPayload } from '../../../shared/agent-session-journal-types' export type JournalPayloadLimits = { - /** Bytes of the payload kept inline on the row. */ + /** Bytes of the payload kept in `head`. */ inlineHeadBytes: number } -export const DEFAULT_JOURNAL_PAYLOAD_LIMITS: JournalPayloadLimits = { +/** What a revision of a call still running keeps where every update is a row and the open budget + * holds it (provider-timeline-budget.ts); its closing revision keeps the whole value. */ +export const JOURNAL_RUNNING_PREVIEW_LIMITS: JournalPayloadLimits = { inlineHeadBytes: 16 * 1024 } +/** The raw provider frame a row keeps behind its disclosure: a record of traffic Orca cannot + * model, not content the agent said, so it keeps a head. */ +export const PROVIDER_FRAME_PAYLOAD_LIMITS: JournalPayloadLimits = { inlineHeadBytes: 16 * 1024 } + +/** A send's rejection reason: a provider error body of any size, not conversation content, and + * it rides the submission list rather than an item (#20654). */ +export const JOURNAL_DISPATCH_REASON_LIMITS: JournalPayloadLimits = { inlineHeadBytes: 16 * 1024 } + /** Marker appended to a clipped inline string so the UI never presents a * truncated body as complete. Kept in the text itself because block-level * payloads (tool-result output) have nowhere else to carry the flag. */ @@ -28,8 +32,23 @@ export function digestPayload(payload: string): string { return createHash('sha256').update(payload, 'utf8').digest('hex') } -/** Clip `payload` to the inline head. `truncated` means the remainder was - * discarded; `digest` and `byteLength` describe the original. */ +/** The whole value, as a tool output or diff is saved. */ +export function journalPayload(payload: string): AgentJournalBoundedPayload { + return { + head: payload, + byteLength: Buffer.byteLength(payload, 'utf8'), + digest: digestPayload(payload), + truncated: false + } +} + +/** Whole once `running` is false; a preview while the call still runs. */ +export function journalToolPayload(payload: string, running: boolean): AgentJournalBoundedPayload { + return running ? boundPayload(payload, JOURNAL_RUNNING_PREVIEW_LIMITS) : journalPayload(payload) +} + +/** Clip `payload` to the inline head. `truncated` means the head is not the whole value; + * `digest` and `byteLength` describe the original. */ export function boundPayload( payload: string, limits: JournalPayloadLimits @@ -39,27 +58,19 @@ export function boundPayload( if (byteLength <= limits.inlineHeadBytes) { return { head: payload, byteLength, digest, truncated: false } } - const boundedBuffer = - Number.isSafeInteger(limits.inlineHeadBytes) && limits.inlineHeadBytes >= 0 - ? Buffer.allocUnsafe(limits.inlineHeadBytes) - : null - const head = boundedBuffer - ? boundedBuffer.toString( - 'utf8', - 0, - boundedBuffer.write(payload, 0, boundedBuffer.length, 'utf8') - ) - : clipUtf8(Buffer.from(payload, 'utf8'), limits.inlineHeadBytes) - return { - head, - byteLength, - digest, - truncated: true - } + return { head: utf8Head(payload, limits.inlineHeadBytes), byteLength, digest, truncated: true } } -/** Bound a plain string that must stay a string (a tool-result block's output), - * keeping the explicit marker inline. */ +/** The first `maxBytes` of `text` as UTF-8, never splitting a character; encodes only that head. */ +export function utf8Head(text: string, maxBytes: number): string { + const boundedBuffer = + Number.isSafeInteger(maxBytes) && maxBytes >= 0 ? Buffer.allocUnsafe(maxBytes) : null + return boundedBuffer + ? boundedBuffer.toString('utf8', 0, boundedBuffer.write(text, 0, boundedBuffer.length, 'utf8')) + : clipUtf8(Buffer.from(text, 'utf8'), maxBytes) +} + +/** Bound a plain string that must stay a string, keeping the explicit marker inline. */ export function boundInlineText( payload: string, limits: JournalPayloadLimits @@ -74,7 +85,7 @@ export function boundInlineText( } } -/** Keep arbitrary tool input JSON bounded before it reaches a row. */ +/** Tool input JSON over the limit, as its head and the original's size and digest. */ export function boundToolInput(input: unknown, limits: JournalPayloadLimits): unknown { let encoded: string try { diff --git a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts index 6e226a4c6cf..60b7bcbd15e 100644 --- a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts +++ b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts @@ -7,16 +7,15 @@ import type { AgentJournalFreeTextInput } from '../../../shared/agent-session-journal-types' import { isPlanApprovalSubject } from '../../../shared/agent-session-approval-subject' -import { - boundInlineText, - boundPayload, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from './journal-payload-bounds' +import { boundInlineText, boundPayload } from './journal-payload-bounds' export const MAX_JOURNAL_PROMPT_OPTIONS = 64 export const MAX_JOURNAL_GROUPED_PROMPT_QUESTIONS = 4 const JOURNAL_PROMPT_OPTION_LIMITS = { inlineHeadBytes: 1024 } +/** A prompt's text is what the agent asked, so it keeps far more than a client is sent; it stays + * bounded because a prompt's body rides settlement batches (`MAX_JOURNAL_LIFECYCLE_BATCH_BYTES`). */ +const JOURNAL_PROMPT_TEXT_LIMITS = { inlineHeadBytes: 128 * 1024 } const JOURNAL_PROMPT_ID_MAX_BYTES = 1024 export function cancelledJournalPromptBody( @@ -141,9 +140,12 @@ function boundPromptOptions( } function boundPromptText(value: string): string { - return boundInlineText(value, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + return boundInlineText(value, JOURNAL_PROMPT_TEXT_LIMITS).text } +/** One prompt text field as a prompt row keeps it. */ +export const boundJournalPromptText = boundPromptText + function boundPromptIdentifier(value: string): string { if (Buffer.byteLength(value, 'utf8') <= JOURNAL_PROMPT_ID_MAX_BYTES) { return value diff --git a/src/main/native-chat/agent-session-journal/journal-reasoning-row.ts b/src/main/native-chat/agent-session-journal/journal-reasoning-row.ts index e8aa2e73664..5d00e65e43b 100644 --- a/src/main/native-chat/agent-session-journal/journal-reasoning-row.ts +++ b/src/main/native-chat/agent-session-journal/journal-reasoning-row.ts @@ -2,7 +2,6 @@ import type { AgentJournalItemBody, AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' export type JournalReasoningLifecycle = Pick @@ -15,9 +14,7 @@ export function journalReasoningBody( ? { kind: 'message', role: 'reasoning', - blocks: [ - { type: 'text', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } - ], + blocks: [{ type: 'text', text }], ...lifecycle } : null diff --git a/src/main/native-chat/agent-session-journal/journal-received-content.test.ts b/src/main/native-chat/agent-session-journal/journal-received-content.test.ts new file mode 100644 index 00000000000..1441989c61f --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-received-content.test.ts @@ -0,0 +1,231 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + adapterFor as claudeAdapter, + claudeFrame, + fakeClaude, + identityFor as claudeIdentity +} from '../../claude/claude-structured-session-test-support' +import { + acquireReadyCodexForTest, + adapterFor as codexAdapter, + fakeCodex, + identityFor as codexIdentity, + THREAD_ID +} from '../../codex/codex-structured-session-adapter-fixture' +import { + openAcpAdapterRig, + sendHello, + shellCall +} from '../../acp/acp-structured-adapter.test-support' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + SESSION +} from '../agent-session-timeline/provider-timeline-assembler-test-support' +import { createDeferredStructuredAgentSessionEventSink } from '../agent-session-wire/structured-agent-session-event-sink' +import { testEventSinkLogging } from '../agent-session-wire/structured-agent-session-logger-test-support' + +const releases: (() => void)[] = [] +afterEach(async () => { + releases.splice(0).forEach((release) => release()) + vi.restoreAllMocks() + await closeProviderTimelineRigs() +}) + +it.each(['claude', 'codex'] as const)( + 'journals a large received %s result before exit despite a busy disk', + async (agent) => { + const rig = await openProviderTimelineRig() + const pressure = vi.fn() + const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(), + onBackpressureChange: pressure + }) + releases.push(() => deferred.close()) + deferred.bind({ journal: rig.journal, fence: 1, publish: () => {} }) + const text = `${'\u0001'.repeat((agent === 'claude' ? 6 : 3) * 1024 * 1024)}MIDDLE:TAIL` + const claude = fakeClaude(), + codex = fakeCodex() + const codexSession = codexAdapter(codex) + const adapter = agent === 'claude' ? claudeAdapter(claude) : codexSession + const acquireInput = { + identity: agent === 'claude' ? claudeIdentity(SESSION) : codexIdentity(SESSION), + fence: 1, + spawnToken: 'whole-content', + events: deferred.sink + } + await (agent === 'codex' + ? acquireReadyCodexForTest(codexSession, acquireInput) + : adapter.acquire(acquireInput)) + if (agent === 'codex') { + codex.connections[0].handlers.onNotification?.('turn/started', { + threadId: THREAD_ID, + turn: { id: 'turn' } + }) + } + await deferred.drained() + const gate = Promise.withResolvers() + releases.push(() => gate.resolve()) + const cursor = rig.journal.cursor() + const snapshot = rig.journal.snapshot() + const blocked = rig.journal.readInOrder(() => { + expect( + deferred.sink.tryAppendItem?.( + { provider: 'legacy', agent: 'grok', sessionId: SESSION, recordId: 'before' }, + { kind: 'status', text: 'before', tone: 'info' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + ).toEqual({ accepted: true }) + return gate.promise + }) + expect(rig.journal.cursor()).toEqual(cursor) + expect(rig.journal.snapshot()).toEqual(snapshot) + const registration = vi.spyOn(rig.journal, 'appendItem') + if (agent === 'claude') { + claudeFrame(claude.connections[0], { + type: 'user', + uuid: 'large-result', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'received', content: text }] + } + }) + claude.connections[0].handlers.onExit?.(new Error('provider exited')) + } else { + codex.connections[0].handlers.onNotification?.('item/completed', { + threadId: THREAD_ID, + turnId: 'turn', + item: { + type: 'commandExecution', + id: 'received', + command: 'run', + status: 'completed', + aggregatedOutput: text + } + }) + codex.connections[0].handlers.onExit?.(new Error('provider exited')) + } + if ('drainObservedExits' in adapter) { + await adapter.drainObservedExits() + } + expect( + registration.mock.calls.some( + ([, body]) => body.kind === 'tool-call' && body.output?.head === text + ) + ).toBe(true) + expect(deferred.state().queuedBytes).toBeGreaterThan(Buffer.byteLength(JSON.stringify(text))) + expect(pressure).toHaveBeenCalledWith(true, expect.objectContaining({ backpressured: true })) + let landed = false + const written = deferred.sink.written?.().then((result) => { + landed = true + return result + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(rig.journal.cursor()).toEqual(cursor) + expect(rig.journal.snapshot()).toEqual(snapshot) + expect(landed).toBe(false) + gate.resolve() + await blocked + expect(await written).toEqual({ ok: true }) + await deferred.drained() + const reopened = await rig.reopenJournal() + const replay = reopened.readSince(cursor) + if (!replay.ok) { + throw new Error('Expected the held writes to remain replayable') + } + expect(replay.rows[0]).toMatchObject({ kind: 'item', body: { kind: 'status', text: 'before' } }) + expect( + replay.rows.findIndex( + (row) => + row.kind === 'item' && row.body.kind === 'tool-call' && row.body.state === 'completed' + ) + ).toBeGreaterThan(0) + expect( + reopened.snapshot().items.flatMap(({ body }) => (body.kind === 'tool-call' ? [body] : [])) + ).toContainEqual( + expect.objectContaining({ + callId: 'received', + state: 'completed', + output: expect.objectContaining({ head: text, truncated: false }) + }) + ) + expect(deferred.state().queuedBytes).toBe(0) + expect(pressure).toHaveBeenLastCalledWith( + false, + expect.objectContaining({ backpressured: false }) + ) + deferred.close() + } +) + +it('hands an ACP omitted-field completion to SQLite before root exit with a blocked predecessor', async () => { + const session = await openAcpAdapterRig() + const { rig, acquire, child, frame } = session + await acquire() + await sendHello(session, 'large') + await frame('session/prompt') + const input = { text: `${'i'.repeat(9 * 1024 * 1024)}INPUT:TAIL` } + const output = `${'o'.repeat(9 * 1024 * 1024)}OUTPUT:TAIL` + const start = shellCall('prompt:large', 'in_progress') + child().agent.notify('session/update', { ...start, update: { ...start.update, rawInput: input } }) + child().agent.notify('session/update', { + ...start, + update: { ...start.update, sessionUpdate: 'tool_call_update', rawOutput: output } + }) + await session.settle() + const gate = Promise.withResolvers() + releases.push(() => gate.resolve()) + const cursor = rig.journal.cursor() + const snapshot = rig.journal.snapshot() + const blocked = rig.journal.readInOrder(() => { + expect( + rig.eventSink.tryAppendItem?.( + { provider: 'legacy', agent: 'grok', sessionId: SESSION, recordId: 'before' }, + { kind: 'status', tone: 'info', text: 'before' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE, lifecycle: true } + ) + ).toEqual({ accepted: true }) + return gate.promise + }) + expect(rig.journal.cursor()).toEqual(cursor) + expect(rig.journal.snapshot()).toEqual(snapshot) + const registration = vi.spyOn(rig.journal, 'appendSteps') + child().agent.notify('session/update', shellCall('prompt:large', 'completed')) + await vi.waitFor(() => expect(registration).toHaveBeenCalled()) + child().exit() + let landed = false + const written = rig.eventSink.written?.().then((result) => { + landed = true + return result + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(rig.journal.cursor()).toEqual(cursor) + expect(rig.journal.snapshot()).toEqual(snapshot) + expect(landed).toBe(false) + gate.resolve() + await blocked + expect(await written).toEqual({ ok: true }) + const reopened = await rig.reopenJournal() + const replay = reopened.readSince(cursor) + if (!replay.ok) { + throw new Error('Expected the held writes to remain replayable') + } + expect(replay.rows[0]).toMatchObject({ kind: 'item', body: { kind: 'status', text: 'before' } }) + expect( + replay.rows.findIndex( + (row) => + row.kind === 'item' && row.body.kind === 'tool-call' && row.body.state === 'completed' + ) + ).toBeGreaterThan(0) + expect( + reopened.snapshot().items.flatMap(({ body }) => (body.kind === 'tool-call' ? [body] : [])) + ).toContainEqual( + expect.objectContaining({ + callId: 'sleep-1', + state: 'completed', + input, + output: expect.objectContaining({ head: output, truncated: false }) + }) + ) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 2ffc6c7e720..e1ba540ebc5 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -28,7 +28,7 @@ import { MAX_JOURNAL_LIFECYCLE_BATCH_BYTES, MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS } from './journal-row-schema' -import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' +import { boundInlineText, JOURNAL_DISPATCH_REASON_LIMITS } from './journal-payload-bounds' import { assertSubmissionIdUnused } from './journal-write-guards' import { turnEndAfterStop } from './journal-stop-turn-end' import { nextJournalItemRevision } from './journal-item-revision' @@ -168,7 +168,7 @@ function boundedDispatchReason(input: ResolveDispatchInput): string | null { if (input.state === 'accepted' || input.state === 'pending' || !input.reason) { return null } - return boundInlineText(input.reason, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + return boundInlineText(input.reason, JOURNAL_DISPATCH_REASON_LIMITS).text } export function journalLifecycleBatchRowBuilder( diff --git a/src/main/native-chat/agent-session-journal/journal-store.spec.ts b/src/main/native-chat/agent-session-journal/journal-store.spec.ts index b31509da54f..ef5d62cb48e 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.spec.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.spec.ts @@ -16,7 +16,7 @@ import { import { boundInlineText, boundPayload, - DEFAULT_JOURNAL_PAYLOAD_LIMITS + JOURNAL_RUNNING_PREVIEW_LIMITS } from './journal-payload-bounds' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' import { AgentSessionJournalError, type AgentSessionJournal } from './journal-store' @@ -320,7 +320,7 @@ export function registerJournalStoreTests(): void { describe('bounds', () => { it('marks a clipped payload instead of dropping bytes silently', () => { - const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 16 } + const limits = { inlineHeadBytes: 16 } const bounded = boundPayload('x'.repeat(4_096), limits) expect(bounded.truncated).toBe(true) expect(bounded.head).toHaveLength(16) @@ -329,7 +329,7 @@ export function registerJournalStoreTests(): void { }) it('never splits a multi-byte character across the bound', () => { - const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 4 } + const limits = { inlineHeadBytes: 4 } // Each character is three bytes, so a naive slice would land mid-sequence. const bounded = boundPayload('日本語テスト', limits) expect(bounded.head).toBe('日') @@ -337,10 +337,10 @@ export function registerJournalStoreTests(): void { }) it('leaves a payload inside the bound untouched', () => { - const bounded = boundPayload('small', DEFAULT_JOURNAL_PAYLOAD_LIMITS) + const bounded = boundPayload('small', JOURNAL_RUNNING_PREVIEW_LIMITS) expect(bounded.truncated).toBe(false) expect(bounded.head).toBe('small') - expect(boundInlineText('small', DEFAULT_JOURNAL_PAYLOAD_LIMITS).text).toBe('small') + expect(boundInlineText('small', JOURNAL_RUNNING_PREVIEW_LIMITS).text).toBe('small') }) }) diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts index 79c1aea2fab..973d9573b22 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler-test-support.ts @@ -136,6 +136,7 @@ export function messageText(body: AgentJournalItemBody | undefined): string | un const journals = createTrackedJournalOpener() const cleanups: (() => Promise)[] = [] +const roots: string[] = [] /** Call from `afterEach`. */ export async function closeProviderTimelineRigs(): Promise { @@ -143,6 +144,9 @@ export async function closeProviderTimelineRigs(): Promise { await cleanup() } await journals.closeAll() + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }) + } } /** An assembler whose sink binds to the rig's journal only when `bind` is called, as a lane that @@ -228,6 +232,7 @@ export async function openProviderTimelineRig( overrides: Partial = {} ): Promise { const root = await mkdtemp(join(tmpdir(), 'orca-provider-timeline-')) + roots.push(root) const journalOptions: TestJournalOptions = { identity: { sessionId: SESSION, @@ -244,7 +249,6 @@ export async function openProviderTimelineRig( deferred.bind({ journal, fence: 1, publish: () => {} }) cleanups.push(async () => { deferred.close() - await rm(root, { recursive: true, force: true }) }) const sink = providerTimelineSink(deferred.sink) if (!sink) { diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts index eda78997e6d..af13cb40aa6 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-text-streams.ts @@ -17,10 +17,6 @@ import { type AgentJournalProducerLinkage, type AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' -import { - boundInlineText, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from '../agent-session-journal/journal-payload-bounds' import { createAgentSessionDeltaCoalescer, type AgentSessionDeltaCoalescerDeps @@ -241,8 +237,7 @@ export class ProviderTimelineTextStreams { this.planText(plan, stream, owed.snapshot.text) } } else { - const text = boundInlineText(finalText, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text - this.planText(plan, stream, text, true) + this.planText(plan, stream, finalText, true) } this.planRelease(plan, (each) => each === stream) } diff --git a/src/main/native-chat/agent-session-wire/agent-session-bounded-frame-item.ts b/src/main/native-chat/agent-session-wire/agent-session-bounded-frame-item.ts index d1dc03b9456..1b8e0ebcfcf 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-bounded-frame-item.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-bounded-frame-item.ts @@ -5,18 +5,15 @@ import type { AgentJournalRenderItem } from '../../../shared/agent-session-journ import { boundJournalKeyComponent } from '../../../shared/agent-session-journal-item-key' import { BOUNDED_FRAME_CONTENT_BUDGET_BYTES, + measureHistoryItem, + type HistoryItemForm, oversizedHistoryItem } from './agent-session-history-page-bounds' -import { jsonBytes, shortenJsonStringsToFit } from './json-string-shortening' +import { shortenedAgentJournalItem } from './agent-session-item-wire-form' /** One row's share of a page, so a page always carries several rows. */ export const BOUNDED_FRAME_ROW_BYTES = BOUNDED_FRAME_CONTENT_BUDGET_BYTES / 8 -/** What the body may weigh once the rest of the row is paid for. */ -function bodyBudget(item: AgentJournalRenderItem): number { - return BOUNDED_FRAME_ROW_BYTES - (jsonBytes({ ...item, body: null }) - jsonBytes(null)) -} - /** The too-large marker with every identifier it carries held to a key's bound, so the marker * itself always fits a row. Provenance a reader never joins on is dropped. */ function oversizedFrameItem(item: AgentJournalRenderItem): AgentJournalRenderItem { @@ -41,21 +38,16 @@ function oversizedFrameItem(item: AgentJournalRenderItem): AgentJournalRenderIte } } -function boundFrameItem(item: AgentJournalRenderItem): AgentJournalRenderItem { - const body = shortenJsonStringsToFit(item.body, bodyBudget(item)) - if (body === null) { - return oversizedFrameItem(item) - } - return body === item.body ? item : { ...item, body } -} - // Items are immutable per revision, so a catch-up or a second subscriber reuses the bound form. -const bounded = new WeakMap() +const bounded = new WeakMap() -export function boundedFrameItem(item: AgentJournalRenderItem): AgentJournalRenderItem { +export function boundedFrameItem(formed: HistoryItemForm): HistoryItemForm { + const { item, bytes } = formed let result = bounded.get(item) if (result === undefined) { - result = boundFrameItem(item) + const fitted = + shortenedAgentJournalItem(item, BOUNDED_FRAME_ROW_BYTES, bytes) ?? oversizedFrameItem(item) + result = fitted === item ? formed : measureHistoryItem(fitted) bounded.set(item, result) } return result diff --git a/src/main/native-chat/agent-session-wire/agent-session-bounded-frames.test.ts b/src/main/native-chat/agent-session-wire/agent-session-bounded-frames.test.ts index 236e36028ad..6349fde3b45 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-bounded-frames.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-bounded-frames.test.ts @@ -135,13 +135,15 @@ describe('a bounded row', () => { }) ] for (const row of rows) { - expect(jsonBytes(boundedFrameItem(row))).toBeLessThanOrEqual(BOUNDED_FRAME_ROW_BYTES) + const formed = boundedFrameItem({ item: row, bytes: jsonBytes(row) }) + expect(formed.bytes).toBe(jsonBytes(formed.item)) + expect(formed.bytes).toBeLessThanOrEqual(BOUNDED_FRAME_ROW_BYTES) } }) it('is the stored row when it already fits', () => { const row = rendered({ body: message('hello') }) - expect(boundedFrameItem(row)).toBe(row) + expect(boundedFrameItem({ item: row, bytes: jsonBytes(row) }).item).toBe(row) }) }) diff --git a/src/main/native-chat/agent-session-wire/agent-session-bounded-frames.ts b/src/main/native-chat/agent-session-wire/agent-session-bounded-frames.ts index 66e452b453b..6359fc1c2b2 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-bounded-frames.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-bounded-frames.ts @@ -16,20 +16,20 @@ import { boundedQueuePublication } from './agent-session-bounded-queue' import { emptyAgentSessionBatch } from './agent-session-empty-batch' import { BOUNDED_FRAME_CONTENT_BUDGET_BYTES, - STORED_HISTORY_READER, type HistoryReader } from './agent-session-history-page-bounds' +import { agentJournalItemForWire, CLIENT_HISTORY_READER } from './agent-session-item-wire-form' import { jsonBytes, shortenJsonStringsToFit } from './json-string-shortening' import type { QueuePublication } from './structured-agent-session-queued-publication' const BOUNDED_FRAME_READER: HistoryReader = { - form: boundedFrameItem, + form: (item) => boundedFrameItem(agentJournalItemForWire(item)), budgetBytes: BOUNDED_FRAME_CONTENT_BUDGET_BYTES } /** How a subscriber's rows are read, and so how its pages are sized. */ export function subscriberHistoryReader(subscriber: { boundedFrames: boolean }): HistoryReader { - return subscriber.boundedFrames ? BOUNDED_FRAME_READER : STORED_HISTORY_READER + return subscriber.boundedFrames ? BOUNDED_FRAME_READER : CLIENT_HISTORY_READER } /** The latest turn and the activity line are each a few hundred bytes, their ids and text bounded diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page-bounds.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page-bounds.ts index b0694726f78..6c65e2bd3c6 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page-bounds.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page-bounds.ts @@ -65,32 +65,40 @@ export function oversizedHistoryItem(item: AgentJournalRenderItem): AgentJournal } } +export type HistoryItemForm = { + item: AgentJournalRenderItem + bytes: number +} + +export function measureHistoryItem(item: AgentJournalRenderItem): HistoryItemForm { + return { item, bytes: Buffer.byteLength(JSON.stringify(item), 'utf8') } +} + /** How a reader receives each item, and the bytes its page may hold of them. A page is measured * in that form, so it carries what it will actually send. */ export type HistoryReader = { - form: (item: AgentJournalRenderItem) => AgentJournalRenderItem + form: (item: AgentJournalRenderItem) => HistoryItemForm budgetBytes: number } -export const STORED_HISTORY_READER: HistoryReader = { - form: (item) => item, - budgetBytes: HISTORY_PAGE_CONTENT_BUDGET_BYTES -} - /** Rows are put in `form` only as the page takes them. */ export function boundHistoryItemsByBytes( items: AgentJournalRenderItem[], keep: 'newest' | 'oldest', submissionBytes: ReadonlyMap, maxBytes: number, - form: HistoryReader['form'] = STORED_HISTORY_READER.form + form: HistoryReader['form'] = measureHistoryItem ): { items: AgentJournalRenderItem[]; dropped: number } { const ordered = groupItemsBySequence(items, keep) const kept: AgentJournalRenderItem[][] = [] let total = 0 for (const stored of ordered) { - const group = stored.map(form) - const bytes = group.reduce((sum, item) => sum + historyEntryBytes(item, submissionBytes), 0) + const formed = stored.map(form) + const group = formed.map(({ item }) => item) + const bytes = formed.reduce( + (sum, { item, bytes }) => sum + bytes + (submissionBytes.get(item.itemId) ?? 0), + 0 + ) if (kept.length === 0 && bytes > maxBytes) { kept.push(group.map((item) => oversizedHistoryItem(item))) break diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts index de31297ee25..5bd7c33cb23 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts @@ -310,7 +310,7 @@ describe('history page byte ceiling', () => { expect(new Set(seen).size).toBe(20) }) - it('degrades a single over-budget item to a visible truncation marker instead of overflowing', async () => { + it('sends a single over-budget item shortened and marked, never replaced', async () => { await journal.appendItem(item(1), body(`1:${'y'.repeat(3 * 1024 * 1024)}`), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE @@ -320,12 +320,14 @@ describe('history page byte ceiling', () => { readAgentSessionHistory(journal, { sessionId: 'session-1', direction: 'tail', limit: 40 }) ) expect(tail.items).toHaveLength(1) - // Named for the client to word, with English for a client that can't. - expect(tail.items[0]?.body).toEqual({ - kind: 'status', - text: 'This part of the chat was too large to show.', - presentation: 'history-item-too-large' - }) + const sent = tail.items[0] + const text = + sent?.body.kind === 'message' && sent.body.blocks[0]?.type === 'text' + ? sent.body.blocks[0].text + : '' + expect(sent?.shortened).toBe(true) + expect(text.startsWith('1:yyy')).toBe(true) + expect(text.length).toBeLessThan(3 * 1024 * 1024) }) }) diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts index 8e2b7393362..cfbbf548e11 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts @@ -28,11 +28,11 @@ import { } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { projectJournalBatch } from './agent-session-journal-batch' +import { CLIENT_HISTORY_READER } from './agent-session-item-wire-form' import { boundHistoryItemsByBytes, - STORED_HISTORY_READER, type HistoryReader, - historyEntryBytes, + measureHistoryItem, newestWholeSequenceGroups, wholeSubagentGroupsStart, oversizedHistoryItem, @@ -95,7 +95,7 @@ export function readAgentSessionHistory( { snapshot = journal.snapshot(), scope = 'every-agent', - reader = STORED_HISTORY_READER + reader = CLIENT_HISTORY_READER }: { /** Reduced state to read against. A synchronous multi-page catch-up passes one * snapshot for the whole run so each page costs its own rows, not the timeline. */ @@ -152,7 +152,7 @@ export function readAgentSessionHistory( */ export function createAgentSessionCatchUpReader( journal: AgentSessionJournal, - reader: HistoryReader = STORED_HISTORY_READER + reader: HistoryReader = CLIENT_HISTORY_READER ): (request: AgentSessionHistoryRequest) => AgentSessionHistoryResult { let snapshot = journal.snapshot() return (request) => { @@ -167,7 +167,7 @@ export function createAgentSessionCatchUpReader( export function readAgentSessionHydrationPage( journal: AgentSessionJournal, fence?: number, - reader: HistoryReader = STORED_HISTORY_READER + reader: HistoryReader = CLIENT_HISTORY_READER ): AgentSessionHistoryPage { return buildHydrationPage(journal.snapshot(), fence, reader) } @@ -235,10 +235,13 @@ function readForward( // submissions AND removal ids — a legal pre-bounding tombstone id can dwarf // every item on the page. const pageContentBytes = ( - items: readonly AgentJournalRenderItem[], + items: readonly ReturnType[], removedItemIds: readonly string[] ): number => - items.reduce((total, item) => total + historyEntryBytes(item, submissionBytes), 0) + + items.reduce( + (total, { item, bytes }) => total + bytes + (submissionBytes.get(item.itemId) ?? 0), + 0 + ) + removedItemIds.reduce( (total, itemId) => total + Buffer.byteLength(JSON.stringify(itemId), 'utf8') + 1, 0 @@ -247,7 +250,7 @@ function readForward( // clipping projected items: dropping an item while advancing the cursor past // the rows that touched it would lose that revision for good. let rows = since.rows.slice(0, limit) - const project = (window: typeof rows): ReturnType => { + const project = (window: typeof rows) => { const result = projectJournalBatch({ rows: window, snapshot, @@ -275,9 +278,11 @@ function readForward( // One row can still touch an over-budget item; degrade it visibly. let items = projected.batch.items if (contentBytes > reader.budgetBytes) { - items = items.map((item) => { - const bytes = historyEntryBytes(item, submissionBytes) - return bytes > reader.budgetBytes ? oversizedHistoryItem(item) : item + items = items.map((formed) => { + const bytes = formed.bytes + (submissionBytes.get(formed.item.itemId) ?? 0) + return bytes > reader.budgetBytes + ? measureHistoryItem(oversizedHistoryItem(formed.item)) + : formed }) contentBytes = pageContentBytes(items, projected.batch.removedItemIds) } @@ -295,7 +300,7 @@ function readForward( page: buildPage({ snapshot, direction: 'after', - items, + items: items.map(({ item }) => item), removedItemIds: projected.batch.removedItemIds, // Reading after a position means there is something before it. hasOlder: cursor.sequence > 0, diff --git a/src/main/native-chat/agent-session-wire/agent-session-item-wire-form.test.ts b/src/main/native-chat/agent-session-wire/agent-session-item-wire-form.test.ts new file mode 100644 index 00000000000..068c28220c2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-item-wire-form.test.ts @@ -0,0 +1,21 @@ +import { expect, it } from 'vitest' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { journalPayload } from '../agent-session-journal/journal-payload-bounds' +import { HISTORY_PAGE_CONTENT_BUDGET_BYTES } from './agent-session-history-page-bounds' +import { agentJournalItemForWire, shortenedAgentJournalItem } from './agent-session-item-wire-form' + +it('preserves the full previously readable desktop message range', () => { + const item: AgentJournalRenderItem = { itemId: 'message', revision: 1, sequence: 1, observedAt: 1, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `${'x'.repeat(1_200_000)}TAIL` }] } } + const formed = agentJournalItemForWire(item) + expect(formed.item).toBe(item) + expect(formed.bytes).toBe(Buffer.byteLength(JSON.stringify(item), 'utf8')) +}) + +it.each([73_728, HISTORY_PAGE_CONTENT_BUDGET_BYTES, 4 * 1024 * 1024])('keeps tool identity and output when cardinality exceeds %i bytes', (budget) => { + const item: AgentJournalRenderItem = { itemId: 'call', revision: 1, sequence: 1, observedAt: 1, + body: { kind: 'tool-call', callId: 'call', name: 'query', state: 'completed', input: { values: Array(Math.ceil(budget / 2) + 1_000).fill(0) }, output: journalPayload('done') } } + const sent = shortenedAgentJournalItem(item, budget) + expect(sent).toMatchObject({ itemId: 'call', shortened: true, body: { kind: 'tool-call', name: 'query', input: { truncated: true }, output: { head: 'done' } } }) + expect(Buffer.byteLength(JSON.stringify(sent))).toBeLessThanOrEqual(budget) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-item-wire-form.ts b/src/main/native-chat/agent-session-wire/agent-session-item-wire-form.ts new file mode 100644 index 00000000000..4d99b701c3d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-item-wire-form.ts @@ -0,0 +1,101 @@ +// One journal item as every client receives it. +// +// The journal keeps tool output and diffs whole. A client is sent a head of each and the item is +// marked `shortened`; it fetches the whole item with `agentSession.item` when the user asks. Every +// other field is sent as kept. An item still past a page is shortened to fit and marked +// rather than replaced, so its row and its "show full" stay readable. + +import type { + AgentJournalBoundedPayload, + AgentJournalItemBody, + AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' +import { boundToolInput, JOURNAL_RUNNING_PREVIEW_LIMITS, utf8Head } from '../agent-session-journal/journal-payload-bounds' +import { + HISTORY_PAGE_CONTENT_BUDGET_BYTES, + type HistoryItemForm, + type HistoryReader +} from './agent-session-history-page-bounds' +import { jsonBytes, shortenJsonStringsToFit } from './json-string-shortening' + +/** Each tool output or diff as sent: the head older clients have always been sent. */ +export const AGENT_SESSION_WIRE_PAYLOAD_HEAD_BYTES = 16 * 1024 + +/** Preserve every ordinary message that already fits a desktop page. */ +const CLIENT_ITEM_MAX_BYTES = HISTORY_PAGE_CONTENT_BUDGET_BYTES + +/** The payload as sent; itself when its head already fits. */ +function wirePayload(payload: AgentJournalBoundedPayload): AgentJournalBoundedPayload { + // UTF-8 spends at most three bytes per UTF-16 unit, so most heads fit without encoding. + if ( + payload.head.length * 3 <= AGENT_SESSION_WIRE_PAYLOAD_HEAD_BYTES || + Buffer.byteLength(payload.head, 'utf8') <= AGENT_SESSION_WIRE_PAYLOAD_HEAD_BYTES + ) { + return payload + } + return { + ...payload, + head: utf8Head(payload.head, AGENT_SESSION_WIRE_PAYLOAD_HEAD_BYTES), + truncated: true + } +} + +function wireBody(body: AgentJournalItemBody): AgentJournalItemBody { + if (body.kind === 'tool-call' && body.output) { + const output = wirePayload(body.output) + return output === body.output ? body : { ...body, output } + } + if (body.kind === 'diff') { + const patch = wirePayload(body.patch) + return patch === body.patch ? body : { ...body, patch } + } + return body +} + +/** `item` fitted to `maxBytes`: its long strings shortened and the item marked. Null when even + * that cannot fit, which the page answers with its too-large marker. */ +export function shortenedAgentJournalItem( + item: AgentJournalRenderItem, + maxBytes: number, + bytes = jsonBytes(item) +): AgentJournalRenderItem | null { + if (bytes <= maxBytes) { + return item + } + const budget = maxBytes - (jsonBytes({ ...item, body: null, shortened: true }) - jsonBytes(null)) + let body = shortenJsonStringsToFit(item.body, budget) + if (body === null && item.body.kind === 'tool-call') { + body = shortenJsonStringsToFit( + { ...item.body, input: boundToolInput(item.body.input, JOURNAL_RUNNING_PREVIEW_LIMITS) }, + budget + ) + } + return body === null ? null : { ...item, body, shortened: true } +} + +function wireItem(item: AgentJournalRenderItem): HistoryItemForm { + const body = wireBody(item.body) + const sent = body === item.body ? item : { ...item, body, shortened: true as const } + const bytes = jsonBytes(sent) + const fitted = shortenedAgentJournalItem(sent, CLIENT_ITEM_MAX_BYTES, bytes) ?? sent + return { item: fitted, bytes: fitted === sent ? bytes : jsonBytes(fitted) } +} + +// Items are immutable per revision, so a catch-up or a second subscriber reuses the sent form. +const sentForms = new WeakMap() + +/** What a client is sent of `item`. */ +export function agentJournalItemForWire(item: AgentJournalRenderItem): HistoryItemForm { + let sent = sentForms.get(item) + if (sent === undefined) { + sent = wireItem(item) + sentForms.set(item, sent) + } + return sent +} + +/** How a desktop or paired client reads history: each item in its sent form. */ +export const CLIENT_HISTORY_READER: HistoryReader = { + form: agentJournalItemForWire, + budgetBytes: HISTORY_PAGE_CONTENT_BUDGET_BYTES +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.ts b/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.ts index 793969c7b1f..e8d228e941e 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-stop-note-refresh.ts @@ -1,7 +1,7 @@ import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { oversizedHistoryItem } from './agent-session-history-page-bounds' +import { measureHistoryItem, oversizedHistoryItem } from './agent-session-history-page-bounds' import { emptyAgentSessionBatch } from './agent-session-empty-batch' import { subscriberHistoryReader } from './agent-session-bounded-frames' import { @@ -69,16 +69,16 @@ export function refreshDerivedStopNotes( continue } const formed = form(item) - const projectedBytes = Buffer.byteLength(JSON.stringify(formed), 'utf8') - const bounded = projectedBytes > budgetBytes ? oversizedHistoryItem(formed) : formed - const itemBytes = Buffer.byteLength(JSON.stringify(bounded), 'utf8') + 1 + const bounded = + formed.bytes > budgetBytes ? measureHistoryItem(oversizedHistoryItem(formed.item)) : formed + const itemBytes = bounded.bytes + 1 if ( pending.length > 0 && (pending.length === AGENT_SESSION_HISTORY_MAX_LIMIT || bytes + itemBytes > budgetBytes) ) { emit() } - pending.push(bounded) + pending.push(bounded.item) bytes += itemBytes } if (pending.length > 0 && port.isActive(subscriber)) { diff --git a/src/main/native-chat/agent-session-wire/hook-failure-provider-frame.test.ts b/src/main/native-chat/agent-session-wire/hook-failure-provider-frame.test.ts index 2e47b6b6c2d..c5e567cd2f9 100644 --- a/src/main/native-chat/agent-session-wire/hook-failure-provider-frame.test.ts +++ b/src/main/native-chat/agent-session-wire/hook-failure-provider-frame.test.ts @@ -172,7 +172,7 @@ describe('failed hook provider frames', () => { ).toBeNull() }) - it('bounds long hook error text and raw Details through the existing payload limits', () => { + it('keeps long hook error text whole and bounds only the raw Details', () => { const row = unhandledProviderFrameJournalItem( 'codex', CODEX_KIND, @@ -181,8 +181,7 @@ describe('failed hook provider frames', () => { }, { inlineHeadBytes: 64 } ) - expect(row?.body.text).toContain('Codex hook failed: ') - expect(row?.body.text).toContain('[Orca: output truncated') + expect(row?.body.text).toBe(`Codex hook failed: ${'x'.repeat(1000)}`) expect(row?.body.providerFrame?.payload).toMatchObject({ truncated: true }) expect( Buffer.byteLength(row?.body.providerFrame?.payload.head ?? '', 'utf8') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index 3d5d9847400..b202082faab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -176,15 +176,11 @@ export class StructuredAgentSessionSinkQueue { this.lifecycleQueuedBytes - (replaced ? lifecycleCost(replaced) : 0) + lifecycleBytes const nextLifecycleOperations = this.lifecycleQueuedOperations - (replaced?.lifecycle ? 1 : 0) + (lifecycle ? 1 : 0) + // Received bytes pace future input; only the existing operation limits refuse work. const exceedsOrdinary = - !finalTail && - !lifecycle && - (nextBytes > this.deps.watermarks.maxQueuedBytes || - nextOperations > this.deps.watermarks.maxQueuedOperations) + !finalTail && !lifecycle && nextOperations > this.deps.watermarks.maxQueuedOperations const exceedsLifecycle = - lifecycle && - (nextLifecycleBytes > this.deps.watermarks.maxLifecycleQueuedBytes || - nextLifecycleOperations > this.deps.watermarks.maxLifecycleQueuedOperations) + lifecycle && nextLifecycleOperations > this.deps.watermarks.maxLifecycleQueuedOperations if (exceedsOrdinary || exceedsLifecycle) { this.setBackpressure(true) return { accepted: false, reason: 'backpressure' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index 3a443559e2e..7c28cf3912e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -20,7 +20,10 @@ import { } from './structured-agent-session-event-sink' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { + closeTestJournalHostDatabase, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import { createCodexJournalTranslator } from '../../codex/codex-structured-journal-translation' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { testEventSinkLogging } from './structured-agent-session-logger-test-support' @@ -484,6 +487,7 @@ describe('deferred structured agent-session event sink', () => { agentJournalItemKey(identity(0)) ]) await journal.close() + closeTestJournalHostDatabase(root) await rm(root, { recursive: true, force: true }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.test.ts index 1c33de1ce12..6def47daea3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.test.ts @@ -2,24 +2,16 @@ import { describe, expect, it } from 'vitest' import { StructuredAgentSessionFinalTailReservation } from './structured-agent-session-final-tail-reservation' describe('finalized provider tail reservations', () => { - it('bounds outstanding bytes and releases each admission once', () => { + it('admits a large captured tail while bounding operations and releasing each admission once', () => { const reservation = new StructuredAgentSessionFinalTailReservation() const release = reservation.reserve(16 * 1024 * 1024) expect(release).not.toBeNull() - expect(reservation.reserve(1)).toBeNull() + const rest = Array.from({ length: 511 }, () => reservation.reserve(1)) + expect(rest.every(Boolean)).toBe(true) + expect(reservation.reserve(0)).toBeNull() release?.() release?.() expect(reservation.reserve(16 * 1024 * 1024)).not.toBeNull() - expect(reservation.reserve(1)).toBeNull() - }) - - it('bounds operations even when they write no bytes', () => { - const reservation = new StructuredAgentSessionFinalTailReservation() - const releases = Array.from({ length: 512 }, () => reservation.reserve(0)) - expect(releases.every(Boolean)).toBe(true) - expect(reservation.reserve(0)).toBeNull() - releases[0]?.() - expect(reservation.reserve(0)).not.toBeNull() expect(reservation.reserve(0)).toBeNull() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.ts index 92b8e0f897f..c64b16ce91d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.ts @@ -1,7 +1,6 @@ -const MAX_FINAL_TAIL_BYTES = 16 * 1024 * 1024 const MAX_FINAL_TAIL_OPERATIONS = 512 -/** Finalized providers can admit their bounded tail before the host takes its exit barrier. */ +/** A sealed lane transfers already-observed work before the host takes its exit barrier. */ export class StructuredAgentSessionFinalTailReservation { private bytes = 0 private operations = 0 @@ -12,7 +11,6 @@ export class StructuredAgentSessionFinalTailReservation { this.closed || !Number.isSafeInteger(bytes) || bytes < 0 || - this.bytes + bytes > MAX_FINAL_TAIL_BYTES || this.operations >= MAX_FINAL_TAIL_OPERATIONS ) { return null diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts index dee7c5cc217..b203aee4264 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts @@ -152,7 +152,7 @@ describe('unhandled provider frame journal fallback', () => { expect(row?.body.providerFrame?.kind).toBe('notification:warning') }) - it('bounds a provider sentence inline', () => { + it('keeps the provider sentence whole while the raw frame keeps a head', () => { const message = 'abcdefghij' const row = unhandledProviderFrameJournalItem( 'codex', @@ -161,8 +161,8 @@ describe('unhandled provider frame journal fallback', () => { { inlineHeadBytes: 8 } ) - expect(row?.body.text).toContain('abcdefgh') - expect(row?.body.text).toContain('[Orca: output truncated') + expect(row?.body.text).toBe(message) + expect(row?.body.providerFrame?.payload.truncated).toBe(true) }) it('unwraps a nested sentence and falls back to the opcode when there is none', () => { diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts index fca7fb88b0a..b1cefaf4242 100644 --- a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts @@ -1,9 +1,8 @@ import type { AgentJournalPlainStatusItem } from '../../../shared/agent-session-journal-types' import { CLAUDE_LOCAL_COMMAND_OUTPUT_FRAME_KIND } from '../../../shared/native-chat-provider-frame-summary' import { - boundInlineText, boundPayload, - DEFAULT_JOURNAL_PAYLOAD_LIMITS, + PROVIDER_FRAME_PAYLOAD_LIMITS, type JournalPayloadLimits } from '../agent-session-journal/journal-payload-bounds' import { codexGoalRowText, codexThreadGoalState } from '../../codex/codex-goal-journal-rows' @@ -118,12 +117,13 @@ function hookFailureFrameText( return null } -/** Substantive adapter fallbacks become bounded journal rows; a chat draws one only with a sentence. */ +/** Substantive adapter fallbacks become journal rows; a chat draws one only with a sentence. The + * row's readable text is kept whole. */ export function unhandledProviderFrameJournalItem( provider: string, kind: string, payload: unknown, - limits: JournalPayloadLimits = DEFAULT_JOURNAL_PAYLOAD_LIMITS, + limits: JournalPayloadLimits = PROVIDER_FRAME_PAYLOAD_LIMITS, options: UnhandledProviderFrameJournalItemOptions = {} ): UnhandledProviderFrameJournalItem | null { const classification = @@ -179,32 +179,15 @@ export function unhandledProviderFrameJournalItem( message = typeof content === 'string' && content.trim() ? content.trim() : message } const goalText = provider === 'codex' ? codexGoalRowText(method, payload) : null - const display = message ? boundInlineText(message, limits) : null - const goalDisplay = goalText ? boundInlineText(goalText, limits) : null const threadGoal = provider === 'codex' ? codexThreadGoalState(method, payload) : null return { body: { kind: 'status', - text: compaction - ? 'Context compacted' - : (goalDisplay?.text ?? display?.text ?? `${provider} · ${kind}`), + text: compaction ? 'Context compacted' : goalText || message || `${provider} · ${kind}`, ...(compaction ? { presentation: 'compaction' } : {}), ...(tone ? { tone } : {}), providerFrame: { provider, kind, payload: bounded }, - ...(threadGoal - ? { - threadGoal: - threadGoal.state === 'set' - ? { - state: 'set' as const, - goal: { - ...threadGoal.goal, - objective: boundInlineText(threadGoal.goal.objective, limits).text - } - } - : threadGoal - } - : {}) + ...(threadGoal ? { threadGoal } : {}) }, classification: classification === 'error-surface' ? 'error-surface' : 'timeline-substantive' } diff --git a/src/main/pi/rpc-messages.ts b/src/main/pi/rpc-messages.ts index c463f49c8e1..bb4acc983d9 100644 --- a/src/main/pi/rpc-messages.ts +++ b/src/main/pi/rpc-messages.ts @@ -1,9 +1,11 @@ import { z } from 'zod' +import { BoundedMap } from '../../shared/bounded-map' +import { MAX_PROVIDER_TIMELINE_OPEN_ENTRIES } from '../native-chat/agent-session-timeline/provider-timeline-budget' import type { AgentJournalToolCallItem } from '../../shared/agent-session-journal-types' import { - boundPayload, boundToolInput, - DEFAULT_JOURNAL_PAYLOAD_LIMITS + JOURNAL_RUNNING_PREVIEW_LIMITS, + journalToolPayload } from '../native-chat/agent-session-journal/journal-payload-bounds' import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' import { piRpcMessageEventSchema, piRpcToolEventSchema } from './rpc-protocol' @@ -19,6 +21,17 @@ export class PiRpcMessages { private active?: number private readonly text = new Map() private readonly tools = new Map() + // Closing events take these latest full fields; running journal rows retain only previews. + private readonly whole = new BoundedMap({ + maxEntries: MAX_PROVIDER_TIMELINE_OPEN_ENTRIES, + sizeOf: (value, key) => value.inputBytes + value.outputBytes + Buffer.byteLength(key) + 64 + }) + + get retainedToolBytes(): number { + return this.whole.retainedBytes + } constructor( private readonly generation: string, @@ -29,6 +42,7 @@ export class PiRpcMessages { this.active = undefined this.text.clear() this.tools.clear() + this.whole.clear() } message(value: unknown, at: number): ProviderTimelineEvent[] { @@ -101,11 +115,12 @@ export class PiRpcMessages { } const event = parsed.data const previous = this.tools.get(event.toolCallId) + const held = this.whole.get(event.toolCallId) const result = event.result ?? event.partialResult const content = resultSchema.safeParse(result) const output = result === undefined - ? undefined + ? held?.output : content.success ? content.data.content .flatMap((block) => (block.text === undefined ? [] : [block.text])) @@ -113,21 +128,29 @@ export class PiRpcMessages { : JSON.stringify(result) const state = event.type === 'tool_execution_end' ? (event.isError ? 'failed' : 'completed') : 'running' + const input = event.args === undefined ? held?.input : event.args const body: AgentJournalToolCallItem = { kind: 'tool-call', name: event.toolName, callId: event.toolCallId, input: - event.args === undefined + input === undefined ? (previous?.input ?? null) - : boundToolInput(event.args, DEFAULT_JOURNAL_PAYLOAD_LIMITS), + : state === 'running' + ? event.args === undefined && previous + ? previous.input + : boundToolInput(input, JOURNAL_RUNNING_PREVIEW_LIMITS) + : input, state, ...(output === undefined ? previous?.output ? { output: previous.output } : {} : { - output: boundPayload(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + output: + state === 'running' && result === undefined && previous?.output + ? previous.output + : journalToolPayload(output, state === 'running') }) } if (state === 'running') { @@ -135,6 +158,18 @@ export class PiRpcMessages { throw new Error('Pi tool stream capacity exceeded') } this.tools.set(event.toolCallId, body) + if (!held && this.whole.size >= MAX_PROVIDER_TIMELINE_OPEN_ENTRIES) { + throw new Error('Pi tool stream capacity exceeded') + } + if (!this.whole.set(event.toolCallId, { + input, output, + inputBytes: event.args === undefined ? (held?.inputBytes ?? 0) + : Buffer.byteLength(JSON.stringify(input) ?? ''), + outputBytes: result === undefined ? (held?.outputBytes ?? 0) + : Buffer.byteLength(JSON.stringify(output) ?? '') + })) { + throw new Error('Pi tool snapshot could not be retained') + } if ( [...this.tools.values()].reduce( (bytes, tool) => bytes + Buffer.byteLength(JSON.stringify(tool)), @@ -146,6 +181,7 @@ export class PiRpcMessages { } } else { this.tools.delete(event.toolCallId) + this.whole.delete(event.toolCallId) } return [ { diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index 557b6734330..f3b7153f59d 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { JsonlRpcResponseError } from '../jsonl-rpc/peer' @@ -45,42 +46,52 @@ describe('Pi RPC session ownership and delivery', () => { expect(h.connections).toHaveLength(2) }) - it('retains final events when exit publication times out under journal backpressure', async () => { - let blocked = true - const h = await setup({}, (sink) => ({ - ...sink, - tryAppendTransition: (transition) => - blocked - ? { accepted: false, reason: 'backpressure' } - : sink.tryAppendTransition!(transition) - })) - vi.useFakeTimers() - h.connection.receive({ - type: 'extension_ui_request', - id: 'last-dialog', - method: 'input', - title: 'Final tail' - }) - h.connection.exit() - await vi.advanceTimersByTimeAsync(2_001) - expect(h.lifecycle).toHaveBeenCalledOnce() - h.adapter.acknowledgeSessionRelease(sessionId) - let completed = false - const drained = h.adapter.drainObservedExits().then(() => { - completed = true - }) - await Promise.resolve() - expect(completed).toBe(false) - blocked = false - await vi.advanceTimersByTimeAsync(250) - await drained - expect( - (await h.rig.rows()).some( - (row) => row.body.kind === 'question' && row.body.question === 'Final tail' - ) - ).toBe(true) - vi.useRealTimers() + it('persists final events after exit while journal writes are blocked', async () => { + const h = await setup() + const gate = Promise.withResolvers() + try { + const blocked = h.rig.journal.readInOrder(() => { + expect( + h.rig.eventSink.tryAppendItem?.( + { provider: 'legacy', agent: 'pi', sessionId, recordId: 'before' }, + { kind: 'status', tone: 'info', text: 'before' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + ).toEqual({ accepted: true }) + return gate.promise + }) + vi.useFakeTimers() + h.connection.receive({ + type: 'extension_ui_request', + id: 'last-dialog', + method: 'input', + title: 'Final tail' + }) + h.connection.exit() + let completed = false + const written = h.rig.eventSink.written?.().then((result) => { + completed = true + return result + }) + await vi.advanceTimersByTimeAsync(2_001) + expect(h.lifecycle).toHaveBeenCalledOnce() + h.adapter.acknowledgeSessionRelease(sessionId) + await h.adapter.drainObservedExits() + expect(completed).toBe(false) + gate.resolve() + await blocked + expect(await written).toEqual({ ok: true }) + expect( + (await h.rig.rows()).some( + (row) => row.body.kind === 'question' && row.body.question === 'Final tail' + ) + ).toBe(true) + } finally { + gate.resolve() + vi.useRealTimers() + } }) + it('lets a new acquisition proceed while the released child drains final stdout', async () => { const h = await setup() h.connection.deferExit = true diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts index 9bb1e6cc3a6..2d82202378e 100644 --- a/src/main/pi/rpc-session.ts +++ b/src/main/pi/rpc-session.ts @@ -244,11 +244,10 @@ export class PiRpcSession { this.failedCause ??= error this.dialogs.cancelAll() this.turns.end() - this.lane.finalize() - this.lane.apply([ - { type: 'session.ended', verdict: { state: 'interrupted', completedAt: Date.now() } } - ]) - this.lane.flush() + // The lane is sealed once finalized, so the end event travels with the final transfer. + this.lane.finalize({ + type: 'session.ended', verdict: { state: 'interrupted', completedAt: Date.now() } + }) let timer: ReturnType | undefined try { await Promise.race([ diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index d8cb30b2937..df3e7dbc662 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -230,6 +230,7 @@ describe('mobile RPC allowlist', () => { 'agentSession.unsubscribe', 'agentSession.subscribeStatus', 'agentSession.readVisual', + 'agentSession.item', 'agentSession.hold', 'agentSession.release' ]) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index f0e68d1ca2f..16d493ed04a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -95,6 +95,7 @@ export const WORK_METHODS = [ { method: 'agentSession.modelCatalog', params: { agent: 'codex', sessionId: SESSION } }, { method: 'agentSession.history', params: { sessionId: SESSION, direction: 'tail' } }, { method: 'agentSession.conversationOutline', params: { sessionId: SESSION } }, + { method: 'agentSession.item', params: { sessionId: SESSION, itemId: 'item-1' } }, { method: 'agentSession.subscribe', params: { sessionId: SESSION } }, { method: 'agentSession.hold', params: { sessionId: SESSION, holderId: 'surface-1' } }, { method: 'agentSession.reveal', params: { sessionId: SESSION } }, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-item.ts b/src/main/runtime/rpc/methods/structured-agent-session-item.ts new file mode 100644 index 00000000000..d070d0e055d --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-item.ts @@ -0,0 +1,35 @@ +// `agentSession.item`: one item whole, for "Show full output". Every other read sends the client a +// head of each tool output and diff (agent-session-item-wire-form.ts); the journal keeps the rest. +// +// Additive and negotiated: a client calls it only once the host advertises +// `agent-session.item-detail.v1`. The reply is fitted to one reply's limit and marked when that +// still had to shorten it; a phone's bridge splits a reply that size. + +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { ItemParams } from '../../../../shared/rpc-contract/structured-agent-session-params' +import { shortenedAgentJournalItem } from '../../../native-chat/agent-session-wire/agent-session-item-wire-form' +import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../../shared/remote-runtime-memory-limits' +import { defineMethod } from '../core' +import { requireInstalledStructuredHost } from './structured-agent-session-gate' + +/** The item's share of one reply, with room for the envelope beside it. */ +const ITEM_REPLY_MAX_BYTES = REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES - 64 * 1024 + +export const STRUCTURED_AGENT_SESSION_ITEM_METHODS = [ + defineMethod({ + name: 'agentSession.item', + permission: 'workspace', + params: ItemParams, + handler: async (params, ctx): Promise<{ item: AgentJournalRenderItem | null }> => { + const snapshot = await ( + await requireInstalledStructuredHost(ctx, params.sessionId) + ).journalSnapshot(params.sessionId) + const item = snapshot.items.find((each) => each.itemId === params.itemId) + if (!item) { + return { item: null } + } + // Null only for an item whose identifiers alone exceed a reply; the client keeps its head. + return { item: shortenedAgentJournalItem(item, ITEM_REPLY_MAX_BYTES) } + } + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-wire-contracts.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-wire-contracts.test.ts index 5a2abbecedb..6ab3f6329f8 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-wire-contracts.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-wire-contracts.test.ts @@ -4,7 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AGENT_SESSION_OPTIONAL_MODEL_CLIENT_CAPABILITY } from '../../../../shared/agent-session-optional-model-capability' import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../../../shared/electron-remote-runtime-client-capabilities' import { + AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY, AGENT_SESSION_SNAPSHOT_CATCH_UP_CLIENT_CAPABILITY, + RUNTIME_CAPABILITIES, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { AGENT_SESSION_CURRENT_REQUESTS_CLIENT_CAPABILITY } from '../../../../shared/agent-session-current-requests-capability' @@ -155,6 +157,40 @@ describe('agentSession.conversationOutline', () => { }) }) +describe('agentSession.item', () => { + it('returns a whole output that every other read sends a head of', async () => { + const output = 'z'.repeat(64 * 1024) + const item = { + itemId: 'tool-1', + revision: 2, + sequence: 7, + observedAt: 7, + body: { + kind: 'tool-call', + name: 'shell', + input: { command: 'cat big.log' }, + state: 'completed', + output: { head: output, byteLength: output.length, digest: 'd', truncated: false } + } + } + hostCalls.journalSnapshot.mockReturnValueOnce({ + sessionId: SESSION, + cursor: { epoch: 'epoch-a', sequence: 7 }, + items: [item], + submissions: [] + }) + + const response = await call( + 'agentSession.item', + { sessionId: SESSION, itemId: 'tool-1' }, + STRUCTURED_MOBILE_CLIENT + ) + + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY) + expect(response).toMatchObject({ ok: true, result: { item } }) + }) +}) + describe('options without a selected model across client versions', () => { const CURRENT_CLIENT = { ...STRUCTURED_CLIENT, diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 535817b6a63..f3b88b73c03 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -175,7 +175,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(38) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(39) }) it('hides the surface from a declared client that did not advertise it', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 1fe96d81d0b..31061d22871 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -51,6 +51,7 @@ import { import { STRUCTURED_AGENT_SESSION_TURN_COMPLETION_METHODS } from './structured-agent-session-turn-completion-stream' import { STRUCTURED_AGENT_SESSION_THREAD_GOAL_METHODS } from './structured-agent-session-thread-goal' import { STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS } from './structured-agent-session-conversation-outline' +import { STRUCTURED_AGENT_SESSION_ITEM_METHODS } from './structured-agent-session-item' import { STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS } from './structured-agent-session-options-read' import { AttachParams, @@ -298,6 +299,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ ...STRUCTURED_AGENT_SESSION_TURN_COMPLETION_METHODS, ...STRUCTURED_AGENT_SESSION_THREAD_GOAL_METHODS, ...STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS, + ...STRUCTURED_AGENT_SESSION_ITEM_METHODS, ...STRUCTURED_AGENT_SESSION_CURRENT_REQUESTS_METHODS, ...STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS ] diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts index 3e3b99c7652..00d2758720a 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-agent-session-methods.ts @@ -28,6 +28,8 @@ export const MOBILE_AGENT_SESSION_RPC_METHODS = [ 'agentSession.subscribeStatus', // One visual from the chat's own scratch folder, for the transcript's `::orca-visual` lines. 'agentSession.readVisual', + // One item whole, for "Show full output" and "Show full message". + 'agentSession.item', // A file the phone attaches to a chat, stored on this host; the agent sees only the host path. 'agentSessionAttachment.uploadStart', 'agentSessionAttachment.uploadAppend', diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 01c70a3f213..91f6c144f68 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -842,8 +842,9 @@ describe('a structured codex session over agentSession.*', () => { ).toBe(false) }) - it('persists truncated command output before publishing its journal row', async () => { + it('persists complete command output while publishing a shortened copy', async () => { await ok<{ fence: number }>('agentSession.create', createIntentParams()) + const stream = await subscribe('sub-large-output') const output = 'large command output\n'.repeat(2_000) codex.notify('item/completed', { @@ -860,13 +861,15 @@ describe('a structured codex session over agentSession.*', () => { }) await drainStreamedEvents() - const host = getStructuredAgentSessionHost() - const journal = ( - host as unknown as { sessions: Map } - ).sessions.get(SESSION)!.journal - const item = journal.snapshot().items.find((candidate) => candidate.body?.kind === 'tool-call') + const snapshot = await getStructuredAgentSessionHost()?.journalSnapshot(SESSION) + const item = snapshot?.items.find((candidate) => candidate.body?.kind === 'tool-call') const bounded = item?.body?.kind === 'tool-call' ? item.body.output : undefined - expect(bounded).toMatchObject({ truncated: true, byteLength: Buffer.byteLength(output) }) + expect(bounded).toMatchObject({ head: output, truncated: false, byteLength: output.length }) + const published = itemsOf(stream).find((candidate) => candidate.itemId === item?.itemId) + expect(published?.shortened).toBe(true) + expect(published?.body).toMatchObject({ + output: { head: output.slice(0, 16 * 1024), truncated: true, byteLength: output.length } + }) }) it('keeps an answered prompt resolved after the provider exits', async () => { @@ -890,10 +893,8 @@ describe('a structured codex session over agentSession.*', () => { }) await drainStreamedEvents() const host = getStructuredAgentSessionHost() - const journal = ( - host as unknown as { sessions: Map } - ).sessions.get(SESSION)!.journal - const approval = journal.snapshot().items.find((item) => item.body?.kind === 'approval') + let snapshot = await host?.journalSnapshot(SESSION) + const approval = snapshot?.items.find((item) => item.body?.kind === 'approval') expect(approval?.body).toMatchObject({ kind: 'approval', resolution: { state: 'pending' } @@ -916,9 +917,8 @@ describe('a structured codex session over agentSession.*', () => { codex.live().handlers.onExit?.(new Error('provider exited after answer')) await drainStreamedEvents() - expect( - journal.snapshot().items.find((item) => item.itemId === approval?.itemId)?.body - ).toMatchObject({ + snapshot = await host?.journalSnapshot(SESSION) + expect(snapshot?.items.find((item) => item.itemId === approval?.itemId)?.body).toMatchObject({ kind: 'approval', resolution: { state: 'resolved', selectedOptionId: 'accept' } }) diff --git a/src/renderer/src/components/native-chat/NativeChatToolLine.tsx b/src/renderer/src/components/native-chat/NativeChatToolLine.tsx index 56dce7855e3..787a4c49d1d 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolLine.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolLine.tsx @@ -23,7 +23,7 @@ import { nativeChatToolLineLabel } from './native-chat-tool-line-label' import { diffFromText, diffFromToolCall } from './native-chat-diff' import { NativeChatExpandable } from './NativeChatExpandable' import { useNativeChatDisclosure } from './native-chat-disclosure-store' -import { truncateToolDetail } from './native-chat-tool-summary' +import { NativeChatToolOutput } from './NativeChatToolOutput' const NO_SEARCH_RESULTS: NonNullable = [] const TARGET_CLASS = @@ -68,17 +68,7 @@ function ToolLineDetail({ )} ) : null} - {body ? ( -
-          {truncateToolDetail(body.output)}
-        
- ) : null} + {body ? : null} ) } diff --git a/src/renderer/src/components/native-chat/NativeChatToolOutput.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolOutput.test.tsx new file mode 100644 index 00000000000..045a7dbcab2 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatToolOutput.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { NativeChatToolResultBlock } from '../../../../shared/native-chat-types' +import { NativeChatToolOutput } from './NativeChatToolOutput' + +const mocks = vi.hoisted(() => ({ read: vi.fn(), owner: { target: { kind: 'local' }, sessionId: 'session', worktreeId: 'workspace' } })) +vi.mock('./native-chat-visual-owner', () => ({ useNativeChatVisualOwner: () => mocks.owner })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ supportsStructuredAgentSessionItemDetail: async () => true, readStructuredAgentSessionItem: mocks.read })) +const container = document.createElement('div') +const root = createRoot(container) +afterEach(() => { act(() => root.unmount()); mocks.read.mockReset() }) + +it('fences same-head live revisions and late replies, and keeps null/partial results truthful', async () => { + const body: NativeChatToolResultBlock = { type: 'tool-result', output: 'x'.repeat(16_384), fullOutputItemId: 'call' } + const response = (text: string, truncated = false): AgentJournalRenderItem => ({ itemId: 'call', revision: 2, sequence: 1, observedAt: 1, body: { kind: 'tool-call', name: 'bash', input: null, state: 'running', output: { head: text, byteLength: 100_000, digest: 'd', truncated } } }) + const render = (block: NativeChatToolResultBlock) => act(() => root.render()) + const click = async () => { await act(async () => container.querySelector('button')?.click()) } + render(body) + mocks.read.mockResolvedValueOnce(response('OLD TAIL')) + await click() + expect(container.textContent).toContain('OLD TAIL') + render({ ...body }) + expect(container.textContent).not.toContain('OLD TAIL') + expect(container.querySelector('button')).not.toBeNull() + const pending = Promise.withResolvers() + mocks.read.mockReturnValueOnce(pending.promise) + await click() + render({ ...body }) + await act(async () => pending.resolve(response('STALE TAIL'))) + expect(container.textContent).not.toContain('STALE TAIL') + mocks.read.mockResolvedValueOnce(null) + await click() + expect(container.textContent).toContain("Couldn't load the full output.") + expect(container.querySelector('button')).not.toBeNull() + mocks.read.mockResolvedValueOnce(response('PARTIAL', true)) + await click() + expect(container.textContent).toContain('Showing') + expect(container.textContent).toContain('PARTIAL') +}) diff --git a/src/renderer/src/components/native-chat/NativeChatToolOutput.tsx b/src/renderer/src/components/native-chat/NativeChatToolOutput.tsx new file mode 100644 index 00000000000..05398b5d30d --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatToolOutput.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from 'react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { formatBytes } from '@/components/status-bar/workspace-space-format' +import { + readStructuredAgentSessionItem, + supportsStructuredAgentSessionItemDetail +} from '@/runtime/structured-agent-session-client' +import type { + AgentJournalBoundedPayload, + AgentJournalItemBody +} from '../../../../shared/agent-session-journal-types' +import type { NativeChatToolResultBlock } from '../../../../shared/native-chat-types' +import { MAX_TOOL_DETAIL_LENGTH } from '../../../../shared/native-chat-tool-summary' +import { useNativeChatVisualOwner } from './native-chat-visual-owner' +import { truncateToolDetail } from './native-chat-tool-summary' + +/** A partial detail response retains its original byte count. */ +type FullOutput = { text: string; partial: { shown: number; total: number } | null } + +function fullPayload(body: AgentJournalItemBody): AgentJournalBoundedPayload | null { + if (body.kind === 'tool-call') { + return body.output ?? null + } + return body.kind === 'diff' ? body.patch : null +} + +/** A tool's output in an opened row: a head of it, and the whole of it on request, read from the + * host that keeps it. State is the row's own, so a live revision never fights it. */ +export function NativeChatToolOutput({ + body +}: { + body: NativeChatToolResultBlock +}): React.JSX.Element { + const owner = useNativeChatVisualOwner() + const [expanded, setExpanded] = useState(false) + const [detail, setDetail] = useState<{ + body: NativeChatToolResultBlock; owner: typeof owner; full?: FullOutput; + loading?: boolean; failed?: boolean + } | null>(null) + useEffect(() => () => setDetail((held) => held?.body === body && held.owner === owner ? null : held), [body, owner]) + const current = detail?.body === body && detail.owner === owner ? detail : null + const full = current?.full + const loading = current?.loading === true + const failed = current?.failed === true + const clipped = + !full && + (body.fullOutputItemId !== undefined || (!expanded && body.output.length > MAX_TOOL_DETAIL_LENGTH)) + + const showFull = async (): Promise => { + const itemId = body.fullOutputItemId + if (itemId === undefined || owner === null) { + setExpanded(true) + return + } + setDetail({ body, owner, loading: true }) + const finish = (result: { full?: FullOutput; failed?: boolean }): void => { + setDetail((held) => held?.body === body && held.owner === owner ? { body, owner, ...result } : held) + } + try { + if (!(await supportsStructuredAgentSessionItemDetail(owner.target))) { + setExpanded(true) + finish({}) + return + } + const item = await readStructuredAgentSessionItem(owner.target, owner.sessionId, itemId) + const payload = item?.itemId === itemId ? fullPayload(item.body) : null + if (!payload) { + throw new Error('Output is unavailable') + } + finish({ full: { + text: payload.head, + partial: payload.truncated ? { + shown: new TextEncoder().encode(payload.head).length, total: payload.byteLength + } : null + } }) + } catch { + finish({ failed: true }) + } + } + + return ( +
+
+        {full ? full.text : expanded ? body.output : truncateToolDetail(body.output)}
+      
+ {full?.partial ? ( +

+ {translate( + 'components.native-chat.tool.fullOutputPartial', + 'Showing {{shown}} of {{total}}.', + { shown: formatBytes(full.partial.shown), total: formatBytes(full.partial.total) } + )} +

+ ) : null} + {clipped ? ( +
+ + {failed ? ( + + {translate( + 'components.native-chat.tool.fullOutputFailed', + "Couldn't load the full output." + )} + + ) : null} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a1a98d3d8bd..700a2fecd3a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18969,6 +18969,9 @@ "interruptedCallsLabel": "Interrupted tool calls: {{value0}}", "runningPreview": "Running {{preview}}", "runningCommand": "Running command", + "showFullOutput": "Show full output", + "fullOutputFailed": "Couldn't load the full output.", + "fullOutputPartial": "Showing {{shown}} of {{total}}.", "runningNamedPreview": "Running {{toolName}} {{preview}}", "runningNamed": "Running {{toolName}}", "ranCommandOneToolSummary": "Ran {{commandCount}} command and used {{toolCount}} tool", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 1348f2cf0c6..c44a0cd76bf 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18615,6 +18615,9 @@ "interruptedCallsLabel": "Llamadas a herramientas interrumpidas: {{value0}}", "runningPreview": "Ejecutando {{preview}}", "runningCommand": "Ejecutando comando", + "showFullOutput": "Mostrar salida completa", + "fullOutputFailed": "No se pudo cargar la salida completa.", + "fullOutputPartial": "Se muestran {{shown}} de {{total}}.", "runningNamedPreview": "Ejecutando {{toolName}} {{preview}}", "runningNamed": "Ejecutando {{toolName}}", "ranCommandOneToolSummary": "Se ejecutó {{commandCount}} comando y se usó {{toolCount}} herramienta", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index e472f5f9483..8b30e411f51 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18724,6 +18724,9 @@ "interruptedCallsLabel": "Appels d'outil interrompus : {{value0}}", "runningPreview": "Exécution de {{preview}}", "runningCommand": "Commande en cours d'exécution", + "showFullOutput": "Afficher la sortie complète", + "fullOutputFailed": "Impossible de charger la sortie complète.", + "fullOutputPartial": "Affichage de {{shown}} sur {{total}}.", "runningNamedPreview": "En cours d'exécution {{toolName}} {{preview}}", "runningNamed": "Exécution de {{toolName}}", "ranCommandOneToolSummary": "Exécuté la commande {{commandCount}} et utilisé l'outil {{toolCount}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index ba9302ec8ab..26e443df6c1 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18655,6 +18655,9 @@ "interruptedCallsLabel": "中断されたツール呼び出し: {{value0}}", "runningPreview": "{{preview}} を実行中", "runningCommand": "コマンドの実行", + "showFullOutput": "出力をすべて表示", + "fullOutputFailed": "出力全体を読み込めませんでした。", + "fullOutputPartial": "{{total}} のうち {{shown}} を表示しています。", "runningNamedPreview": "{{toolName}} {{preview}} を実行中", "runningNamed": "{{toolName}} を実行中", "ranCommandOneToolSummary": "{{commandCount}} コマンドを実行し、{{toolCount}} ツールを使用しました", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 6129ed9cdf3..5d3d2c05c19 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18655,6 +18655,9 @@ "interruptedCallsLabel": "중단된 도구 호출: {{value0}}", "runningPreview": "{{preview}} 실행 중", "runningCommand": "실행 명령", + "showFullOutput": "전체 출력 보기", + "fullOutputFailed": "전체 출력을 불러오지 못했습니다.", + "fullOutputPartial": "{{total}} 중 {{shown}}을(를) 표시하는 중입니다.", "runningNamedPreview": "{{toolName}} {{preview}} 실행 중", "runningNamed": "{{toolName}} 실행 중", "ranCommandOneToolSummary": "{{commandCount}} 명령을 실행하고 {{toolCount}} 도구를 사용했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c4e7c872375..095570c0836 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18620,6 +18620,9 @@ "interruptedCallsLabel": "已中断的工具调用:{{value0}}", "runningPreview": "正在运行 {{preview}}", "runningCommand": "运行命令", + "showFullOutput": "显示完整输出", + "fullOutputFailed": "无法加载完整输出。", + "fullOutputPartial": "正在显示 {{total}} 中的 {{shown}}。", "runningNamedPreview": "运行 {{toolName}} {{preview}}", "runningNamed": "正在运行 {{toolName}}", "ranCommandOneToolSummary": "运行 {{commandCount}} 命令并使用 {{toolCount}} 工具", diff --git a/src/renderer/src/runtime/structured-agent-session-client.ts b/src/renderer/src/runtime/structured-agent-session-client.ts index 7ad8cff68ac..bd67412587a 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.ts @@ -1,4 +1,7 @@ -import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalCursor, + AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' import type { AgentSessionStatusEvent, AgentSessionSubscribeEvent, @@ -9,6 +12,7 @@ import { AGENT_SESSION_CONVERSATION_COMMAND_TIMEOUT_MS } from '../../../shared/a import { AGENT_SESSION_ATTENTION_ACK_RUNTIME_CAPABILITY, AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, + AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY, AGENT_SESSION_PROMPT_CANCEL_RUNTIME_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, @@ -101,6 +105,27 @@ export async function readStructuredAgentSessionConversationOutline( ) } +export function supportsStructuredAgentSessionItemDetail( + target: RuntimeClientTarget +): Promise { + return structuredAgentSessionHostSupports(target, AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY) +} + +/** One item whole, as the host keeps it; null when the host no longer has it. Call only after + * `supportsStructuredAgentSessionItemDetail`; a failed read rejects. */ +export async function readStructuredAgentSessionItem( + target: RuntimeClientTarget, + sessionId: string, + itemId: string +): Promise { + const result = await callRuntimeRpc<{ item: AgentJournalRenderItem | null }>( + target, + 'agentSession.item', + { sessionId, itemId } + ) + return result?.item ?? null +} + const STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS: ReadonlyMap = new Map([ ['agentSession.conversationCommand', AGENT_SESSION_CONVERSATION_COMMAND_TIMEOUT_MS], // The host may start an agent at rest before rewinding it, as it does for a command. diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 5cd2f87caf2..d5b1b28551e 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -317,6 +317,7 @@ export const AgentJournalRenderItemSchema = z.object({ recovered: z.literal(true).optional(), recoveredAt: z.number().optional(), turnScope: AgentJournalTurnScopeSchema.optional(), + shortened: z.literal(true).optional(), ...AgentJournalProducerLinkageFields }) diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 4e969014736..920942bfb29 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -82,15 +82,14 @@ export type AgentJournalItemIdentity = // ─── Bounded payloads ─────────────────────────────────────────────────────── -/** A tool output or diff body clipped to a head. The remainder is DISCARDED, - * never stored: crossing a bound sets `truncated` and the two fields below - * describe what was dropped, so it is marked rather than silently lost. */ +/** A tool output or diff body. A row keeps the whole value (`truncated: false`). `truncated: true` + * marks a head: the copy sent to a client (fetch the whole item with `agentSession.item`), a + * call still running, or a row an older build saved, whose remainder is gone. */ export type AgentJournalBoundedPayload = { head: string /** Byte length of the ORIGINAL payload, not of `head`. */ byteLength: number - /** sha256 of the original payload — identification only; nothing stores or - * retrieves the discarded remainder by it. */ + /** sha256 of the original payload, for identification only. */ digest: string truncated: boolean } @@ -444,6 +443,9 @@ export type AgentJournalRenderItem = AgentJournalProducerLinkage & { recoveredAt?: number /** Absent only from a host that predates it. */ turnScope?: AgentJournalTurnScope + /** Set on the copy sent to a client when text in it was shortened to fit; never saved. The whole + * item comes from `agentSession.item`. A cut tool output or diff also says so in `truncated`. */ + shortened?: true } // ─── Submissions ──────────────────────────────────────────────────────────── diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 09e1828acd0..6bca7c7b222 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -114,6 +114,8 @@ export type NativeChatToolResultBlock = { callId?: string /** Present only for edit tools whose result reported resolved hunks. */ editPatch?: NativeChatEditPatch + /** The journal item to fetch when `output` is only a head of what the tool returned. */ + fullOutputItemId?: string } /** A reference to an image, by local path or remote URL. Exactly the field @@ -256,6 +258,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { parentId?: string /** How a user message was delivered when it was not an ordinary prompt. */ sentAs?: AgentJournalMessageSendMode + /** Its text was shortened to fit the copy sent to this client; the host keeps it whole. */ + shortened?: true /** The journal row's own lifecycle; absent means unknown, never live. */ state?: AgentJournalMessageState /** Host clock when the row's message was seen to end; absent when no end was seen live. */ diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index ded6da55940..46d963ef281 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -83,6 +83,7 @@ import { AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, + AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, @@ -266,6 +267,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RECOVERY_CAPABILITY, AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, + AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index e3672b9326b..777d2610dae 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -544,6 +544,7 @@ import { HandoffStatusParams, HistoryParams, HoldParams, + ItemParams, ModelCatalogParams, OptionsParams, QueuedMessageActionParams, @@ -708,6 +709,7 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.handoffStatus': HandoffStatusParams, 'agentSession.history': HistoryParams, 'agentSession.hold': HoldParams, + 'agentSession.item': ItemParams, 'agentSession.modelCatalog': ModelCatalogParams, 'agentSession.options': OptionsParams, 'agentSession.queuedMessageDelete': QueuedMessageActionParams, diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index e0ddef0c277..e66c68aebaf 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -279,6 +279,7 @@ export const SetOptionParams = z .strict() export const OptionsParams = z.object({ sessionId: SessionId }).strict() +export const ItemParams = OptionsParams.extend({ itemId: z.string().min(1) }).strict() export const SleepWorkspaceParams = z.object({ workspaceId: z.string().min(1) }).strict() export const AcknowledgeAttentionParams = OptionsParams.extend({ observedCursor: JournalCursor }) @@ -364,9 +365,7 @@ export const HistoryParams = z }) .strict() -export const SubscribeParams = z - .object({ sessionId: SessionId, cursor: JournalCursor.optional() }) - .strict() +export const SubscribeParams = OptionsParams.extend({ cursor: JournalCursor.optional() }).strict() // Not strict: an older host ignores these params entirely, and this host must ignore a newer client's. export const SubscribeTurnCompletionsParams = z.object({ includePrompts: z.boolean().optional() }) diff --git a/src/shared/runtime-capability-constants.ts b/src/shared/runtime-capability-constants.ts index 5a3d2315cf0..601c5e50d6c 100644 --- a/src/shared/runtime-capability-constants.ts +++ b/src/shared/runtime-capability-constants.ts @@ -192,6 +192,9 @@ export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = // older host leaves the message rail on loaded messages instead of answering method_not_found. export const AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY = 'agent-session.conversation-outline.v1' as const +// Why: agentSession.item is additive; a client probes this before offering "Show full output" so an +// older host, whose rows keep only a head, is never asked for more. +export const AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY = 'agent-session.item-detail.v1' as const // The RPC is registered unconditionally; per-session rewind support is a separate check. export const AGENT_SESSION_REWIND_RUNTIME_CAPABILITY = 'agent-session.rewind.v1' as const // Why: the rewind UI relies on the next send settling an in-doubt rewind, even with the agent up. diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 6b66904c275..6a2ce2d08da 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -50,8 +50,19 @@ export { } from './structured-agent-session-live-turn' export { hasUnansweredStructuredAgentSessionDispatch } from './structured-agent-session-unanswered-dispatch' -function boundedText(payload: { head: string; truncated: boolean; byteLength: number }): string { - return payload.truncated ? `${payload.head}\n… (${payload.byteLength} bytes)` : payload.head +/** A tool's output as its result block. Only a cut the sent copy made has more behind it; a head an + * older build saved has none, so only that cut names the item to fetch. */ +function toolResult( + item: AgentJournalRenderItem, + payload: { head: string; truncated: boolean; byteLength: number }, + callId: string | undefined +) { + return { + type: 'tool-result' as const, + output: payload.truncated ? `${payload.head}\n… (${payload.byteLength} bytes)` : payload.head, + callId, + ...(item.shortened && payload.truncated ? { fullOutputItemId: item.itemId } : {}) + } } /** The markers a clipped payload carries in its own text, anchored to the end @@ -83,10 +94,7 @@ function itemBlocks(item: AgentJournalRenderItem): { // The call and its output are one journal row, so the result names its call. const { callId } = call if (body.kind === 'diff') { - return { - role: 'assistant', - blocks: [call, { type: 'tool-result', output: boundedText(body.patch), callId }] - } + return { role: 'assistant', blocks: [call, toolResult(item, body.patch, callId)] } } return { role: 'assistant', @@ -95,11 +103,9 @@ function itemBlocks(item: AgentJournalRenderItem): { ...(body.output ? [ { - type: 'tool-result' as const, - output: boundedText(body.output), + ...toolResult(item, body.output, callId), // Output a call left when it was cut short is not an error it reported. - isError: agentJournalToolCallLifecycle(body) === 'failed', - callId + isError: agentJournalToolCallLifecycle(body) === 'failed' } ] : []) @@ -202,7 +208,8 @@ export function projectStructuredItemToNativeChat( ...(sentAs !== undefined && isAgentJournalMessageSendMode(sentAs) ? { sentAs } : {}), ...lifecycle, ...(command ? { command } : {}), - ...(from && projected.role === 'user' ? { from } : {}) + ...(from && projected.role === 'user' ? { from } : {}), + ...(item.shortened && item.body.kind === 'message' ? { shortened: true } : {}) } : null projectedItems.set(item, message) diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index e9232451a36..99cc52bd9a3 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -39,6 +39,7 @@ import { resolveBaselineReleaseRef } from './release-checkout' import { callBuild, expectDeclaredSurfaceExecutes, + expectItemDetailNegotiation, runtimeStub } from './structured-agent-session-surface-execution' import { @@ -300,6 +301,7 @@ describe('cross-version structured agent sessions', () => { build.capabilities.includes(AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY) ).toBe(build.methodNames.includes(CONVERSATION_OUTLINE_METHOD)) } + expectItemDetailNegotiation([current, baseline]) // Additive surface: bumping the protocol number would strand every paired // device on this release rather than degrade one feature. expect(current.protocolVersion).toBe(baseline.protocolVersion) @@ -558,32 +560,28 @@ describe('cross-version structured agent sessions', () => { }) it('hides the row from the old client and annotates it for a capable client', async () => { - const oldReply = ( - await callBuild( - current, - 'aiVault.listSessions', - {}, - { - clientKind: 'runtime', - clientCapabilities: legacyClientCapabilities() - }, - runtime - ) - )[0] + const [oldReply] = await callBuild( + current, + 'aiVault.listSessions', + {}, + { + clientKind: 'runtime', + clientCapabilities: legacyClientCapabilities() + }, + runtime + ) expect(oldReply).toMatchObject({ ok: true, result: { sessions: [] } }) - const capableReply = ( - await callBuild( - current, - 'aiVault.listSessions', - {}, - { - clientKind: 'runtime', - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] - }, - runtime - ) - )[0] + const [capableReply] = await callBuild( + current, + 'aiVault.listSessions', + {}, + { + clientKind: 'runtime', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + }, + runtime + ) expect(capableReply).toMatchObject({ ok: true, result: { diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts index d9b1352c7b5..ed257aa92f5 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts @@ -11,6 +11,7 @@ export const ATTENTION_READ = { } as const export const REWIND_METHOD = 'agentSession.rewind' export const CONVERSATION_OUTLINE_METHOD = 'agentSession.conversationOutline' +export const ITEM_DETAIL_METHOD = 'agentSession.item' export const STATUS_FEED_METHOD = 'agentSession.subscribeStatus' export const TURN_COMPLETION_FEED_METHOD = 'agentSession.subscribeTurnCompletions' @@ -182,6 +183,11 @@ export const STRUCTURED_CALLS: { hostMethod: 'journalSnapshot', result: { sessionId: SESSION, entries: [], omittedEntries: 0 } }, + { + method: ITEM_DETAIL_METHOD, + hostMethod: 'journalSnapshot', + result: { item: null } + }, // A subscription that opens with nothing to say answers with no reply at all, // so reaching the host is the only signal that the gate opened. { method: 'agentSession.subscribe', hostMethod: 'subscribe' }, diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts index 0c303be1f4a..92cb0939c12 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts @@ -1,4 +1,5 @@ import { expect, vi } from 'vitest' +import { AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import { RuntimeSubscriptionRegistry } from '../../../src/main/runtime/runtime-subscription-registry' import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' import { @@ -138,3 +139,12 @@ export async function expectDeclaredSurfaceExecutes( } } } + +export function expectItemDetailNegotiation(builds: readonly AgentSessionWireBuild[]): void { + expect(builds[0]?.capabilities).toContain(AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY) + for (const build of builds) { + expect(build.capabilities.includes(AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY)).toBe( + build.methodNames.includes('agentSession.item') + ) + } +} diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts index 4dd014ce155..5cfaefd5605 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -22,7 +22,8 @@ import { THREAD, NOW, ATTENTION_READ, - REWIND_METHOD + REWIND_METHOD, + ITEM_DETAIL_METHOD } from './structured-agent-session-surface-calls' export { @@ -33,6 +34,7 @@ export { ATTENTION_READ, REWIND_METHOD, CONVERSATION_OUTLINE_METHOD, + ITEM_DETAIL_METHOD, STATUS_FEED_METHOD, TURN_COMPLETION_FEED_METHOD, STRUCTURED_CALLS @@ -180,6 +182,8 @@ export function paramsFor(method: string): unknown { } case 'agentSession.history': return { sessionId: SESSION, direction: 'tail' } + case ITEM_DETAIL_METHOD: + return { sessionId: SESSION, itemId: 'item-1' } case 'agentSession.sleepWorkspace': return { workspaceId: WORKSPACE } case 'agentSession.currentRequests':