mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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 commitbdb49ab738. * Revert "Prototype source-batch journal ownership for Claude" This reverts commite5ed387f0c. * 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 commit0b4991b144. * Revert "ci: retain unit shard output when tests time out" This reverts commit87eec4c04f. * Revert "ci: preserve unit shard diagnostics after a timeout" This reverts commit1888c656bb. * 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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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']))
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
+35
-21
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+5
-1
@@ -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')
|
||||
|
||||
+3
-7
@@ -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 })
|
||||
})
|
||||
|
||||
|
||||
+4
-12
@@ -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
-3
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
+1
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -18655,6 +18655,9 @@
|
||||
"interruptedCallsLabel": "中断されたツール呼び出し: {{value0}}",
|
||||
"runningPreview": "{{preview}} を実行中",
|
||||
"runningCommand": "コマンドの実行",
|
||||
"showFullOutput": "出力をすべて表示",
|
||||
"fullOutputFailed": "出力全体を読み込めませんでした。",
|
||||
"fullOutputPartial": "{{total}} のうち {{shown}} を表示しています。",
|
||||
"runningNamedPreview": "{{toolName}} {{preview}} を実行中",
|
||||
"runningNamed": "{{toolName}} を実行中",
|
||||
"ranCommandOneToolSummary": "{{commandCount}} コマンドを実行し、{{toolCount}} ツールを使用しました",
|
||||
|
||||
@@ -18655,6 +18655,9 @@
|
||||
"interruptedCallsLabel": "중단된 도구 호출: {{value0}}",
|
||||
"runningPreview": "{{preview}} 실행 중",
|
||||
"runningCommand": "실행 명령",
|
||||
"showFullOutput": "전체 출력 보기",
|
||||
"fullOutputFailed": "전체 출력을 불러오지 못했습니다.",
|
||||
"fullOutputPartial": "{{total}} 중 {{shown}}을(를) 표시하는 중입니다.",
|
||||
"runningNamedPreview": "{{toolName}} {{preview}} 실행 중",
|
||||
"runningNamed": "{{toolName}} 실행 중",
|
||||
"ranCommandOneToolSummary": "{{commandCount}} 명령을 실행하고 {{toolCount}} 도구를 사용했습니다.",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user