From 6c7a6ddd54de4454d2cf854c9a096d102cc2ba1d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:13:48 -0700 Subject: [PATCH] fix(native-chat): save every chat reply and tool output whole; bound only the copy sent to clients (#29982) * fix(native-chat): save every chat reply and tool output whole; bound only the copy sent to clients Orca cut Codex replies, every agent's reasoning, plans, prompts and all tool output to a 16 KB head when it saved them, and threw the rest away. The limit existed so a reconnecting client is not replayed a huge row, but it was applied at save time, so every transport limit became permanent data loss. The journal now keeps what the agent said. One outbound form, applied to every history page, live batch and catch-up, sends a client the same 16 KB head of each tool output and diff it always got, and marks the item `shortened`. A new capability-gated `agentSession.item` RPC returns the whole item, and the desktop and phone show "Show full output" / "Show full message" when the copy was cut. Items past half a page are shortened and marked instead of replaced with "too large to show". Two bounds stay, both named: a 4 MiB ceiling per text (head and tail), applied where every provider's rows enter the write queue so one write can never stall a chat, and a preview on Grok/OpenCode/OMP/Pi calls while they run, whose closing row is built from the whole value the translator held. * test(native-chat): an over-budget history item is shortened and marked, not replaced * fix(native-chat): format, keep the sink and projection under the line limit, update method count and tests * fix(chat): preserve complete writes and keep detail current * test(chat): align final tail reservation with captured content * Prototype source-batch journal ownership for Claude * Pin the resolved iterator promise in ownership experiment * Revert "Pin the resolved iterator promise in ownership experiment" This reverts commit bdb49ab73894af5693ff8a98c70e5c2d7800dde9. * Revert "Prototype source-batch journal ownership for Claude" This reverts commit e5ed387f0c73a414f4cd190a9a74a410393b0700. * Preserve large received chat content while pacing input * Verify received chat writes wait for journal persistence * Close test journal databases before removing temporary roots * Exercise item detail in the cross-version chat wire manifest * Pin complete saved output and fitted client publication * Exercise Pi exit retention with real journal write pressure * Settle terminal web test unmount before DOM teardown * Reuse existing readers and contract support in session tests * Reuse measured chat item sizes in history pages * fix(chat): derive page form type from reader contract * fix(chat): open phone user messages while loading full text * test(native-chat): reuse imperative context provider construction * test(native-chat): format full-content disclosure fixture * ci: preserve unit shard diagnostics after a timeout * ci: retain unit shard output when tests time out * ci: expose unit shard watchdog and output stages * Revert "ci: expose unit shard watchdog and output stages" This reverts commit 0b4991b1444581c4259d2d8cd991b600fc899a33. * Revert "ci: retain unit shard output when tests time out" This reverts commit 87eec4c04f7ff6f6ce7870670e3b96adef466a18. * Revert "ci: preserve unit shard diagnostics after a timeout" This reverts commit 1888c656bbb7efbd557dfbe0b071b0b7399a22eb. * test: verify complete Claude tool output without large failure diffs * Simplify reply extraction in cross-version session test * Restore full message recovery for grouped phone thoughts * Target recovery button in grouped thought fixture * test(native-chat): count agentSession.item alongside main's new structured method * fix(chat): reuse strict session subscription schema * test(chat): open Codex before checking received content --- .../MobileNativeChatFullContent.test.tsx | 157 ++++++++++++ .../session/MobileNativeChatFullContent.tsx | 191 +++++++++++++++ .../src/session/MobileNativeChatMessage.tsx | 16 +- .../MobileNativeChatMessage.work-run.test.tsx | 83 +++++++ .../src/session/MobileNativeChatOverlay.tsx | 156 ++++++------ .../session/MobileNativeChatReasoningRow.tsx | 22 ++ .../src/session/MobileNativeChatToolLine.tsx | 22 +- .../MobileNativeChatUserMessageFold.tsx | 38 ++- .../mobile-native-chat-controller-contract.ts | 2 + .../session/mobile-native-chat-item-read.ts | 50 ++++ ...chat-merged-snapshot-parity-hooks.test.tsx | 1 + .../mobile-native-chat-message-styles.ts | 9 + ...uctured-agent-session-host-support.test.ts | 1 + ...e-structured-agent-session-host-support.ts | 4 + .../use-mobile-native-chat-controller.ts | 4 + ...mobile-native-chat-message-detail.test.tsx | 90 +++++++ .../use-mobile-native-chat-message-detail.ts | 84 +++++++ ...tured-agent-session-prompt-cancel.test.tsx | 1 + ...tured-agent-session-queued.test-fixture.ts | 1 + ...-mobile-structured-turn-indicator.test.tsx | 1 + ...terminal-live-input-text-write.web.test.ts | 2 +- src/main/acp/acp-background-task-timeline.ts | 10 - src/main/acp/acp-session-update.ts | 19 +- src/main/acp/acp-subagents.ts | 11 +- src/main/acp/acp-timeline-generic.test.ts | 7 +- src/main/acp/acp-timeline-requests.ts | 6 +- src/main/acp/acp-tool-timeline.ts | 59 ++++- ...aude-child-work-evidence-retention.test.ts | 15 +- src/main/claude/claude-informational-row.ts | 6 +- .../claude-structured-item-translation.ts | 9 +- ...ude-structured-journal-translation.test.ts | 10 +- .../claude-structured-provider-fallback.ts | 15 +- src/main/claude/claude-thread-goal.ts | 14 +- ...x-child-work-translation-retention.test.ts | 16 +- .../codex-collab-agent-item-translation.ts | 19 +- .../codex-collab-call-delegation.test.ts | 4 +- .../codex/codex-image-item-translation.ts | 3 +- src/main/codex/codex-provider-retry-row.ts | 4 +- .../codex-structured-item-translation.test.ts | 14 +- .../codex-structured-item-translation.ts | 47 ++-- ...tructured-journal-exit-convergence.test.ts | 37 +-- ...red-journal-translation-settlement.test.ts | 12 +- ...journal-translation-turn-lifecycle.test.ts | 4 +- .../codex-structured-notification-retry.ts | 3 +- .../codex-structured-prompt-items.test.ts | 2 +- .../codex/codex-structured-prompt-items.ts | 18 +- .../jsonl-rpc/timeline-final-tail.test.ts | 38 ++- src/main/jsonl-rpc/timeline-lane.ts | 91 +++++-- .../journal-completed-content.test.ts | 89 +++++++ .../journal-dispatch-legacy-reason.test.ts | 4 +- .../journal-dispatch-reason-bound.spec.ts | 4 +- .../journal-dispatch-reducer.ts | 4 +- .../journal-legacy-import.test.ts | 33 ++- .../journal-legacy-import.ts | 6 +- .../journal-payload-bounds-retention.test.ts | 56 +++-- .../journal-payload-bounds.ts | 73 +++--- .../journal-prompt-body-bounds.ts | 14 +- .../journal-reasoning-row.ts | 5 +- .../journal-received-content.test.ts | 231 ++++++++++++++++++ .../journal-row-builders.ts | 4 +- .../journal-store.spec.ts | 10 +- ...rovider-timeline-assembler-test-support.ts | 6 +- .../provider-timeline-text-streams.ts | 7 +- .../agent-session-bounded-frame-item.ts | 26 +- .../agent-session-bounded-frames.test.ts | 6 +- .../agent-session-bounded-frames.ts | 6 +- .../agent-session-history-page-bounds.ts | 26 +- .../agent-session-history-page.test.ts | 16 +- .../agent-session-history-page.ts | 29 ++- .../agent-session-item-wire-form.test.ts | 21 ++ .../agent-session-item-wire-form.ts | 101 ++++++++ .../agent-session-stop-note-refresh.ts | 10 +- .../hook-failure-provider-frame.test.ts | 5 +- ...ructured-agent-session-event-sink-queue.ts | 10 +- ...tructured-agent-session-event-sink.test.ts | 6 +- ...ent-session-final-tail-reservation.test.ts | 16 +- ...ed-agent-session-final-tail-reservation.ts | 4 +- .../unhandled-provider-frame.test.ts | 6 +- .../unhandled-provider-frame.ts | 29 +-- src/main/pi/rpc-messages.ts | 48 +++- src/main/pi/rpc-session-adapter.test.ts | 81 +++--- src/main/pi/rpc-session.ts | 9 +- src/main/runtime/mobile-rpc-allowlist.test.ts | 1 + ...ession-gate-classification.test-fixture.ts | 1 + .../methods/structured-agent-session-item.ts | 35 +++ ...tured-agent-session-wire-contracts.test.ts | 36 +++ .../methods/structured-agent-session.test.ts | 2 +- .../rpc/methods/structured-agent-session.ts | 2 + ...untime-rpc-mobile-agent-session-methods.ts | 2 + ...ructured-agent-session-integration.test.ts | 28 +-- .../native-chat/NativeChatToolLine.tsx | 14 +- .../native-chat/NativeChatToolOutput.test.tsx | 42 ++++ .../native-chat/NativeChatToolOutput.tsx | 126 ++++++++++ src/renderer/src/i18n/locales/en.json | 3 + src/renderer/src/i18n/locales/es.json | 3 + src/renderer/src/i18n/locales/fr.json | 3 + src/renderer/src/i18n/locales/ja.json | 3 + src/renderer/src/i18n/locales/ko.json | 3 + src/renderer/src/i18n/locales/zh.json | 3 + .../structured-agent-session-client.ts | 27 +- src/shared/agent-session-journal-schemas.ts | 1 + src/shared/agent-session-journal-types.ts | 12 +- src/shared/native-chat-types.ts | 4 + src/shared/protocol-version.ts | 2 + .../rpc-params-catalog.generated.ts | 2 + .../structured-agent-session-params.ts | 5 +- src/shared/runtime-capability-constants.ts | 3 + .../structured-agent-session-projection.ts | 29 ++- ...ss-version-agent-session-wire.unit.test.ts | 46 ++-- .../structured-agent-session-surface-calls.ts | 6 + ...uctured-agent-session-surface-execution.ts | 10 + ...ructured-agent-session-surface-manifest.ts | 6 +- 112 files changed, 2210 insertions(+), 679 deletions(-) create mode 100644 mobile/src/session/MobileNativeChatFullContent.test.tsx create mode 100644 mobile/src/session/MobileNativeChatFullContent.tsx create mode 100644 mobile/src/session/mobile-native-chat-item-read.ts create mode 100644 mobile/src/session/use-mobile-native-chat-message-detail.test.tsx create mode 100644 mobile/src/session/use-mobile-native-chat-message-detail.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-completed-content.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-received-content.test.ts create mode 100644 src/main/native-chat/agent-session-wire/agent-session-item-wire-form.test.ts create mode 100644 src/main/native-chat/agent-session-wire/agent-session-item-wire-form.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-item.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatToolOutput.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatToolOutput.tsx 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':