diff --git a/packages/client/runtime/src/client/sessions/history.ts b/packages/client/runtime/src/client/sessions/history.ts index bf11e9d6ab..369c7ce43a 100644 --- a/packages/client/runtime/src/client/sessions/history.ts +++ b/packages/client/runtime/src/client/sessions/history.ts @@ -15,20 +15,22 @@ export interface SessionHistoryInspection { /** * Create a lazy inspection projection over an immutable history window. - * Conversation consumers retain the cheap wrapper; only Trajectory reads the - * getters that replay event order and request lifecycle state. - * @param entries - Contiguous raw history entries in sequence order. + * Conversation consumers retain the cheap wrapper; only Trajectory snapshots + * the entries and replays event order and request lifecycle state. + * @param loadEntries - Lazily snapshots contiguous raw entries in sequence order. * @returns Lazy, memoized inspection fields for that exact window. */ export function createHistoryInspection( - entries: readonly HistoryEntry[], + loadEntries: () => readonly HistoryEntry[], ): SessionHistoryInspection { + let entries: readonly HistoryEntry[] | undefined let conversation: ReturnType | undefined let requests: ReturnType | undefined + const historyEntries = () => entries ??= loadEntries() const conversationProjection = () => - conversation ??= projectConversationHistory(entries) + conversation ??= projectConversationHistory(historyEntries()) const requestProjection = () => - requests ??= inspectRequests(entries) + requests ??= inspectRequests(historyEntries()) return { get eventNodes() { return conversationProjection().eventNodes diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 1ee11a744a..7a87237d03 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -111,13 +111,13 @@ export class Session implements ObservableSnapshot { private codeDispatches = new Map() private dispatchesRev = 0 private dispatchesCache: { rev: number; value: ReadonlyMap } | null = null - /** Raw history revision; published entries are copied so later live appends never mutate a prior snapshot. */ + /** Raw history revision; inspection wrappers capture the exact array window and length. */ private historyRev = 0 - private historyEntriesCache: { rev: number; value: readonly HistoryEntry[] } | null = null private historyInspectionCache: { rev: number value: SessionHistoryInspection } | null = null + private loadOlderPromise: Promise | null = null private running = false /** * Sticky send marker, private input of the composerPhase derivation: set @@ -252,42 +252,56 @@ export class Session implements ObservableSnapshot { return promise } - /** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). */ - async loadOlder(): Promise { - if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return + /** + * Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). + * Concurrent callers share the active page so complete-history readers can continue afterward. + * @returns When the active or newly started page request settles. + */ + loadOlder(): Promise { + if (this.loadOlderPromise !== null) return this.loadOlderPromise + if (this.openState !== 'open' || !this.hasMore) return Promise.resolve() this.loadingOlder = true this.notifier.markDirty() - try { - const { result } = await this.api.sessions.history({ - sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES, - }) - if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded) - const older = result.value.events - if (older.length === 0) { + const generation = this.openGeneration + const operation = (async () => { + try { + const { result } = await this.api.sessions.history({ + sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES, + }) + if (generation !== this.openGeneration || this.openState !== 'open') return + if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded) + const older = result.value.events + if (older.length === 0) { + this.hasMore = result.value.hasMore + return + } + const tail = older[older.length - 1] + if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) { + // §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream. + console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`) + this.hasMore = false + return + } + this.events = [...older.map(e => e.event), ...this.events] + this.views = [...older.map(e => e.view), ...this.views] + this.historyRev++ + /* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */ + this.baseSeq = older[0]?.event.seq ?? this.baseSeq this.hasMore = result.value.hasMore - return + this.foldAdapter.reset(this.events, this.baseSeq, this.views) // prepend forces a rebuild (sentinel count changed) + this.rebuildDerivedFromWindow() + } catch (error) { + console.error('[web-runtime] loadOlder failed:', error) } - const tail = older[older.length - 1] - if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) { - // §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream. - console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`) - this.hasMore = false - return - } - this.events = [...older.map(e => e.event), ...this.events] - this.views = [...older.map(e => e.view), ...this.views] - this.historyRev++ - /* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */ - this.baseSeq = older[0]?.event.seq ?? this.baseSeq - this.hasMore = result.value.hasMore - this.foldAdapter.reset(this.events, this.baseSeq, this.views) // prepend forces a rebuild (sentinel count changed) - this.rebuildDerivedFromWindow() - } catch (error) { - console.error('[web-runtime] loadOlder failed:', error) - } finally { + })() + const settled = operation.finally(() => { + if (this.loadOlderPromise !== settled) return + this.loadOlderPromise = null this.loadingOlder = false this.notifier.markDirty() - } + }) + this.loadOlderPromise = settled + return settled } /** @@ -297,7 +311,7 @@ export class Session implements ObservableSnapshot { * @returns When the available history has been exhausted or paging stops making progress. */ async loadAllHistory(): Promise { - while (this.openState === 'open' && this.hasMore && !this.loadingOlder) { + while (this.openState === 'open' && this.hasMore) { const previousBaseSeq = this.baseSeq await this.loadOlder() if (this.baseSeq === previousBaseSeq) return @@ -844,24 +858,27 @@ export class Session implements ObservableSnapshot { } } - /** Build the lazy history inspection wrapper without leaking mutable window arrays. */ + /** Build a lazy inspection wrapper for the exact current history window. */ private buildHistoryInspection(): SessionHistoryInspection { - if (this.historyEntriesCache === null || this.historyEntriesCache.rev !== this.historyRev) { - this.historyEntriesCache = { - rev: this.historyRev, - value: this.events.map((event, index) => { - const view = this.views[index] - return view === undefined ? { event } : { event, view } - }), - } - } if ( this.historyInspectionCache === null || this.historyInspectionCache.rev !== this.historyRev ) { + const events = this.events + const views = this.views + const length = events.length this.historyInspectionCache = { rev: this.historyRev, - value: createHistoryInspection(this.historyEntriesCache.value), + value: createHistoryInspection(() => + Array.from({ length }, (_, index) => { + const event = events[index] + if (event === undefined) { + throw new Error('captured history window changed before inspection') + } + const view = views[index] + return view === undefined ? { event } : { event, view } + }), + ), } } return this.historyInspectionCache.value diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index d1450800e3..fc816e2a9c 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -124,6 +124,21 @@ describe('live event path', () => { expect((last as { interrupted?: true }).interrupted).toBeUndefined() }) + it('keeps a lazily inspected snapshot pinned to its original history window', async () => { + const { session } = await opened() + const before = session.getSnapshot() + + session.handleMuxEnvelope('r' as never, { + type: 'session/event', + sessionId: SID, + event: ev.user(6, 'later'), + }) + + expect(before.inspection?.eventNodes.map(node => node.seq)).toEqual([1, 3]) + expect(session.getSnapshot().inspection?.eventNodes.map(node => node.seq)) + .toEqual([1, 3, 6]) + }) + it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => { const { session } = await opened() const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } @@ -292,6 +307,36 @@ describe('paging', () => { expect(session.getSnapshot().hasMore).toBe(true) }) + it('continues complete-history loading after an already active page', async () => { + const pages = [ + plainTurn(0, 0, '最早问', '最早答'), + plainTurn(6, 1, '中间问', '中间答'), + plainTurn(12, 2, '最新问', '最新答'), + ] + const middle = deferred>>() + const { api, session } = makeSession() + api.onHistory = (payload) => { + if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true) + if (payload.beforeSeq === 12) return middle.promise + return histResponse(pages[0]!, false) + } + + await session.open() + const activePage = session.loadOlder() + const completeHistory = session.loadAllHistory() + middle.resolve(ok({ + events: entries(pages[1]!) as never[], + hasMore: true, + modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' }, + })) + await Promise.all([activePage, completeHistory]) + + expect(api.callsOf('session.history')).toHaveLength(3) + expect(session.getSnapshot().hasMore).toBe(false) + expect(session.getSnapshot().nodes.map(node => node.seq)) + .toEqual([1, 3, 7, 9, 13, 15]) + }) + it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => { const { api, session } = makeSession() api.onHistory = payload => payload.beforeSeq === undefined diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index 8641166ef3..1bb8fa62cc 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -289,6 +289,7 @@ function JsonTreeNode({ /> {previewValue(value, 0)} + {!lastElement && ,} {expanded && (
    {entries.map(([key, item], index) => ( @@ -524,7 +525,7 @@ export function JsonTree({ field={key} value={value} path={[Array.isArray(data) ? index : key]} - lastElement + lastElement={index === rootEntries.length - 1} initialExpanded={false} tabStopId={tabStopId} onClaimTabStop={setTabStopId} diff --git a/packages/client/ui-primitives/tests/json-tree.spec.tsx b/packages/client/ui-primitives/tests/json-tree.spec.tsx index 1455b18f1e..b9dd962ad3 100644 --- a/packages/client/ui-primitives/tests/json-tree.spec.tsx +++ b/packages/client/ui-primitives/tests/json-tree.spec.tsx @@ -34,7 +34,7 @@ describe('JsonTree', () => { const tree = screen.getByRole('tree', { name: 'Payload' }) const rows = within(tree).getAllByRole('treeitem') expect(rows).toHaveLength(2) - expect(rows[0]?.textContent).toBe('nested:{answer: 42}') + expect(rows[0]?.textContent).toBe('nested:{answer: 42},') expect(rows[1]?.textContent).toBe('list:["alpha", "beta"]') const expanders = within(tree).getAllByRole('button', { name: 'Expand JSON node' }) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 93b7bf1ba0..873ea09477 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1188,6 +1188,20 @@ function RecordPayload({ : 'No result captured' if (!value) return

    {missing}

    + const json = parseJsonContainer(value) + const singleTextResult = direction === 'output' + && record.cell.outputBlocks?.length === 1 + && record.cell.outputBlocks[0]?.type === 'text' + if (singleTextResult && json !== undefined) { + return ( + + ) + } + if ( direction === 'output' && record.cell.outputBlocks?.some(block => @@ -1214,7 +1228,6 @@ function RecordPayload({ ) } - const json = parseJsonContainer(value) if (json !== undefined) { return ( requests.filter(request => + trajectoryBranchContainsSeq(currentBranch, request.startSeq), + ), + [currentBranch, requests], + ) const globalRequestNumbers = useMemo(() => { const assistantsByStep = new Map() for (const context of contexts) { @@ -235,12 +241,12 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T nodes: selectedNodes, partial, runningCalls, - requests, + requests: selectedRequests, ...(callSchemas === undefined ? {} : { callSchemas }), codeDispatches, }), [ - selectedNodes, partial, runningCalls, requests, callSchemas, codeDispatches, + selectedNodes, partial, runningCalls, selectedRequests, callSchemas, codeDispatches, ], ) const collapsibleTurnIds = useMemo( diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index e4a2a1de65..ee073c754e 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -96,6 +96,33 @@ describe('TrajectoryTable', () => { expect(screen.getByText('ToolError: non_zero_exit')).toBeTruthy() }) + it('renders a single-text JSON tool result as a JSON tree', () => { + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: [{ + index: 1, + kind: 'tool', + text: 'read {"path":"result.json"}', + outputDetail: '{"value":1,"nested":{"ok":true}}', + outputBlocks: [{ + type: 'text', + content: '{"value":1,"nested":{"ok":true}}', + }], + timeSeconds: 0.1, + }], + }], + }] + + render() + fireEvent.click(screen.getByRole('row', { name: /TOOL/ })) + fireEvent.click(screen.getByRole('tab', { name: 'Result' })) + + expect(screen.getByRole('tree', { name: 'Result JSON' })).toBeTruthy() + expect(screen.getByText('value:')).toBeTruthy() + }) + it('keeps the first row and a compact summary when a turn is collapsed', () => { render( { }) }) +describe('TrajectoryView branches', () => { + it('renders only the selected rewind branch while retaining session-global requests', () => { + const retained = { + kind: 'user', + seq: 1, + time: 1_000, + content: [{ type: 'text', text: 'retained user' }], + source: null, + } as unknown as ConversationSnapshot['nodes'][number] + const abandoned = { + kind: 'assistant', + seq: 3, + time: 3_000, + turn: 1, + step: 1, + blocks: [{ kind: 'text', text: 'abandoned response' }], + } as unknown as ConversationSnapshot['nodes'][number] + const current = { + kind: 'assistant', + seq: 5, + time: 5_000, + turn: 2, + step: 1, + blocks: [{ kind: 'text', text: 'current response' }], + } as unknown as ConversationSnapshot['nodes'][number] + const request = (startSeq: number, turn: number): RequestView => ({ + purpose: 'assistant', + startSeq, + turn, + step: 1, + startedAt: startSeq * 1_000, + completedAt: startSeq * 1_000 + 100, + status: 'complete', + }) + const store = createSnapshotStore({ + nodes: [retained, current], + inspection: { + eventNodes: [retained, abandoned, current], + contexts: [ + { id: 0, nodes: [retained, abandoned] }, + { + id: 1, + parentId: 0, + origin: 'rewind' as const, + originSeq: 4, + nodes: [retained, current], + }, + ], + requests: [request(2, 1), request(4, 2)], + callSchemas: new Map(), + }, + openState: 'open' as const, + hasMore: false, + partial: null, + runningCalls: [] as ConversationSnapshot['runningCalls'], + codeDispatches: new Map(), + }) + + const view = render( + } + loadAllHistory={vi.fn(() => Promise.resolve())} + />, + ) + + expect(screen.queryByText('abandoned response')).toBeNull() + expect(screen.getByText('current response')).toBeTruthy() + expect(screen.getByRole('row', { name: /Request 2, ASSISTANT/ })).toBeTruthy() + expect(view.container.querySelectorAll('[data-request-only="true"]')).toHaveLength(0) + }) +}) + describe('WaterfallView standalone branches', () => { it('empty window renders the placeholder copy', () => { render(createElement(WaterfallView as FC,