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 bdb49ab738.

* Revert "Prototype source-batch journal ownership for Claude"

This reverts commit e5ed387f0c.

* 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 0b4991b144.

* Revert "ci: retain unit shard output when tests time out"

This reverts commit 87eec4c04f.

* Revert "ci: preserve unit shard diagnostics after a timeout"

This reverts commit 1888c656bb.

* 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
This commit is contained in:
Brennan Benson
2026-10-20 11:13:48 -07:00
committed by GitHub
parent b8a5c8f910
commit 6c7a6ddd54
112 changed files with 2210 additions and 679 deletions
@@ -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<void>()
const oldLoad = vi.fn(() => pending.promise)
const newLoad = vi.fn(async () => {})
const source = (client: RpcClient, sessionId: string, load: () => Promise<void>) => ({
client,
sessionId,
messageDetail: { raw: () => message, state: () => 'available' as const, load }
})
const render = (value: ReturnType<typeof source>) =>
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<void>()
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 (
<MobileNativeChatUserMessageFold
text={text}
fontScale={1}
open={open}
onToggle={() => setOpen((held) => !held)}
recovery={full.recovery}
status={full.showFull}
>
{createElement('Text', null, text)}
</MobileNativeChatUserMessageFold>
)
}
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<void>()
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)
})
@@ -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 (
<Pressable
style={styles.showFull}
onPress={onPress}
disabled={load === 'loading'}
hitSlop={8}
accessibilityRole="button"
>
<Text style={styles.showFullText}>
{load === 'failed' ? `${label} — couldn't load, tap to retry` : label}
</Text>
</Pressable>
)
}
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 (
<View>
<MobileNativeChatOwnedScrollView
rootIdentity={rootIdentity}
positionKey={positionKey}
style={[toolStyles.codeBox, toolStyles.codeScroll]}
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
>
<Text
selectable={INLINE_TEXT_SELECTION}
style={[toolStyles.codeText, type, isError && toolStyles.errorText]}
>
{full ? full.head : expanded ? output : truncateToolDetail(output)}
</Text>
</MobileNativeChatOwnedScrollView>
{full?.truncated ? (
<Text style={styles.showFullText}>
Showing {new TextEncoder().encode(full.head).length} of {full.byteLength} bytes.
</Text>
) : null}
{clipped ? (
<ShowFull label="Show full output" load={current?.load ?? 'idle'} onPress={showFull} />
) : null}
</View>
)
}
/** `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' ? (
<Text style={styles.showFullText}>Showing part of this message.</Text>
) : state === 'available' ? (
<ShowFull label="Show full message" load={load} onPress={showFull} />
) : null
}
}
+12 -4
View File
@@ -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.
<MobileNativeChatReasoningRow
<MobileNativeChatGroupedThought
key={`thought:${thoughtMessage.id}`}
message={thoughtMessage}
markdown={deriveNativeChatRowContent(thoughtMessage.blocks).markdown}
fontScale={fontScale}
live={activeTurnIsWorking === true}
expanded={openReasoning?.has(nativeChatReasoningDisclosureKey(thoughtMessage.id)) === true}
@@ -251,6 +255,7 @@ function MobileNativeChatMessageImpl({
onOpenFile={onOpenFile}
onLongPress={onLongPress}
/>
{showFull}
</View>
) : 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)))}
</MobileNativeChatUserMessageFold>
@@ -336,6 +343,7 @@ function MobileNativeChatMessageImpl({
{(block, index, shown) => proseRow(block, index, shown.text, shown.revealing)}
</MobileNativeChatPacedProse>
)}
{isUser ? null : showFull}
{askRow}
{showToolRun ? (
<ToolRun
@@ -5,6 +5,10 @@ import { createElement, type ReactNode } from 'react'
import { act, create, type ReactTestInstance, 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 { MobileNativeChatItemSourceContext } from './mobile-native-chat-item-read'
import { useMobileNativeChatMessageDetail } from './use-mobile-native-chat-message-detail'
vi.mock('react-native', async () => {
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']))
})
+86 -70
View File
@@ -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({
<MobileNativeChatRowOverlaysHost conversation={{ surfaceId: sendSurfaceId, sessionId }}>
<View style={styles.overlay}>
<MobileNativeChatVisualContext.Provider value={visuals}>
<MobileNativeChatReplyRevealsContext.Provider value={replyReveals}>
<MobileNativeChatView
messages={session.messages}
folded={folded}
status={session.status}
error={session.error}
readFailedFinally={session.readFailedFinally === true}
agent={controller.nativeChatAgent}
agentWorking={controller.nativeChatAgentWorking}
canStop={controller.nativeChatCanStop}
structuredActivityUi={controller.nativeChatStructured}
turnIndicator={controller.nativeChatTurnIndicator}
workingStartedAt={controller.nativeChatWorkingStartedAt}
settledTurns={controller.nativeChatSettledTurns}
turnJournal={controller.nativeChatTurnJournal}
receipts={controller.nativeChatReceipts}
actionablePromptIds={controller.nativeChatActionablePromptIds}
streaming={streaming}
onStop={controller.handleNativeChatStop}
ask={controller.nativeChatAsk}
askKey={controller.nativeChatAskKey}
promptKey={controller.nativeChatPromptKey}
onDismissAsk={controller.dismissNativeChatAsk}
onAnswerAsk={controller.handleNativeChatAnswerAsk}
onCancelAsk={controller.handleNativeChatCancelAsk}
onCancelPrompt={controller.handleNativeChatCancelPrompt}
onCollapseAsk={controller.collapseNativeChatAsk}
onCollapsePrompt={controller.collapseNativeChatPrompt}
collapsedPrompt={controller.nativeChatCollapsedPrompt}
question={controller.nativeChatQuestion}
onAnswerQuestion={controller.handleNativeChatQuestionAnswer}
permission={controller.nativeChatPermission}
onRespondPermission={controller.handleNativeChatRespondPermission}
composerTray={composerTray}
onOpenFile={onOpenFile}
hasMore={session.hasMore}
olderPages={session.olderPages}
onLoadEarlier={session.loadEarlier}
onSend={images.sendNativeChat}
sendSurfaceId={sendSurfaceId}
getSendCompletionGeneration={getSendCompletionGeneration}
getComposerEditGeneration={controller.getChatComposerEditGeneration}
pending={controller.chatPending}
imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId}
composerText={controller.chatComposerText}
onComposerTextChange={controller.setChatComposerText}
onAttachImage={
controller.nativeChatAcceptsImages
? () => 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}
/>
</MobileNativeChatReplyRevealsContext.Provider>
<MobileNativeChatItemSourceContext.Provider value={detailSource}>
<MobileNativeChatReplyRevealsContext.Provider value={replyReveals}>
<MobileNativeChatView
messages={session.messages}
folded={folded}
status={session.status}
error={session.error}
readFailedFinally={session.readFailedFinally === true}
agent={controller.nativeChatAgent}
agentWorking={controller.nativeChatAgentWorking}
canStop={controller.nativeChatCanStop}
structuredActivityUi={controller.nativeChatStructured}
turnIndicator={controller.nativeChatTurnIndicator}
workingStartedAt={controller.nativeChatWorkingStartedAt}
settledTurns={controller.nativeChatSettledTurns}
turnJournal={controller.nativeChatTurnJournal}
receipts={controller.nativeChatReceipts}
actionablePromptIds={controller.nativeChatActionablePromptIds}
streaming={streaming}
onStop={controller.handleNativeChatStop}
ask={controller.nativeChatAsk}
askKey={controller.nativeChatAskKey}
promptKey={controller.nativeChatPromptKey}
onDismissAsk={controller.dismissNativeChatAsk}
onAnswerAsk={controller.handleNativeChatAnswerAsk}
onCancelAsk={controller.handleNativeChatCancelAsk}
onCancelPrompt={controller.handleNativeChatCancelPrompt}
onCollapseAsk={controller.collapseNativeChatAsk}
onCollapsePrompt={controller.collapseNativeChatPrompt}
collapsedPrompt={controller.nativeChatCollapsedPrompt}
question={controller.nativeChatQuestion}
onAnswerQuestion={controller.handleNativeChatQuestionAnswer}
permission={controller.nativeChatPermission}
onRespondPermission={controller.handleNativeChatRespondPermission}
composerTray={composerTray}
onOpenFile={onOpenFile}
hasMore={session.hasMore}
olderPages={session.olderPages}
onLoadEarlier={session.loadEarlier}
onSend={images.sendNativeChat}
sendSurfaceId={sendSurfaceId}
getSendCompletionGeneration={getSendCompletionGeneration}
getComposerEditGeneration={controller.getChatComposerEditGeneration}
pending={controller.chatPending}
imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId}
composerText={controller.chatComposerText}
onComposerTextChange={controller.setChatComposerText}
onAttachImage={
controller.nativeChatAcceptsImages
? () => 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}
/>
</MobileNativeChatReplyRevealsContext.Provider>
</MobileNativeChatItemSourceContext.Provider>
</MobileNativeChatVisualContext.Provider>
</View>
</MobileNativeChatRowOverlaysHost>
@@ -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<React.ComponentProps<typeof MobileNativeChatReasoningRow>, 'message' | 'markdown'> & {
message: NativeChatMessage
}): React.JSX.Element {
const { shown, showFull } = useMobileNativeChatFullMessage(message)
return (
<>
<MobileNativeChatReasoningRow
{...props}
message={shown}
markdown={deriveNativeChatRowContent(shown.blocks).markdown}
/>
{showFull}
</>
)
}
/** A reasoning row, collapsed to its headline; its text mounts only once opened. Desktop parity:
* `NativeChatReasoningRow`. */
export function MobileNativeChatReasoningRow({
@@ -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({
</MobileNativeChatOwnedScrollView>
) : null}
{body ? (
<MobileNativeChatOwnedScrollView
rootIdentity={`${memberId ?? body?.callId ?? 'detail'}:output`}
positionKey={`${disclosureKey ?? body?.callId ?? 'detail'}:output`}
style={[styles.codeBox, styles.codeScroll]}
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
>
<Text
selectable={INLINE_TEXT_SELECTION}
style={[styles.codeText, type, body.isError && styles.errorText]}
>
{truncateToolDetail(body.output)}
</Text>
</MobileNativeChatOwnedScrollView>
<MobileNativeChatToolOutput
body={body}
type={type}
rootIdentity={`${memberId ?? body.callId ?? 'detail'}:output`}
positionKey={`${disclosureKey ?? body.callId ?? 'detail'}:output`}
/>
) : null}
</View>
)
@@ -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<typeof useMobileNativeChatFullMessage>['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<number | null>(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 (
<>
<View style={folds && !open ? { maxHeight: clip, overflow: 'hidden' } : undefined}>
<View onLayout={(event) => setHeight(event.nativeEvent.layout.height)}>{children}</View>
</View>
{folds && height !== null ? (
{longText ? (
<View style={folds && !open ? { maxHeight: clip, overflow: 'hidden' } : undefined}>
<View onLayout={(event) => setHeight(event.nativeEvent.layout.height)}>{children}</View>
</View>
) : (
children
)}
{(!longText || height !== null) && (folds || recovery) ? (
<Pressable
style={styles.toggle}
onPress={onToggle}
onPress={() => {
onToggle()
if (!open && recovery && recovery.load !== 'loading') {
recovery.onPress()
}
}}
hitSlop={8}
accessibilityRole="button"
accessibilityState={{ expanded: open }}
>
<Text style={styles.toggleText}>{open ? 'Show less' : 'Show full message'}</Text>
<Text style={styles.toggleText}>
{open
? 'Show less'
: recovery?.load === 'failed'
? "Show full message — couldn't load, tap to retry"
: 'Show full message'}
</Text>
</Pressable>
) : null}
{!recovery || (open && recovery.load === 'failed') ? status : null}
</>
)
}
@@ -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
@@ -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<AgentJournalRenderItem>(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<AgentJournalRenderItem | null> {
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
}
@@ -96,6 +96,7 @@ const HOST_SUPPORT = {
sendOptions: false,
conversationStop: false,
permissionModes: false,
itemDetail: false,
phoneAttachments: false
}
@@ -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
@@ -30,6 +30,7 @@ describe('structuredAgentSessionHostSupport', () => {
sendOptions: false,
conversationStop: false,
permissionModes: false,
itemDetail: false,
phoneAttachments: false
}
expect(structuredAgentSessionHostSupport([])).toEqual(none)
@@ -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)
}
}
@@ -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,
@@ -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<typeof useMobileNativeChatMessageDetail> | 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',
'<command-name>/plugin:example</command-name><command-args>head…</command-args>'
),
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',
'<command-name>/plugin:example</command-name><command-args>full</command-args>',
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<AgentJournalRenderItem>()
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')
})
@@ -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<void>
}
/** 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<NativeChatMessage>(),
entries: new Map<NativeChatMessage, NativeChatMessage>()
}),
[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()
}
}
}
}
}
@@ -128,6 +128,7 @@ function Harness({
sendOptions: false,
conversationStop: false,
permissionModes: false,
itemDetail: false,
phoneAttachments: false
},
onSendError: vi.fn()
@@ -23,6 +23,7 @@ export const CAPABLE: StructuredAgentSessionHostSupport = {
sendOptions: false,
conversationStop: false,
permissionModes: false,
itemDetail: false,
phoneAttachments: false
}
export const LEGACY: StructuredAgentSessionHostSupport = {
@@ -271,6 +271,7 @@ describe("useMobileStructuredAgentSession and the host's Stopping", () => {
sendOptions: false,
conversationStop: false,
permissionModes: false,
itemDetail: false,
phoneAttachments: false
},
agent: 'codex',
@@ -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()
}
})
@@ -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)
+6 -13
View File
@@ -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
}
+3 -8
View File
@@ -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
}
+4 -3
View File
@@ -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') {
+1 -5
View File
@@ -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
}
+50 -9
View File
@@ -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<string, string>({
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<string, {
input?: unknown; output?: string; inputBytes: number; outputBytes: number
}>({
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)
}
}
}
@@ -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()
+1 -5
View File
@@ -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
}
@@ -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) } : {})
}
}
@@ -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'
})
})
@@ -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()
+1 -13
View File
@@ -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' }
}
}
@@ -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', {
@@ -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<string, unknown>, 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) })
}
}
@@ -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 })
@@ -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)
+2 -2
View File
@@ -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)
}
}
}
@@ -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', () => {
@@ -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<string, unknown>, '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<string, unknown>
@@ -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<string, unknown> | 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
}
}
@@ -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<void>()
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',
@@ -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', () => {
@@ -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)
@@ -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')
@@ -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)
})
})
@@ -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<string, unknown> {
return typeof params === 'object' && params !== null ? (params as Record<string, unknown>) : {}
@@ -51,8 +49,8 @@ function readString(source: Record<string, unknown>, 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, unknown>): 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) }
+16 -22
View File
@@ -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'
+68 -23
View File
@@ -27,7 +27,9 @@ export type JsonlRpcTimelineLaneDeps = {
export class JsonlRpcTimelineLane {
private readonly assembler: ProviderTimelineAssembler
private readonly identity: ReturnType<typeof createLegacyProviderTimelineIdentityScheme>
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<void> {
@@ -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<ProviderTimelineEvent, { type: 'session.ended' }>): 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()
}
@@ -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<void>()
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) })))
})
@@ -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
)
})
@@ -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
@@ -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
@@ -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<Parameters<typeof openAgentSessionJournal>[0]> = {}
): Promise<AgentSessionJournal> {
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. */
@@ -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<PreparedLegacyImport> {
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)
@@ -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',
@@ -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 {
@@ -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
@@ -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<AgentJournalMessageItem, 'state' | 'completedAt'>
@@ -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
@@ -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<void>()
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<void>()
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 })
})
)
})
@@ -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(
@@ -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')
})
})
@@ -136,6 +136,7 @@ export function messageText(body: AgentJournalItemBody | undefined): string | un
const journals = createTrackedJournalOpener()
const cleanups: (() => Promise<void>)[] = []
const roots: string[] = []
/** Call from `afterEach`. */
export async function closeProviderTimelineRigs(): Promise<void> {
@@ -143,6 +144,9 @@ export async function closeProviderTimelineRigs(): Promise<void> {
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<ProviderTimelineAssemblerDeps> = {}
): Promise<ProviderTimelineRig> {
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) {
@@ -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)
}
@@ -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<AgentJournalRenderItem, AgentJournalRenderItem>()
const bounded = new WeakMap<AgentJournalRenderItem, HistoryItemForm>()
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
@@ -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)
})
})
@@ -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
@@ -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<string, number>,
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
@@ -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)
})
})
@@ -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<HistoryReader['form']>[],
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<typeof projectJournalBatch> => {
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,
@@ -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)
})
@@ -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<AgentJournalRenderItem, HistoryItemForm>()
/** 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
}
@@ -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)) {
@@ -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')
@@ -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' }
@@ -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 })
})
@@ -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()
})
@@ -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
@@ -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', () => {
@@ -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'
}
+42 -6
View File
@@ -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<string, { index: number; channel: 'assistant' | 'reasoning' }>()
private readonly tools = new Map<string, AgentJournalToolCallItem>()
// Closing events take these latest full fields; running journal rows retain only previews.
private readonly whole = new BoundedMap<string, {
input?: unknown; output?: string; inputBytes: number; outputBytes: number
}>({
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 [
{
+46 -35
View File
@@ -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<void>()
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
+4 -5
View File
@@ -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<typeof setTimeout> | undefined
try {
await Promise.race([
@@ -230,6 +230,7 @@ describe('mobile RPC allowlist', () => {
'agentSession.unsubscribe',
'agentSession.subscribeStatus',
'agentSession.readVisual',
'agentSession.item',
'agentSession.hold',
'agentSession.release'
])
@@ -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 } },
@@ -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) }
}
})
]
@@ -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,
@@ -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 () => {
@@ -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
]
@@ -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',
@@ -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<string, { journal: AgentSessionJournal }> }
).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<string, { journal: AgentSessionJournal }> }
).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' }
})
@@ -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<NativeChatToolCallBlock['webSearchResults']> = []
const TARGET_CLASS =
@@ -68,17 +68,7 @@ function ToolLineDetail({
)}
</pre>
) : null}
{body ? (
<pre
data-native-chat-code-content
className={cn(
'max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-chat-code-border bg-chat-code-surface p-2 font-mono text-chat-code scrollbar-sleek',
body.isError ? 'text-destructive' : 'text-chat-foreground'
)}
>
{truncateToolDetail(body.output)}
</pre>
) : null}
{body ? <NativeChatToolOutput body={body} /> : null}
</div>
)
}
@@ -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(<NativeChatToolOutput body={block} />))
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<AgentJournalRenderItem>()
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')
})
@@ -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<void> => {
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 (
<div className="space-y-1">
<pre
data-native-chat-code-content
className={cn(
'max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-chat-code-border bg-chat-code-surface p-2 font-mono text-chat-code scrollbar-sleek',
body.isError ? 'text-destructive' : 'text-chat-foreground'
)}
>
{full ? full.text : expanded ? body.output : truncateToolDetail(body.output)}
</pre>
{full?.partial ? (
<p className="text-xs text-chat-foreground-faint">
{translate(
'components.native-chat.tool.fullOutputPartial',
'Showing {{shown}} of {{total}}.',
{ shown: formatBytes(full.partial.shown), total: formatBytes(full.partial.total) }
)}
</p>
) : null}
{clipped ? (
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="xs"
disabled={loading}
onClick={() => void showFull()}
>
{translate('components.native-chat.tool.showFullOutput', 'Show full output')}
</Button>
{failed ? (
<span className="text-xs text-chat-foreground-faint">
{translate(
'components.native-chat.tool.fullOutputFailed',
"Couldn't load the full output."
)}
</span>
) : null}
</div>
) : null}
</div>
)
}
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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}}",
+3
View File
@@ -18655,6 +18655,9 @@
"interruptedCallsLabel": "中断されたツール呼び出し: {{value0}}",
"runningPreview": "{{preview}} を実行中",
"runningCommand": "コマンドの実行",
"showFullOutput": "出力をすべて表示",
"fullOutputFailed": "出力全体を読み込めませんでした。",
"fullOutputPartial": "{{total}} のうち {{shown}} を表示しています。",
"runningNamedPreview": "{{toolName}} {{preview}} を実行中",
"runningNamed": "{{toolName}} を実行中",
"ranCommandOneToolSummary": "{{commandCount}} コマンドを実行し、{{toolCount}} ツールを使用しました",
+3
View File
@@ -18655,6 +18655,9 @@
"interruptedCallsLabel": "중단된 도구 호출: {{value0}}",
"runningPreview": "{{preview}} 실행 중",
"runningCommand": "실행 명령",
"showFullOutput": "전체 출력 보기",
"fullOutputFailed": "전체 출력을 불러오지 못했습니다.",
"fullOutputPartial": "{{total}} 중 {{shown}}을(를) 표시하는 중입니다.",
"runningNamedPreview": "{{toolName}} {{preview}} 실행 중",
"runningNamed": "{{toolName}} 실행 중",
"ranCommandOneToolSummary": "{{commandCount}} 명령을 실행하고 {{toolCount}} 도구를 사용했습니다.",
+3
View File
@@ -18620,6 +18620,9 @@
"interruptedCallsLabel": "已中断的工具调用:{{value0}}",
"runningPreview": "正在运行 {{preview}}",
"runningCommand": "运行命令",
"showFullOutput": "显示完整输出",
"fullOutputFailed": "无法加载完整输出。",
"fullOutputPartial": "正在显示 {{total}} 中的 {{shown}}。",
"runningNamedPreview": "运行 {{toolName}} {{preview}}",
"runningNamed": "正在运行 {{toolName}}",
"ranCommandOneToolSummary": "运行 {{commandCount}} 命令并使用 {{toolCount}} 工具",
@@ -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<boolean> {
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<AgentJournalRenderItem | null> {
const result = await callRuntimeRpc<{ item: AgentJournalRenderItem | null }>(
target,
'agentSession.item',
{ sessionId, itemId }
)
return result?.item ?? null
}
const STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS: ReadonlyMap<string, number> = 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.

Some files were not shown because too many files have changed in this diff Show More