From f908d9b5164013be82fc2d729dc3f4d437ccd91b Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Mon, 17 Aug 2026 11:30:13 +0800 Subject: [PATCH 01/16] fix(web-shell): bound daemon transcript retention to stop renderer OOM crashes Watching a busy daemon session (long turn, many subagents) could exhaust the browser renderer: the replay snapshot stayed pinned on the session client for the whole attachment, replay rebuilds ran uncapped and could ratchet the transcript block cap above its configured limit, and a few retention windows were implicitly far larger than intended. Observed as a Chrome renderer abort after multi-GB growth, reproducible on every reload of the affected session. - Release the replay snapshot once it is injected into the transcript store (SSE resumes from lastEventId; older history via pagination) - Rebuild replays under the configured maxBlocks cap and never raise the committed cap above it; trimming keeps the most recent blocks - Cap the debug text embedded for unrecognized session_update kinds, which appended one block per frame with the full payload - Pass the web-shell block cap to the subagent detail provider and lower the provider default window from 200k to 50k blocks --- .../src/daemon/DaemonSessionClient.ts | 32 ++++- .../src/daemon/ui/normalizer.ts | 8 +- .../test/unit/DaemonSessionClient.test.ts | 31 +++++ .../sdk-typescript/test/unit/daemonUi.test.ts | 19 +++ .../components/artifacts/SubagentDetail.tsx | 2 + .../session/DaemonSessionProvider.test.tsx | 119 +++++++++++++++++- .../daemon/session/DaemonSessionProvider.tsx | 60 ++++++--- 7 files changed, 247 insertions(+), 24 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index f7157eb23ae..c5d2b9fb27a 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -144,7 +144,14 @@ export class DaemonSessionClient { readonly client: DaemonClient; readonly session: DaemonSession; readonly state: DaemonSessionState; - readonly replaySnapshot: DaemonReplaySnapshot; + /** + * Backing store for {@link replaySnapshot}. Mutable so consumers can + * `consumeReplaySnapshot()` once the snapshot has been injected into a + * transcript store, releasing the raw wire events (which can reach tens + * of MiB for busy sessions) instead of retaining them for the lifetime + * of the session client. + */ + private retainedReplaySnapshot: DaemonReplaySnapshot; readonly replaySnapshotComplete: boolean; readonly replayPartial: boolean; readonly replayError: string | undefined; @@ -194,7 +201,7 @@ export class DaemonSessionClient { this.historyHasMore = opts.historyHasMore ?? false; this.historyAnchorRecordId = opts.historyAnchorRecordId; this.replayDegraded = opts.replayDegraded ?? false; - this.replaySnapshot = opts.replaySnapshot ?? { + this.retainedReplaySnapshot = opts.replaySnapshot ?? { compactedReplay: [], liveJournal: [], }; @@ -371,6 +378,27 @@ export class DaemonSessionClient { return this.lastSeenEpoch; } + /** + * The replay snapshot captured by `load()`. Empty after + * {@link consumeReplaySnapshot} has been called. + */ + get replaySnapshot(): DaemonReplaySnapshot { + return this.retainedReplaySnapshot; + } + + /** + * Returns the retained replay snapshot and drops the client's reference + * to it. Call once the snapshot has been injected into a transcript + * store; the raw wire events are no longer needed (SSE continues from + * `lastEventId`, and older history is served by pagination) and can + * otherwise pin tens of MiB per session client. + */ + consumeReplaySnapshot(): DaemonReplaySnapshot { + const snapshot = this.retainedReplaySnapshot; + this.retainedReplaySnapshot = { compactedReplay: [], liveJournal: [] }; + return snapshot; + } + setLastEventId(lastEventId: number | undefined): void { this.lastSeenEventId = validateLastEventId(lastEventId); } diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 1ecc62fb5b7..895368041d4 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -879,7 +879,13 @@ function normalizeSessionUpdate( debugReason: kind?.trim() ? 'unrecognized_session_update' : 'malformed_payload', - text: `${kind ?? 'session_update'}: ${stringifyRedactedJson(update)}`, + // One block per such frame is appended to the transcript, so the + // payload embedding must stay tiny: a high-frequency unrecognized + // kind (e.g. a newer daemon's streaming update) could otherwise + // accumulate 100KB blocks up to the transcript block cap. + text: capDetails( + `${kind ?? 'session_update'}: ${stringifyRedactedJson(update)}`, + ), }, ]; } diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index 41b99b9484a..f431620429c 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -334,6 +334,37 @@ describe('DaemonSessionClient', () => { expect(calls[1]?.headers['last-event-id']).toBe('42'); }); + it('releases the replay snapshot once consumed', async () => { + const { fetch } = recordingFetch((req) => { + if (req.url.endsWith('/session/s-1/load')) { + return jsonResponse(200, { + sessionId: 's-1', + workspaceCwd: '/work/a', + attached: true, + clientId: 'client-1', + state: {}, + lastEventId: 7, + compactedReplay: [{ id: 1, v: 1, type: 'session_update', data: {} }], + liveJournal: [{ id: 7, v: 1, type: 'session_update', data: {} }], + }); + } + return jsonResponse(500, { error: `unexpected ${req.url}` }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + const session = await DaemonSessionClient.load(client, 's-1'); + expect(session.replaySnapshot.compactedReplay).toHaveLength(1); + expect(session.replaySnapshot.liveJournal).toHaveLength(1); + + const consumed = session.consumeReplaySnapshot(); + expect(consumed.compactedReplay).toHaveLength(1); + expect(consumed.liveJournal).toHaveLength(1); + expect(session.replaySnapshot.compactedReplay).toHaveLength(0); + expect(session.replaySnapshot.liveJournal).toHaveLength(0); + // Idempotent: a second consume returns the empty snapshot. + expect(session.consumeReplaySnapshot().compactedReplay).toHaveLength(0); + }); + it('loads restored prompt activity from hasActivePrompt responses', async () => { const { fetch } = recordingFetch((req) => { if (req.url.endsWith('/session/s-1/load')) { diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index d4e49da1f58..ab8484541e5 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -2662,6 +2662,25 @@ describe('daemon UI normalizer — Wave 3/4 event coverage (PR-A)', () => { ]); }); + it('caps the embedded payload of unrecognized session_update debug text', () => { + // One transcript block per such frame is appended, so an unrecognized + // kind streaming at high frequency must not embed its full payload + // (up to 100KB wire frames) — the text is capped like tool details. + const events = normalizeDaemonEvent( + envelopeOf('session_update', { + update: { + sessionUpdate: 'some_future_kind', + payload: 'x'.repeat(100_000), + }, + }), + ); + + expect(events).toHaveLength(1); + const text = (events[0] as { text?: string }).text ?? ''; + expect(text.length).toBeLessThanOrEqual(4096 + '... [truncated]'.length); + expect(text.endsWith('... [truncated]')).toBe(true); + }); + it('classifies a session_update with no usable discriminator as malformed', () => { // `getSessionUpdatePayload` accepts any record, so these reach the default // branch with `kind === undefined`. They are broken frames, not kinds from diff --git a/packages/web-shell/client/components/artifacts/SubagentDetail.tsx b/packages/web-shell/client/components/artifacts/SubagentDetail.tsx index da1057a073c..453810e53e6 100644 --- a/packages/web-shell/client/components/artifacts/SubagentDetail.tsx +++ b/packages/web-shell/client/components/artifacts/SubagentDetail.tsx @@ -6,6 +6,7 @@ import { } from '@qwen-code/webui/daemon-react-sdk'; import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon'; import type { ACPToolCall, Message } from '../../adapters/types'; +import { WEB_SHELL_MAX_TRANSCRIPT_BLOCKS } from '../../constants/sessions'; import { useAnimationFrameTranscriptBlocks } from '../../hooks/useAnimationFrameTranscriptBlocks'; import { useMessagesFromBlocks } from '../../hooks/useMessages'; import { useSessionArtifacts } from '../../hooks/useSessionArtifacts'; @@ -365,6 +366,7 @@ export function SubagentDetail({ workspaceCwd={workspaceCwd} clientId={instance.clientId} maxQueued={256} + maxBlocks={WEB_SHELL_MAX_TRANSCRIPT_BLOCKS} subagentTranscriptMode="full" suppressOwnUserEcho > diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 6b93cabdefb..b703bd1a52a 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -100,6 +100,10 @@ interface MockSession { compactedReplay: DaemonEvent[]; liveJournal: DaemonEvent[]; }; + consumeReplaySnapshot: () => { + compactedReplay: DaemonEvent[]; + liveJournal: DaemonEvent[]; + }; events: (opts?: { signal?: AbortSignal; maxQueued?: number; @@ -3829,11 +3833,16 @@ describe('DaemonSessionProvider', () => { const sdk = await import('@qwen-code/sdk/daemon'); const realCreateStore = sdk.createDaemonTranscriptStore; const replayDispatchBatchSizes: number[] = []; + // The first store created is the provider's main store; every later + // store in this flow is the replay rebuild store (replay now rebuilds + // under the same maxBlocks cap, so the seed no longer identifies it). + let createStoreCalls = 0; const createStoreSpy = vi .spyOn(sdk, 'createDaemonTranscriptStore') .mockImplementation((seed) => { const store = realCreateStore(seed); - if (seed?.maxBlocks === Number.MAX_SAFE_INTEGER) { + createStoreCalls += 1; + if (createStoreCalls > 1) { const realDispatch = store.dispatch.bind(store); store.dispatch = (event) => { replayDispatchBatchSizes.push( @@ -12112,7 +12121,7 @@ describe('DaemonSessionProvider', () => { expect(notices.at(-1)).toBeUndefined(); }); - it('keeps an oversized initial replay intact and stops older pagination', async () => { + it('trims an oversized initial replay to the block cap and stops older pagination', async () => { sdkMocks.capabilities.mockResolvedValue({ workspaceCwd: '/mock-workspace', features: ['session_transcript_pagination'], @@ -12161,8 +12170,10 @@ describe('DaemonSessionProvider', () => { maxBlocks: 1, }); + // The rebuild keeps the most recent blocks within the cap instead of + // ratcheting the cap up to the replay size (which retained unbounded + // history and exhausted renderer memory on busy sessions). expect(blocks).toMatchObject([ - { kind: 'user', text: 'recent prompt' }, { kind: 'assistant', text: 'recent answer' }, ]); expect(history).toMatchObject({ @@ -12173,6 +12184,88 @@ describe('DaemonSessionProvider', () => { expect(sdkMocks.getSessionTranscriptPage).not.toHaveBeenCalled(); }); + it('releases the replay snapshot after injection and never raises the block cap', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: [], + }); + const chunk = ( + id: number, + kind: 'user_message_chunk' | 'agent_message_chunk', + text: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: kind, + content: { type: 'text', text }, + }, + }, + }); + const session = createMockSession({ + sessionId: 'session-consume-replay', + lastEventId: 3, + replaySnapshot: { + compactedReplay: [ + chunk(1, 'user_message_chunk', 'replayed prompt'), + chunk(2, 'agent_message_chunk', 'replayed answer'), + { + id: 3, + v: 1, + type: 'turn_complete', + data: { stopReason: 'end_turn' }, + }, + ], + liveJournal: [], + }, + events: async function* oneLiveEventThenIdle( + opts: { signal?: AbortSignal } = {}, + ) { + yield chunk(4, 'user_message_chunk', 'live follow-up'); + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + yield* []; + }, + }); + sdkMocks.sessions.push(session); + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + maxBlocks: 1, + }); + await act(async () => { + await vi.waitFor(() => + expect(blocks).toMatchObject([ + { kind: 'user', text: 'live follow-up' }, + ]), + ); + }); + + // The oversized replay was trimmed to the cap on injection... + expect(blocks).toHaveLength(1); + // ...the raw snapshot was released from the session client... + expect(session.consumeReplaySnapshot).toHaveBeenCalledTimes(1); + expect(session.replaySnapshot.compactedReplay).toHaveLength(0); + expect(session.replaySnapshot.liveJournal).toHaveLength(0); + // ...and later live growth still trims at the configured cap, i.e. the + // replay did not ratchet maxBlocks up to its own size. + }); + it('rejects a terminal older page that does not fit atomically', async () => { sdkMocks.capabilities.mockResolvedValue({ workspaceCwd: '/mock-workspace', @@ -12182,13 +12275,14 @@ describe('DaemonSessionProvider', () => { id: number, text: string, recordId?: string, + kind: 'user_message_chunk' | 'agent_message_chunk' = 'user_message_chunk', ): DaemonEvent => ({ id, v: 1, type: 'session_update', data: { update: { - sessionUpdate: 'user_message_chunk', + sessionUpdate: kind, content: { type: 'text', text }, ...(recordId ? { _meta: { 'qwen.session.recordId': recordId } } : {}), }, @@ -12203,10 +12297,16 @@ describe('DaemonSessionProvider', () => { }, }); sdkMocks.sessions.push(session); + // Two older blocks (distinct kinds so they do not merge): with the + // retained block they overflow the cap, so the terminal page must be + // rejected atomically instead of partially prepended. sdkMocks.getSessionTranscriptPage.mockResolvedValue({ v: 1, sessionId: session.sessionId, - events: [replayEvent(1, 'older prompt')], + events: [ + replayEvent(0, 'older prompt'), + replayEvent(1, 'older answer', undefined, 'agent_message_chunk'), + ], hasMore: false, }); let history: ReturnType | undefined; @@ -12218,10 +12318,12 @@ describe('DaemonSessionProvider', () => { return null; } + // maxBlocks 2 leaves headroom over the single retained replay block so + // the load actually proceeds and exercises the atomic page rejection. await renderWithProvider(, { autoConnect: true, historyPageSize: 25, - maxBlocks: 1, + maxBlocks: 2, }); await act(async () => { await history?.loadMore(); @@ -12442,6 +12544,11 @@ function createMockSession(opts: Partial = {}): MockSession { compactedReplay: [], liveJournal: [], }, + consumeReplaySnapshot: vi.fn(() => { + const snapshot = session.replaySnapshot; + session.replaySnapshot = { compactedReplay: [], liveJournal: [] }; + return snapshot; + }), events: opts.events ?? createIdleEvents(), }; return session; diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index a3df97d06a9..83950c9eadb 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -492,10 +492,12 @@ interface HeartbeatFailureState { // Keep enough transcript history for large daemon replay streams so event order // and subagent grouping survive replay. Rendering is virtualized, but message -// normalization still rebuilds from retained blocks today, so this high default -// is a history-preservation tradeoff rather than a claim that large transcripts -// are CPU-free. Callers can pass a smaller maxBlocks in constrained contexts. -const DEFAULT_MAX_BLOCKS = 200_000; +// normalization still rebuilds from retained blocks today, so this default is a +// history-preservation tradeoff rather than a claim that large transcripts are +// CPU-free. The value is also a memory ceiling: blocks can carry large tool +// payloads, so an implicit 200k window let a single busy session exhaust +// renderer memory. Callers can pass a smaller maxBlocks in constrained contexts. +const DEFAULT_MAX_BLOCKS = 50_000; const INITIAL_WORKSPACE_EVENT_SIGNALS: DaemonWorkspaceEventSignals = { memoryVersion: 0, @@ -1482,7 +1484,18 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ) && (activeSession.historyHasMore || replayHistoryWasTruncated) && firstPersistedRecordId !== undefined; - if (!repairingEpisode) { + const replayInjected = + shouldInjectReplaySnapshot && replayEvents.length > 0; + // After the snapshot is consumed the replay-derived inputs above + // (firstPersistedRecordId, replayHistoryWasTruncated) recompute + // empty on delta-resume reconnects; keep the history state that + // the original injection initialized instead of clobbering it. + if ( + !repairingEpisode && + (replayInjected || + transcriptHistoryRef.current.sessionId !== + activeSession.sessionId) + ) { transcriptHistoryRef.current = { sessionId: activeSession.sessionId, ...(firstPersistedRecordId !== undefined @@ -1500,6 +1513,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { paginationError: false, }); } else if ( + repairingEpisode && !markerStillVisible && firstPersistedRecordId !== undefined ) { @@ -1507,8 +1521,14 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { firstPersistedRecordId; transcriptHistoryRef.current.cursor = undefined; } - const replayInjected = - shouldInjectReplaySnapshot && replayEvents.length > 0; + if (replayInjected) { + // The snapshot is injected into the store below and never read + // again (SSE continues from lastEventId; older history arrives + // via pagination). Drop the client's reference so busy-session + // snapshots — tens of MiB of raw wire events after adaptive + // journal growth — don't stay pinned for the whole attachment. + activeSession.consumeReplaySnapshot(); + } if (needsStoreReset && !replayInjected) { // Reset needed but no replay data (e.g. fresh session) — reset // immediately since there is no dispatch to batch with. @@ -1643,11 +1663,16 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { needsStoreReset || store.getSnapshot().blocks.length === 0; if (rebuildReplay) { - const replayMaxBlocks = repairingEpisode - ? markerStillVisible + // Ordinary replay rebuilds under the same cap as live growth: + // a session loaded mid-turn can carry a live journal with tens + // of thousands of events, and retaining it all (the previous + // uncapped rebuild) exhausted renderer memory. Trimming keeps + // the most recent blocks; older history stays reachable via + // pagination. + const replayMaxBlocks = + repairingEpisode && markerStillVisible ? repairingEpisode.checkpoint.maxBlocks - : maxBlocks - : Number.MAX_SAFE_INTEGER; + : maxBlocks; const replayStore = createDaemonTranscriptStore( repairingEpisode && markerStillVisible ? { @@ -1674,12 +1699,17 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { } } const replayState = replayStore.getSnapshot(); + // A saturated rebuild means older blocks were trimmed away + // (or the window is exactly full), so there is no in-store + // room for pagination either way — surface capacityReached. replayExceededCapacity = repairingEpisode === undefined && - replayState.blocks.length > maxBlocks; - const committedMaxBlocks = repairingEpisode - ? replayMaxBlocks - : Math.max(maxBlocks, replayState.blocks.length); + replayState.blocks.length >= maxBlocks; + // Replay must never ratchet the retention window above the + // configured cap: the committed cap is what bounds every + // later dispatch, and an escalation here turned one large + // replay into permanent unbounded retention. + const committedMaxBlocks = replayMaxBlocks; store.reset({ ...replayState, maxBlocks: committedMaxBlocks, From f7301e4482ce494f5ff079893707c6f5b652dfdc Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Mon, 17 Aug 2026 06:31:38 +0000 Subject: [PATCH 02/16] fix(review): address round-1 findings for transcript retention (#9303) Co-authored-by: Qwen-Coder --- .../src/daemon/ui/normalizer.ts | 18 ++- .../sdk-typescript/test/unit/daemonUi.test.ts | 34 ++++++ .../web-shell/client/constants/sessions.ts | 12 +- .../session/DaemonSessionProvider.test.tsx | 109 ++++++++++++++++++ 4 files changed, 162 insertions(+), 11 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 895368041d4..2a6160c1440 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -404,7 +404,9 @@ function normalizeUnrecognizedEvent( ...base, type: 'debug', debugReason: 'unrecognized_event', - text: `${event.type} (unrecognized daemon event): ${stringifyRedactedJson(event.data)}`, + text: capDetails( + `${event.type} (unrecognized daemon event): ${stringifyRedactedJson(event.data)}`, + ), }, ]; } @@ -703,7 +705,9 @@ function normalizeSessionUpdate( ...base, type: 'debug', debugReason: 'malformed_payload', - text: `session_update: ${stringifyRedactedJson(event.data)}`, + text: capDetails( + `session_update: ${stringifyRedactedJson(event.data)}`, + ), }, ]; } @@ -1175,7 +1179,9 @@ function normalizePermissionRequest( ...base, type: 'debug', debugReason: 'malformed_payload', - text: `permission_request: ${stringifyRedactedJson(event.data)}`, + text: capDetails( + `permission_request: ${stringifyRedactedJson(event.data)}`, + ), }, ]; } @@ -1187,7 +1193,9 @@ function normalizePermissionRequest( ...base, type: 'debug', debugReason: 'malformed_payload', - text: `permission_request: ${stringifyRedactedJson(event.data)}`, + text: capDetails( + `permission_request: ${stringifyRedactedJson(event.data)}`, + ), }, ]; } @@ -1221,7 +1229,7 @@ function normalizePermissionResolved( ...base, type: 'debug', debugReason: 'malformed_payload', - text: `${event.type}: ${stringifyRedactedJson(event.data)}`, + text: capDetails(`${event.type}: ${stringifyRedactedJson(event.data)}`), }, ]; } diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index ab8484541e5..d059cbe0c4c 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -2681,6 +2681,40 @@ describe('daemon UI normalizer — Wave 3/4 event coverage (PR-A)', () => { expect(text.endsWith('... [truncated]')).toBe(true); }); + it('caps the embedded payload of the sibling debug-block paths too', () => { + // The one-block-per-frame accumulation hazard is not specific to the + // unrecognized session_update branch: every debug path that embeds the + // raw payload must stay capped, or a high-frequency frame taking any + // sibling path reproduces the same unbounded-growth OOM. + const large = 'x'.repeat(100_000); + const expectCapped = (events: ReturnType) => { + expect(events).toHaveLength(1); + const text = (events[0] as { text?: string }).text ?? ''; + expect(text.length).toBeLessThanOrEqual(4096 + '... [truncated]'.length); + expect(text.endsWith('... [truncated]')).toBe(true); + }; + + // Top-level unrecognized event type. + expectCapped( + normalizeDaemonEvent(envelopeOf('some_future_event', { payload: large })), + ); + // session_update with a non-record payload (no usable update record). + expectCapped(normalizeDaemonEvent(envelopeOf('session_update', large))); + // permission_request with a non-record payload and one without requestId. + expectCapped(normalizeDaemonEvent(envelopeOf('permission_request', large))); + expectCapped( + normalizeDaemonEvent( + envelopeOf('permission_request', { payload: large }), + ), + ); + // permission_resolved without requestId. + expectCapped( + normalizeDaemonEvent( + envelopeOf('permission_resolved', { payload: large }), + ), + ); + }); + it('classifies a session_update with no usable discriminator as malformed', () => { // `getSessionUpdatePayload` accepts any record, so these reach the default // branch with `kind === undefined`. They are broken frames, not kinds from diff --git a/packages/web-shell/client/constants/sessions.ts b/packages/web-shell/client/constants/sessions.ts index 0e5bc7d8ec9..4172cd8d58e 100644 --- a/packages/web-shell/client/constants/sessions.ts +++ b/packages/web-shell/client/constants/sessions.ts @@ -25,11 +25,11 @@ export const WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS = 500; * (the main chat and each split pane). The daemon stays the authoritative * full-transcript source; this only caps the client's in-memory window. * - * The SDK default (200_000) is far beyond what the virtualized message list - * renders, and it inflates both the per-dispatch reducer cost (a full - * block-array copy) and the full-list message normalization. On a large - * transcript that turns a burst of buffered SSE events — e.g. the stream - * catching up when the tab returns from being hidden — into a multi-minute - * main-thread block. Bounding the window keeps very long sessions responsive. + * Matches the provider's DEFAULT_MAX_BLOCKS; passed explicitly so Web Shell + * retention cannot drift if that default changes. Bounding the window keeps + * very long sessions responsive: the per-dispatch reducer cost (a full + * block-array copy) and the full-list message normalization turn a burst of + * buffered SSE events — e.g. the stream catching up when the tab returns + * from being hidden — into a long main-thread block on large transcripts. */ export const WEB_SHELL_MAX_TRANSCRIPT_BLOCKS = 50_000; diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index b703bd1a52a..be430b2530d 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -3998,6 +3998,115 @@ describe('DaemonSessionProvider', () => { }, ); + it('keeps replay-derived pagination state across a same-session reconnect', async () => { + // Regression coverage: once consumeReplaySnapshot() releases the + // snapshot, a same-session reconnect (stream-end resubscribe) recomputes + // the history inputs empty — firstPersistedRecordId degrades to + // historyAnchorRecordId and replayHistoryWasTruncated to false. The + // history state the original injection initialized (hasMore / + // beforeRecordId derived from the replay window) must survive instead of + // being clobbered with the degraded recomputation, which would make + // older history unloadable until a page reload. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const events = vi.fn(async function* endOnceThenIdle( + opts: { signal?: AbortSignal } = {}, + ) { + if (events.mock.calls.length === 1) { + return; + } + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + yield* []; + }); + const session = createMockSession({ + sessionId: 'session-pagination-reconnect', + // historyHasMore / historyAnchorRecordId stay at their defaults + // (false / undefined): pagination state derives solely from the replay + // window, so a clobbering recomputation would drop both. + replaySnapshot: { + compactedReplay: [ + { + v: 1, + type: 'history_truncated', + data: { + reason: 'replay_window_exceeded', + truncatedEvents: 4, + retainedEvents: 2, + maxBytes: 512, + fullTranscriptAvailable: true, + }, + }, + { + id: 5, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'retained replay' }, + _meta: { 'qwen.session.recordId': 'record-retained' }, + }, + }, + }, + ], + liveJournal: [], + }, + events, + }); + sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: [], + hasMore: false, + }); + let history: ReturnType | undefined; + + function Harness() { + history = useDaemonTranscriptHistory(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + historyPageSize: 25, + }); + await act(async () => { + await wait(5); + await flushPromises(); + }); + + // The stream ended once and the provider resubscribed the same session... + expect(events).toHaveBeenCalledTimes(2); + expect(events.mock.calls[1]?.[0]).toMatchObject({ + sseConnectReason: 'stream_end', + }); + // ...and the pagination state initialized by the replay injection + // survives the reconnect even though the snapshot was consumed. + expect(history?.hasMore).toBe(true); + await act(async () => history?.loadMore()); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledWith( + session.sessionId, + { + beforeRecordId: 'record-retained', + limit: 25, + clientId: session.clientId, + }, + ); + }); + it('uses history_truncated marker recordId as pagination anchor when session_updates lack one', async () => { // Regression coverage: a live-journal truncation during a single long // in-flight turn can leave the retained window with no From d36777e23f52f5321ab7c8e6e914cef7fbd9dc62 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Mon, 17 Aug 2026 10:12:35 +0000 Subject: [PATCH 03/16] fix(review): cap debug payloads at the producer for transcript retention (#9303) --- packages/sdk-typescript/scripts/build.js | 5 ++- .../src/daemon/ui/normalizer.ts | 41 ++++++++----------- .../src/daemon/ui/toolPreview.ts | 5 ++- .../sdk-typescript/src/daemon/ui/utils.ts | 11 +++++ .../sdk-typescript/test/unit/daemonUi.test.ts | 18 ++++++++ 5 files changed, 55 insertions(+), 25 deletions(-) diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index d3ffa560ef9..3b3b3ce0330 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -92,7 +92,10 @@ const rootDir = join(__dirname, '..'); // metadata (#9180). // Bumped from 195KB to 196KB for transient-vs-gone media hydration errors and // the reference-only replay placeholder. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 196 * 1024; +// Bumped from 196KB to 197KB for the daemon transcript-retention work +// (replay-snapshot release + capped debug payloads, #9303) landing on top of +// the session media references bundle. +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 197 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index a2bd39ae8d7..7fcc3a5de84 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -22,6 +22,7 @@ import type { } from './types.js'; import { DAEMON_PLAN_TOOL_CALL_ID } from './types.js'; import { + capDetails, getFirstString, getOutputText, getString, @@ -59,7 +60,6 @@ const MCP_RESTART_REFUSED_REASONS = new Set([ ]); const MALFORMED_MEMORY_CHANGED = 'malformed memory_changed payload'; -const MAX_DETAILS_LENGTH = 4096; const SESSION_RECORDING_DEGRADED_MESSAGE = 'Session recording stopped after a write failure. New messages for the affected session will not be saved. Check disk space and permissions, then start a new session to resume recording.'; @@ -407,8 +407,9 @@ function normalizeUnrecognizedEvent( ...base, type: 'debug', debugReason: 'unrecognized_event', - text: capDetails( - `${event.type} (unrecognized daemon event): ${stringifyRedactedJson(event.data)}`, + text: debugBlockText( + `${event.type} (unrecognized daemon event)`, + event.data, ), }, ]; @@ -769,9 +770,7 @@ function normalizeSessionUpdate( ...base, type: 'debug', debugReason: 'malformed_payload', - text: capDetails( - `session_update: ${stringifyRedactedJson(event.data)}`, - ), + text: debugBlockText('session_update', event.data), }, ]; } @@ -968,13 +967,7 @@ function normalizeSessionUpdate( debugReason: kind?.trim() ? 'unrecognized_session_update' : 'malformed_payload', - // One block per such frame is appended to the transcript, so the - // payload embedding must stay tiny: a high-frequency unrecognized - // kind (e.g. a newer daemon's streaming update) could otherwise - // accumulate 100KB blocks up to the transcript block cap. - text: capDetails( - `${kind ?? 'session_update'}: ${stringifyRedactedJson(update)}`, - ), + text: debugBlockText(kind ?? 'session_update', update), }, ]; } @@ -1249,9 +1242,15 @@ function asDaemonErrorKind( : undefined; } -function capDetails(details: string): string { - if (details.length <= MAX_DETAILS_LENGTH) return details; - return `${details.slice(0, MAX_DETAILS_LENGTH)}... [truncated]`; +/** + * Builds the `text` of a `debug` block that embeds an unrecognized or + * malformed payload, capped at the producer. One such block is appended per + * frame, so a high-frequency frame could otherwise accumulate 100KB blocks up + * to the transcript block cap; capping here means a future debug branch + * cannot drop the cap. + */ +function debugBlockText(prefix: string, data: unknown): string { + return capDetails(`${prefix}: ${stringifyRedactedJson(data)}`); } function normalizePermissionRequest( @@ -1264,9 +1263,7 @@ function normalizePermissionRequest( ...base, type: 'debug', debugReason: 'malformed_payload', - text: capDetails( - `permission_request: ${stringifyRedactedJson(event.data)}`, - ), + text: debugBlockText('permission_request', event.data), }, ]; } @@ -1278,9 +1275,7 @@ function normalizePermissionRequest( ...base, type: 'debug', debugReason: 'malformed_payload', - text: capDetails( - `permission_request: ${stringifyRedactedJson(event.data)}`, - ), + text: debugBlockText('permission_request', event.data), }, ]; } @@ -1314,7 +1309,7 @@ function normalizePermissionResolved( ...base, type: 'debug', debugReason: 'malformed_payload', - text: capDetails(`${event.type}: ${stringifyRedactedJson(event.data)}`), + text: debugBlockText(event.type, event.data), }, ]; } diff --git a/packages/sdk-typescript/src/daemon/ui/toolPreview.ts b/packages/sdk-typescript/src/daemon/ui/toolPreview.ts index 64004f89561..617c6e5f4a2 100644 --- a/packages/sdk-typescript/src/daemon/ui/toolPreview.ts +++ b/packages/sdk-typescript/src/daemon/ui/toolPreview.ts @@ -10,6 +10,7 @@ import type { DaemonTranscriptQuestionOption, } from './types.js'; import { + capDetails, getFirstString, isRecord, isSensitiveKey, @@ -338,7 +339,9 @@ function collectPreviewRows( if (isRecord(value)) continue; rows.push({ label: key, - value: isSensitiveKey(key) ? '[redacted]' : stringifyRedactedJson(value), + value: isSensitiveKey(key) + ? '[redacted]' + : capDetails(stringifyRedactedJson(value)), }); } return rows; diff --git a/packages/sdk-typescript/src/daemon/ui/utils.ts b/packages/sdk-typescript/src/daemon/ui/utils.ts index c70f2052357..37533a03dda 100644 --- a/packages/sdk-typescript/src/daemon/ui/utils.ts +++ b/packages/sdk-typescript/src/daemon/ui/utils.ts @@ -42,6 +42,17 @@ export function stringifyRedactedJson(value: unknown): string { return stringifyJson(redactSensitiveFields(value)); } +const MAX_DETAILS_LENGTH = 4096; + +/** + * Caps a rendered details string so a single unbounded payload cannot grow a + * transcript block without limit. + */ +export function capDetails(details: string): string { + if (details.length <= MAX_DETAILS_LENGTH) return details; + return `${details.slice(0, MAX_DETAILS_LENGTH)}... [truncated]`; +} + export function redactSensitiveFields(value: unknown, depth = 0): unknown { if (depth > 16) return '[truncated]'; if (Array.isArray(value)) { diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 8d324badd8f..e4a5ecb81a6 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -2328,6 +2328,24 @@ describe('daemon UI normalizer and transcript reducer', () => { }); }); + it('caps oversized generic key_value preview values', () => { + // A generic tool input whose only usable field is one large primitive + // string must not embed the full value in the preview row — the row value + // is capped like other rendered detail strings. + const preview = createDaemonToolPreview({ + someBigTextField: 'x'.repeat(100_000), + }); + expect(preview.kind).toBe('key_value'); + const row = ( + preview as { rows?: Array<{ label: string; value: string }> } + ).rows?.find((entry) => entry.label === 'someBigTextField'); + expect(row).toBeDefined(); + expect(row?.value.length).toBeLessThanOrEqual( + 4096 + '... [truncated]'.length, + ); + expect(row?.value.endsWith('... [truncated]')).toBe(true); + }); + it('recognizes common secret-key aliases before rendering previews', () => { expect( [ From 199008cd9e619e0da80b151311476b166fe82c3b Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Tue, 18 Aug 2026 01:46:28 +0800 Subject: [PATCH 04/16] fix(web-shell): slim consumeReplaySnapshot to fit the SDK browser bundle budget The consume-and-drop API landed as a getter plus a private backing field, which pushed the minified browser daemon bundle 51 bytes past the assertBrowserSafeBundle budget. Collapse it to a single mutable replaySnapshot field swapped by consumeReplaySnapshot(), preserving the external behavior while fitting the budget without raising it. --- .../src/daemon/DaemonSessionClient.ts | 25 ++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index c86d7d71224..0cac288778c 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -170,13 +170,12 @@ export class DaemonSessionClient { readonly session: DaemonSession; readonly state: DaemonSessionState; /** - * Backing store for {@link replaySnapshot}. Mutable so consumers can - * `consumeReplaySnapshot()` once the snapshot has been injected into a - * transcript store, releasing the raw wire events (which can reach tens - * of MiB for busy sessions) instead of retaining them for the lifetime - * of the session client. + * Not `readonly`: {@link consumeReplaySnapshot} swaps it for an empty + * snapshot once the provider has injected it into the transcript store, + * releasing the raw wire events (tens of MiB on busy sessions) instead of + * retaining them for the session client's lifetime. */ - private retainedReplaySnapshot: DaemonReplaySnapshot; + replaySnapshot: DaemonReplaySnapshot; readonly replaySnapshotComplete: boolean; readonly replayPartial: boolean; readonly replayError: string | undefined; @@ -231,7 +230,7 @@ export class DaemonSessionClient { this.historyHasMore = opts.historyHasMore ?? false; this.historyAnchorRecordId = opts.historyAnchorRecordId; this.replayDegraded = opts.replayDegraded ?? false; - this.retainedReplaySnapshot = opts.replaySnapshot ?? { + this.replaySnapshot = opts.replaySnapshot ?? { compactedReplay: [], liveJournal: [], }; @@ -410,14 +409,6 @@ export class DaemonSessionClient { return this.lastSeenEpoch; } - /** - * The replay snapshot captured by `load()`. Empty after - * {@link consumeReplaySnapshot} has been called. - */ - get replaySnapshot(): DaemonReplaySnapshot { - return this.retainedReplaySnapshot; - } - /** * Returns the retained replay snapshot and drops the client's reference * to it. Call once the snapshot has been injected into a transcript @@ -426,8 +417,8 @@ export class DaemonSessionClient { * otherwise pin tens of MiB per session client. */ consumeReplaySnapshot(): DaemonReplaySnapshot { - const snapshot = this.retainedReplaySnapshot; - this.retainedReplaySnapshot = { compactedReplay: [], liveJournal: [] }; + const snapshot = this.replaySnapshot; + this.replaySnapshot = { compactedReplay: [], liveJournal: [] }; return snapshot; } From a94c6ba7ae63c7ec18380ec7a46e5d76b2bc1c14 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Tue, 18 Aug 2026 02:31:59 +0800 Subject: [PATCH 05/16] fix(web-shell): bound transcript retention in bytes, not just blocks (#9303) Round-3 review fixes for the renderer OOM: - The capped diagnostic strings no longer retain their oversized parents: capped slices are detached via a UTF-8 round-trip before being kept on transcript blocks (capDetails and the 100KB text-block truncation), so a 4KB visible cap cannot pin a 100KB payload backing store. - Retention is now byte-aware: the transcript state keeps a running estimate of what blocks retain and trimming evicts oldest blocks until the estimate is back under a byte budget (128 MiB default), so the block-count window is no longer mistaken for a memory ceiling while tool blocks carry uncapped raw payloads. - The replay snapshot is released only after the injection dispatch succeeds, keeping the window consistent if normalization throws mid-injection. - The provider DEFAULT_MAX_BLOCKS is exported and guarded by a web-shell drift test instead of a duplicated literal. --- packages/sdk-typescript/scripts/build.js | 5 +- .../sdk-typescript/src/daemon/ui/index.ts | 1 + .../sdk-typescript/src/daemon/ui/store.ts | 10 ++ .../src/daemon/ui/transcript.ts | 101 ++++++++++++++++-- .../sdk-typescript/src/daemon/ui/types.ts | 8 ++ .../sdk-typescript/src/daemon/ui/utils.ts | 13 ++- .../sdk-typescript/test/unit/daemonUi.test.ts | 86 +++++++++++++++ .../client/constants/sessions.test.ts | 20 ++++ .../web-shell/client/constants/sessions.ts | 15 +-- packages/webui/src/daemon-react-sdk.ts | 7 ++ .../daemon/session/DaemonSessionProvider.tsx | 26 ++--- packages/webui/src/daemon/session/index.ts | 1 + 12 files changed, 267 insertions(+), 26 deletions(-) create mode 100644 packages/web-shell/client/constants/sessions.test.ts diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 2618d3b6ded..f72de04706f 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -96,7 +96,10 @@ const rootDir = join(__dirname, '..'); // surface (catalog version + live snapshot accessors) and the daemon // transcript-retention work (replay-snapshot release + capped debug payloads, // #9303) landing on top of the session media references bundle. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 197 * 1024; +// Bumped from 197KB to 198KB for the retention byte budget (block byte +// estimation + budget-aware trimming) and backing-store-detached string caps +// (#9303 review round 3). +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 198 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index 7e5d8ab7b45..8abb964e7bc 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -13,6 +13,7 @@ export { createDaemonToolPreview } from './toolPreview.js'; export { appendLocalUserTranscriptMessage, createDaemonTranscriptState, + estimateDaemonTranscriptBlockBytes, formatBlockTimestamp, isSubagentChildBlock, rebuildDaemonTranscriptBlockIndex, diff --git a/packages/sdk-typescript/src/daemon/ui/store.ts b/packages/sdk-typescript/src/daemon/ui/store.ts index 61a4beed9dc..894c2c085ff 100644 --- a/packages/sdk-typescript/src/daemon/ui/store.ts +++ b/packages/sdk-typescript/src/daemon/ui/store.ts @@ -13,6 +13,7 @@ import type { import { appendLocalUserTranscriptMessage, createDaemonTranscriptState, + estimateDaemonTranscriptBlockBytes, rebuildDaemonTranscriptBlockIndex, reduceDaemonTranscriptEvents, } from './transcript.js'; @@ -141,10 +142,19 @@ function createState( return { ...createDaemonTranscriptState({ maxBlocks: seed.maxBlocks, + maxRetainedBytes: seed.maxRetainedBytes, now: seed.now, }), ...seed, blocks, + // Seeded blocks (e.g. a replay snapshot handed to `reset`) must count + // toward the retention byte budget from the start. + retainedBytes: + seed.retainedBytes ?? + blocks.reduce( + (total, block) => total + estimateDaemonTranscriptBlockBytes(block), + 0, + ), blockIndexById: rebuildDaemonTranscriptBlockIndex(blocks), toolBlockByCallId: createNullIndex(seed.toolBlockByCallId), trimmedToolNotificationByCallId: createNullIndex( diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 7b3e58066c6..e5f08b5d252 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -20,9 +20,17 @@ import type { } from './types.js'; import { DAEMON_PLAN_TOOL_CALL_ID } from './types.js'; import { createDaemonToolPreview } from './toolPreview.js'; -import { isRecord } from './utils.js'; +import { detachString, isRecord } from './utils.js'; const DEFAULT_MAX_BLOCKS = 1_000; +/** + * Byte budget for retained transcript blocks. Blocks carry raw tool payloads + * (up to the daemon's per-frame cap each), so the block-count window alone + * does not bound memory; trimming evicts the oldest blocks until the running + * estimate is back under this budget. The ceiling is therefore budget + one + * worst-case block. + */ +const DEFAULT_MAX_RETAINED_BYTES = 128 * 1024 * 1024; const TRIMMED_TOOL_BLOCK_ID = '__trimmed_tool_block__'; const TRIMMED_PERMISSION_BLOCK_ID = '__trimmed_permission_block__'; const MAX_TEXT_BLOCK_LENGTH = 100_000; @@ -58,6 +66,8 @@ export function createDaemonTranscriptState( nextOrdinal: 1, now: opts.now ?? Date.now(), maxBlocks: opts.maxBlocks ?? DEFAULT_MAX_BLOCKS, + retainedBytes: 0, + maxRetainedBytes: opts.maxRetainedBytes ?? DEFAULT_MAX_RETAINED_BYTES, retainSubagentBlocks: opts.retainSubagentBlocks ?? true, }; if (opts.onTruncation) truncationCallbacks.set(state, opts.onTruncation); @@ -823,6 +833,15 @@ function upsertToolBlock( } return; } + // Measure the block as currently retained BEFORE the write path clones it + // (`getWritableBlockById` swaps in a COW clone, whose structure can estimate + // slightly differently); the delta stays exact against what is actually + // retained before and after. + const retainedIndex = + existingId !== undefined ? state.blockIndexById[existingId] : undefined; + const retainedBefore = + retainedIndex !== undefined ? state.blocks[retainedIndex] : undefined; + const bytesBefore = retainedBefore ? estimateBlockBytes(retainedBefore) : 0; const existing = getWritableBlockById(state, existingId); if (existing?.kind === 'tool') { if (event.title !== undefined) existing.title = event.title; @@ -915,6 +934,7 @@ function upsertToolBlock( if (event.subagentType && !existing.subagentType) { existing.subagentType = event.subagentType; } + state.retainedBytes += estimateBlockBytes(existing) - bytesBefore; updateCurrentToolPointer(state, event.toolCallId, event.status); return; } @@ -1384,6 +1404,7 @@ function cloneTranscriptState( ...state, now: opts.now ?? Date.now(), maxBlocks: opts.maxBlocks ?? state.maxBlocks, + maxRetainedBytes: opts.maxRetainedBytes ?? state.maxRetainedBytes, retainSubagentBlocks: opts.retainSubagentBlocks ?? state.retainSubagentBlocks, // Lazy copy-on-write for @@ -1437,9 +1458,30 @@ function cloneTranscriptState( function trimTranscriptState( state: DaemonTranscriptState, ): DaemonTranscriptState { - if (state.blocks.length <= state.maxBlocks) return state; + const overByteBudget = state.retainedBytes > state.maxRetainedBytes; + if (state.blocks.length <= state.maxBlocks && !overByteBudget) return state; truncationCallbacks.get(state)?.({ kind: 'blocks' }); - const blocks = state.blocks.slice(-state.maxBlocks); + // Count-based floor: keep at most the last `maxBlocks` blocks. + let removeCount = Math.max(0, state.blocks.length - state.maxBlocks); + let removedBytes = 0; + for (let i = 0; i < removeCount; i += 1) { + removedBytes += estimateBlockBytes(state.blocks[i]!); + } + // Byte budget: keep evicting oldest blocks until the retained estimate is + // back under the budget. The last block always survives, so the ceiling is + // budget + one worst-case block rather than strictly the budget. + let bytes = state.retainedBytes - removedBytes; + while ( + removeCount < state.blocks.length - 1 && + bytes > state.maxRetainedBytes + ) { + const blockBytes = estimateBlockBytes(state.blocks[removeCount]!); + removedBytes += blockBytes; + bytes -= blockBytes; + removeCount += 1; + } + state.retainedBytes = Math.max(0, bytes); + const blocks = state.blocks.slice(removeCount); const keptIds = new Set(blocks.map((block) => block.id)); state.blocks = blocks; state.blockIndexById = rebuildDaemonTranscriptBlockIndex(blocks); @@ -1619,6 +1661,7 @@ function appendBlock( takeBlocksOwnership(state); state.blockIndexById[block.id] = state.blocks.length; (state.blocks as DaemonTranscriptBlock[]).push(block); + state.retainedBytes += estimateBlockBytes(block); } function getWritableBlockById( @@ -1687,11 +1730,20 @@ function appendBoundedText( text: string, ): string { const existing = 'text' in block ? block.text : ''; + let next: string; if (existing.length >= MAX_TEXT_BLOCK_LENGTH) { if (text) reportTextTruncation(state, block.id, block.sourceRecordIds); - return existing; + next = existing; + } else { + next = truncateText( + state, + block.id, + block.sourceRecordIds, + existing + text, + ); } - return truncateText(state, block.id, block.sourceRecordIds, existing + text); + state.retainedBytes += (next.length - existing.length) * 2; + return next; } function truncateText( @@ -1706,7 +1758,9 @@ function truncateText( 0, MAX_TEXT_BLOCK_LENGTH - TEXT_TRUNCATED_SUFFIX.length, ); - return `${text.slice(0, keepLength)}${TEXT_TRUNCATED_SUFFIX}`; + // detach: a bare slice would keep the oversized parent string's backing + // store alive for as long as the block is retained. + return `${detachString(text.slice(0, keepLength))}${TEXT_TRUNCATED_SUFFIX}`; } function reportTextTruncation( @@ -1721,6 +1775,41 @@ function reportTextTruncation( }); } +/** + * Cheap structural size estimate of a retained value (bytes). Strings count + * as 2 bytes per UTF-16 code unit; records/arrays add a small per-entry + * overhead. Deliberately approximate: it drives the retention byte budget, + * not billing. The walk is bounded by the same depth cap as cloning. + */ +function estimateRetainedBytes(value: unknown, depth = 0): number { + if (depth > MAX_CLONE_DEPTH) return 0; + if (typeof value === 'string') return value.length * 2; + if (typeof value === 'number' || typeof value === 'boolean') return 16; + if (Array.isArray(value)) { + let total = 32; + for (const entry of value) { + total += estimateRetainedBytes(entry, depth + 1); + } + return total; + } + if (isRecord(value)) { + let total = 64; + for (const [key, entry] of Object.entries(value)) { + total += key.length * 2 + estimateRetainedBytes(entry, depth + 1); + } + return total; + } + return 0; +} + +export function estimateDaemonTranscriptBlockBytes( + block: DaemonTranscriptBlock, +): number { + return estimateRetainedBytes(block); +} + +const estimateBlockBytes = estimateDaemonTranscriptBlockBytes; + function createIndex( source?: Readonly>, ): Record { diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 12d688d5088..72bf8bd87c5 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -1055,10 +1055,18 @@ export interface DaemonTranscriptState now: number; maxBlocks: number; retainSubagentBlocks: boolean; + /** + * Running estimate (bytes) of what `blocks` retains. Blocks carry raw tool + * payloads, so a block-count cap alone is not a memory ceiling; trimming + * also evicts until the estimate is back under `maxRetainedBytes`. + */ + retainedBytes: number; + maxRetainedBytes: number; } export interface DaemonTranscriptReducerOptions { maxBlocks?: number; + maxRetainedBytes?: number; now?: number; retainSubagentBlocks?: boolean; onTruncation?: (detail: DaemonTranscriptTruncationDetail) => void; diff --git a/packages/sdk-typescript/src/daemon/ui/utils.ts b/packages/sdk-typescript/src/daemon/ui/utils.ts index 37533a03dda..6992765ab0f 100644 --- a/packages/sdk-typescript/src/daemon/ui/utils.ts +++ b/packages/sdk-typescript/src/daemon/ui/utils.ts @@ -44,13 +44,24 @@ export function stringifyRedactedJson(value: unknown): string { const MAX_DETAILS_LENGTH = 4096; +/** + * Returns a copy of `value` that does not reference the input's backing + * storage. Engines such as V8 represent slices of large strings as views + * (SlicedString) that keep the parent alive, so a capped string retained on a + * transcript block would otherwise pin the entire uncapped payload and defeat + * the cap. The UTF-8 round-trip forces an independent string. + */ +export function detachString(value: string): string { + return new TextDecoder('utf-8').decode(new TextEncoder().encode(value)); +} + /** * Caps a rendered details string so a single unbounded payload cannot grow a * transcript block without limit. */ export function capDetails(details: string): string { if (details.length <= MAX_DETAILS_LENGTH) return details; - return `${details.slice(0, MAX_DETAILS_LENGTH)}... [truncated]`; + return `${detachString(details.slice(0, MAX_DETAILS_LENGTH))}... [truncated]`; } export function redactSensitiveFields(value: unknown, depth = 0): unknown { diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index e4a5ecb81a6..91227863621 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -11,6 +11,7 @@ import { createDaemonTranscriptState, createDaemonTranscriptStore, daemonUiEventToTerminalText, + estimateDaemonTranscriptBlockBytes, getOutputText, isDaemonUiSensitiveKey, normalizeDaemonEvent, @@ -1208,6 +1209,91 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(Object.keys(state.toolBlockByCallId)).toHaveLength(4); }); + it('evicts oldest blocks to stay under the retention byte budget', () => { + // The block-count window is not a memory ceiling: blocks carry raw tool + // payloads. With a generous block window but a tight byte budget, heavy + // blocks must still be evicted oldest-first. + const large = 'x'.repeat(20_000); + let state = createDaemonTranscriptState({ + maxBlocks: 100, + maxRetainedBytes: 90_000, + now: 1, + }); + for (let index = 0; index < 4; index += 1) { + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId: `tool-${index}`, + title: `Tool ${index}`, + status: 'completed', + rawOutput: large, + }, + ], + { now: index + 2 }, + ); + } + + expect(state.blocks.length).toBeGreaterThan(0); + expect(state.blocks.length).toBeLessThan(4); + expect(state.retainedBytes).toBeLessThanOrEqual(90_000); + // Most recent blocks survive eviction. + const keptToolCallIds = state.blocks.map( + (block) => (block as { toolCallId?: string }).toolCallId, + ); + expect(keptToolCallIds).toContain('tool-3'); + expect(keptToolCallIds).not.toContain('tool-0'); + // The running estimate matches the blocks actually retained. + const expected = state.blocks.reduce( + (total, block) => total + estimateDaemonTranscriptBlockBytes(block), + 0, + ); + expect(state.retainedBytes).toBe(expected); + }); + + it('keeps the retention estimate current when a tool payload is replaced', () => { + let state = createDaemonTranscriptState({ + maxBlocks: 10, + maxRetainedBytes: 10_000_000, + now: 1, + }); + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId: 'tool-a', + title: 'Tool', + status: 'running', + rawOutput: 'small', + }, + ], + { now: 2 }, + ); + const before = state.retainedBytes; + expect(before).toBeGreaterThan(0); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId: 'tool-a', + status: 'completed', + rawOutput: 'y'.repeat(50_000), + }, + ], + { now: 3 }, + ); + + expect(state.blocks).toHaveLength(1); + expect(state.retainedBytes).toBeGreaterThan(before); + expect(state.retainedBytes).toBe( + estimateDaemonTranscriptBlockBytes(state.blocks[0]!), + ); + }); + it('keeps active assistant text open when reporting trimmed tool updates', () => { let state = createDaemonTranscriptState({ maxBlocks: 2, now: 1 }); diff --git a/packages/web-shell/client/constants/sessions.test.ts b/packages/web-shell/client/constants/sessions.test.ts new file mode 100644 index 00000000000..8471e0353e7 --- /dev/null +++ b/packages/web-shell/client/constants/sessions.test.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { DAEMON_SESSION_DEFAULT_MAX_BLOCKS } from '@qwen-code/webui/daemon-react-sdk'; +import { WEB_SHELL_MAX_TRANSCRIPT_BLOCKS } from './sessions'; + +describe('web-shell session constants', () => { + it('keeps the transcript window aligned with the provider default', () => { + // Web Shell passes its own maxBlocks to every DaemonSessionProvider it + // mounts; if the provider default moves, this copy must move with it + // (see WEB_SHELL_MAX_TRANSCRIPT_BLOCKS). + expect(WEB_SHELL_MAX_TRANSCRIPT_BLOCKS).toBe( + DAEMON_SESSION_DEFAULT_MAX_BLOCKS, + ); + }); +}); diff --git a/packages/web-shell/client/constants/sessions.ts b/packages/web-shell/client/constants/sessions.ts index 10ba5073179..0fb9188a558 100644 --- a/packages/web-shell/client/constants/sessions.ts +++ b/packages/web-shell/client/constants/sessions.ts @@ -26,11 +26,14 @@ export const WEB_SHELL_TRANSCRIPT_RELOAD_BLOCKS = 500; * (the main chat and each split pane). The daemon stays the authoritative * full-transcript source; this only caps the client's in-memory window. * - * Matches the provider's DEFAULT_MAX_BLOCKS; passed explicitly so Web Shell - * retention cannot drift if that default changes. Bounding the window keeps - * very long sessions responsive: the per-dispatch reducer cost (a full - * block-array copy) and the full-list message normalization turn a burst of - * buffered SSE events — e.g. the stream catching up when the tab returns - * from being hidden — into a long main-thread block on large transcripts. + * Intentionally equal to the provider's `DEFAULT_MAX_BLOCKS`; the equality is + * enforced by `sessions.test.ts` (importing the constant here instead of the + * provider would pull the webui barrel into every importer's module graph and + * break the enumerative `daemon-react-sdk` mocks in component tests). Bounding + * the window keeps very long sessions responsive: the per-dispatch reducer + * cost (a full block-array copy) and the full-list message normalization turn + * a burst of buffered SSE events — e.g. the stream catching up when the tab + * returns from being hidden — into a long main-thread block on large + * transcripts. */ export const WEB_SHELL_MAX_TRANSCRIPT_BLOCKS = 50_000; diff --git a/packages/webui/src/daemon-react-sdk.ts b/packages/webui/src/daemon-react-sdk.ts index b87f45d4b0d..5336eb82e8b 100644 --- a/packages/webui/src/daemon-react-sdk.ts +++ b/packages/webui/src/daemon-react-sdk.ts @@ -31,6 +31,13 @@ */ export { DaemonSessionProvider } from './daemon/index.js'; +/** + * Default transcript block-count window applied by `DaemonSessionProvider` + * when no `maxBlocks` prop is given. Exported so UI surfaces can reference + * the provider default instead of hard-coding a copy that can drift. + */ +export { DEFAULT_MAX_BLOCKS as DAEMON_SESSION_DEFAULT_MAX_BLOCKS } from './daemon/session/index.js'; + /** * Wraps children with workspace-level daemon context. * Provides access to cross-session resources: tools, skills, MCP servers, diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index dc20847a436..621261245e6 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -498,10 +498,12 @@ interface HeartbeatFailureState { // and subagent grouping survive replay. Rendering is virtualized, but message // normalization still rebuilds from retained blocks today, so this default is a // history-preservation tradeoff rather than a claim that large transcripts are -// CPU-free. The value is also a memory ceiling: blocks can carry large tool -// payloads, so an implicit 200k window let a single busy session exhaust -// renderer memory. Callers can pass a smaller maxBlocks in constrained contexts. -const DEFAULT_MAX_BLOCKS = 50_000; +// CPU-free. This is a block-COUNT ceiling; the memory ceiling is enforced +// separately by the transcript store's retention byte budget, because blocks +// can carry large raw tool payloads (an implicit 200k window let a single busy +// session exhaust renderer memory). Callers can pass a smaller maxBlocks in +// constrained contexts. +export const DEFAULT_MAX_BLOCKS = 50_000; const INITIAL_WORKSPACE_EVENT_SIGNALS: DaemonWorkspaceEventSignals = { memoryVersion: 0, @@ -1530,14 +1532,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { firstPersistedRecordId; transcriptHistoryRef.current.cursor = undefined; } - if (replayInjected) { - // The snapshot is injected into the store below and never read - // again (SSE continues from lastEventId; older history arrives - // via pagination). Drop the client's reference so busy-session - // snapshots — tens of MiB of raw wire events after adaptive - // journal growth — don't stay pinned for the whole attachment. - activeSession.consumeReplaySnapshot(); - } if (needsStoreReset && !replayInjected) { // Reset needed but no replay data (e.g. fresh session) — reset // immediately since there is no dispatch to batch with. @@ -1796,6 +1790,14 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ); } setConnection((c) => ({ ...c, catchingUp: undefined })); + // Release the raw snapshot only after the injection above + // completed: if normalization/dispatch threw, the recovery path + // reloads the session, and the still-retained snapshot keeps the + // window consistent until then. On success it is never read again + // (SSE continues from lastEventId; older history via pagination), + // so dropping it unpins busy-session snapshots that can reach + // tens of MiB after adaptive journal growth. + activeSession.consumeReplaySnapshot(); } setConnection((current) => ({ ...current, diff --git a/packages/webui/src/daemon/session/index.ts b/packages/webui/src/daemon/session/index.ts index 55194c37f5a..3360bf3a5df 100644 --- a/packages/webui/src/daemon/session/index.ts +++ b/packages/webui/src/daemon/session/index.ts @@ -6,6 +6,7 @@ export { DaemonSessionProvider, + DEFAULT_MAX_BLOCKS, useDaemonActions, useOptionalDaemonActions, useDaemonSessionOwnerGuard, From 55f9328a822d2ab6083c17a1ceceab87c319e2eb Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Mon, 17 Aug 2026 21:50:11 +0000 Subject: [PATCH 06/16] fix(review): close round-4 byte-budget accounting gaps in transcript retention (#9303) --- packages/sdk-typescript/src/daemon/index.ts | 1 + .../sdk-typescript/src/daemon/ui/store.ts | 1 + .../src/daemon/ui/transcript.ts | 34 ++- .../sdk-typescript/test/unit/daemonUi.test.ts | 222 ++++++++++++++++++ .../session/DaemonSessionProvider.test.tsx | 189 ++++++++++++++- .../daemon/session/DaemonSessionProvider.tsx | 10 + 6 files changed, 449 insertions(+), 8 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index b8ea8c6d3a1..6834317946d 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -99,6 +99,7 @@ export { daemonBlockToPlainText, daemonToolPreviewToMarkdown, daemonUiEventToTerminalText, + estimateDaemonTranscriptBlockBytes, extractContentPart, extractServerTimestamp, formatBlockTimestamp, diff --git a/packages/sdk-typescript/src/daemon/ui/store.ts b/packages/sdk-typescript/src/daemon/ui/store.ts index 894c2c085ff..c3ab3c91d4c 100644 --- a/packages/sdk-typescript/src/daemon/ui/store.ts +++ b/packages/sdk-typescript/src/daemon/ui/store.ts @@ -75,6 +75,7 @@ export function createDaemonTranscriptStore( reset(nextSeed: Partial = {}) { state = createState({ maxBlocks: nextSeed.maxBlocks ?? state.maxBlocks, + maxRetainedBytes: nextSeed.maxRetainedBytes ?? state.maxRetainedBytes, retainSubagentBlocks: nextSeed.retainSubagentBlocks ?? state.retainSubagentBlocks, ...nextSeed, diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index e5f08b5d252..05befd5064e 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -267,6 +267,9 @@ function applyDaemonTranscriptEvent( appendBlock(next, block); next.activeUserBlockId = block.id; } else { + // Measure the retained block BEFORE the write path clones it + // (same pattern as upsertToolBlock) so merged images stay counted. + const bytesBefore = activeUser ? estimateBlockBytes(activeUser) : 0; // Use getWritableBlockById to ensure COW safety when mutating block.images const block = getWritableBlockById(next, next.activeUserBlockId) as | DaemonTextTranscriptBlock @@ -278,6 +281,7 @@ function applyDaemonTranscriptEvent( ...(block.images ?? []), { data: event.data, mimeType: event.mimeType }, ]; + next.retainedBytes += estimateBlockBytes(block) - bytesBefore; } } break; @@ -1019,8 +1023,17 @@ function discardToolBlock( ): void { const blockId = state.toolBlockByCallId[toolCallId]; if (!blockId || blockId === TRIMMED_TOOL_BLOCK_ID) return; + const droppedIndex = state.blockIndexById[blockId]; + const dropped = + droppedIndex !== undefined ? state.blocks[droppedIndex] : undefined; takeBlocksOwnership(state); state.blocks = state.blocks.filter((block) => block.id !== blockId); + if (dropped) { + state.retainedBytes = Math.max( + 0, + state.retainedBytes - estimateBlockBytes(dropped), + ); + } state.blockIndexById = rebuildDaemonTranscriptBlockIndex(state.blocks); delete state.toolBlockByCallId[toolCallId]; delete state.toolProgress[toolCallId]; @@ -1460,26 +1473,27 @@ function trimTranscriptState( ): DaemonTranscriptState { const overByteBudget = state.retainedBytes > state.maxRetainedBytes; if (state.blocks.length <= state.maxBlocks && !overByteBudget) return state; - truncationCallbacks.get(state)?.({ kind: 'blocks' }); // Count-based floor: keep at most the last `maxBlocks` blocks. let removeCount = Math.max(0, state.blocks.length - state.maxBlocks); - let removedBytes = 0; + let bytes = state.retainedBytes; for (let i = 0; i < removeCount; i += 1) { - removedBytes += estimateBlockBytes(state.blocks[i]!); + bytes -= estimateBlockBytes(state.blocks[i]!); } // Byte budget: keep evicting oldest blocks until the retained estimate is // back under the budget. The last block always survives, so the ceiling is // budget + one worst-case block rather than strictly the budget. - let bytes = state.retainedBytes - removedBytes; while ( removeCount < state.blocks.length - 1 && bytes > state.maxRetainedBytes ) { - const blockBytes = estimateBlockBytes(state.blocks[removeCount]!); - removedBytes += blockBytes; - bytes -= blockBytes; + bytes -= estimateBlockBytes(state.blocks[removeCount]!); removeCount += 1; } + // Nothing evictable (e.g. one oversized block): skip the callback and + // rebuild. Firing `kind: 'blocks'` with zero removals records a false + // truncation and churns snapshot identity on every dispatch. + if (removeCount === 0) return state; + truncationCallbacks.get(state)?.({ kind: 'blocks' }); state.retainedBytes = Math.max(0, bytes); const blocks = state.blocks.slice(removeCount); const keptIds = new Set(blocks.map((block) => block.id)); @@ -1618,7 +1632,13 @@ function truncateTranscriptBeforeBlock( blockIndex: number, ): void { takeBlocksOwnership(state); + let droppedBytes = 0; + for (let index = blockIndex; index < state.blocks.length; index += 1) { + const block = state.blocks[index]; + if (block) droppedBytes += estimateBlockBytes(block); + } state.blocks = state.blocks.slice(0, blockIndex); + state.retainedBytes = Math.max(0, state.retainedBytes - droppedBytes); ownedBlocks.set(state, state.blocks); rebuildTranscriptIndexes(state); } diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 91227863621..d501422643a 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -1294,6 +1294,228 @@ describe('daemon UI normalizer and transcript reducer', () => { ); }); + it('applies the default retention budget when none is configured', () => { + // The eviction comparison degrades to "never fires" if the creation-time + // default is dropped, so pin the exact value for callers that rely on it. + expect(createDaemonTranscriptState().maxRetainedBytes).toBe( + 128 * 1024 * 1024, + ); + expect(createDaemonTranscriptStore().getSnapshot().maxRetainedBytes).toBe( + 128 * 1024 * 1024, + ); + }); + + it('keeps a configured retention budget across store reset', () => { + // reset() carries maxBlocks and retainSubagentBlocks forward from the + // current state; maxRetainedBytes must behave the same or a custom + // budget silently reverts to the default on every replay/session switch. + const store = createDaemonTranscriptStore({ maxRetainedBytes: 5_000_000 }); + expect(store.getSnapshot().maxRetainedBytes).toBe(5_000_000); + store.reset(); + expect(store.getSnapshot().maxRetainedBytes).toBe(5_000_000); + }); + + it('counts every image merged into a user block against the retention budget', () => { + const data = 'I'.repeat(100_000); + let state = createDaemonTranscriptState({ + maxBlocks: 10, + maxRetainedBytes: 10_000_000, + now: 1, + }); + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'user.image.delta', + data, + mimeType: 'image/png', + sourceRecordIds: ['record-1'], + }, + { + type: 'user.image.delta', + data, + mimeType: 'image/png', + sourceRecordIds: ['record-1'], + }, + ], + { now: 2 }, + ); + + expect(state.blocks).toHaveLength(1); + const block = state.blocks[0]!; + expect((block as { images?: unknown[] }).images).toHaveLength(2); + expect(state.retainedBytes).toBe(estimateDaemonTranscriptBlockBytes(block)); + }); + + it('releases the retention budget when a rewind drops blocks', () => { + const large = 'x'.repeat(100_000); + let state = createDaemonTranscriptState({ + maxBlocks: 100, + maxRetainedBytes: 1_000_000, + now: 1, + }); + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'user.text.delta', text: 'turn zero' }], + { now: 2 }, + ); + for (let index = 0; index < 3; index += 1) { + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId: `tool-${index}`, + title: `Tool ${index}`, + status: 'completed', + rawOutput: large, + }, + ], + { now: index + 3 }, + ); + } + expect(state.blocks).toHaveLength(4); + expect(state.retainedBytes).toBeGreaterThan(0); + + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'session.rewound', promptId: 'prompt-1', targetTurnIndex: 0 }], + { now: 9 }, + ); + expect(state.blocks).toHaveLength(0); + expect(state.retainedBytes).toBe(0); + + // Ghost bytes from the dropped blocks must not collapse later growth: + // with an inflated counter every dispatch looks over budget and evicts + // the freshly appended blocks down to the last survivor. + for (let index = 0; index < 3; index += 1) { + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId: `post-${index}`, + title: `Post ${index}`, + status: 'completed', + rawOutput: large, + }, + ], + { now: index + 10 }, + ); + } + expect( + state.blocks.map( + (block) => (block as { toolCallId?: string }).toolCallId, + ), + ).toEqual(['post-0', 'post-1', 'post-2']); + const expected = state.blocks.reduce( + (total, block) => total + estimateDaemonTranscriptBlockBytes(block), + 0, + ); + expect(state.retainedBytes).toBe(expected); + }); + + it('releases the retention budget when a subagent tool block is discarded', () => { + let state = createDaemonTranscriptState({ + maxBlocks: 10, + maxRetainedBytes: 10_000_000, + retainSubagentBlocks: false, + now: 1, + }); + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId: 'sub-1', + title: 'Subagent', + status: 'running', + rawOutput: 'y'.repeat(50_000), + }, + ], + { now: 2 }, + ); + expect(state.blocks).toHaveLength(1); + expect(state.retainedBytes).toBeGreaterThan(0); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId: 'sub-1', + parentToolCallId: 'parent-1', + status: 'completed', + }, + ], + { now: 3 }, + ); + expect(state.blocks).toHaveLength(0); + expect(state.retainedBytes).toBe(0); + }); + + it('accounts streamed assistant text against the retention byte budget', () => { + let state = createDaemonTranscriptState({ + maxBlocks: 100, + maxRetainedBytes: 250_000, + now: 1, + }); + const chunk = 'A'.repeat(25_000); + for (let blockIndex = 0; blockIndex < 2; blockIndex += 1) { + for (let chunkIndex = 0; chunkIndex < 4; chunkIndex += 1) { + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'assistant.text.delta', text: chunk }], + { now: blockIndex * 10 + chunkIndex + 2 }, + ); + } + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'assistant.done', reason: 'end_turn' }], + { now: blockIndex * 10 + 8 }, + ); + } + + // Each text block caps at 100k chars (~200KB estimated), so the 250KB + // budget cannot hold both streamed blocks and the older one is evicted. + expect(state.blocks).toHaveLength(1); + expect(state.retainedBytes).toBeLessThanOrEqual(250_000); + const expected = state.blocks.reduce( + (total, block) => total + estimateDaemonTranscriptBlockBytes(block), + 0, + ); + expect(state.retainedBytes).toBe(expected); + }); + + it('does not signal truncation when the byte budget cannot evict anything', () => { + const onTruncation = vi.fn(); + let state = createDaemonTranscriptState({ + maxBlocks: 10, + maxRetainedBytes: 100, + now: 1, + onTruncation, + }); + // A single block whose estimate exceeds the budget can never be evicted + // (the last block always survives). + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'status', text: 'x'.repeat(100) }], + { now: 2 }, + ); + expect(state.blocks).toHaveLength(1); + onTruncation.mockClear(); + const blocksBefore = state.blocks; + + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'session.metadata.changed', sessionId: 'session-1' }], + { now: 3 }, + ); + + expect(onTruncation).not.toHaveBeenCalled(); + expect(state.blocks).toBe(blocksBefore); + }); + it('keeps active assistant text open when reporting trimmed tool updates', () => { let state = createDaemonTranscriptState({ maxBlocks: 2, now: 1 }); diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 57e459dfd18..0cd4cc3c5a5 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -18,7 +18,10 @@ import type { DaemonUiSessionActions, PromptResult, } from '@qwen-code/sdk/daemon'; -import { DaemonHttpError } from '@qwen-code/sdk/daemon'; +import { + DaemonHttpError, + estimateDaemonTranscriptBlockBytes, +} from '@qwen-code/sdk/daemon'; import { DaemonSessionProvider, useDaemonActions, @@ -11865,6 +11868,190 @@ describe('DaemonSessionProvider', () => { expect(history?.hasMore).toBe(false); }); + it('counts prepended history pages against the retention byte estimate', async () => { + // Pagination prepends blocks carrying raw tool payloads; the running + // retained-bytes estimate must grow with them or later live growth trims + // against an undercounted counter and exceeds the retention budget. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const replayEvent = ( + id: number, + text: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text }, + _meta: { 'qwen.session.recordId': recordId }, + }, + }, + }); + const toolEvent = ( + id: number, + toolCallId: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId, + title: `Tool ${toolCallId}`, + status: 'completed', + rawInput: { payload: 'P'.repeat(100_000) }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, + }, + }, + }); + const session = createMockSession({ + sessionId: 'session-history-bytes', + historyHasMore: true, + replaySnapshot: { + compactedReplay: [replayEvent(2, 'recent prompt', 'record-2')], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: [ + toolEvent(0, 'tool-older-0', 'record-0'), + toolEvent(1, 'tool-older-1', 'record-1'), + ], + hasMore: false, + }); + let history: ReturnType | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + let retainedBytes = 0; + + function Harness() { + history = useDaemonTranscriptHistory(); + blocks = useDaemonTranscriptBlocks(); + retainedBytes = useDaemonTranscriptState().retainedBytes; + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + }); + const retainedBefore = retainedBytes; + expect(retainedBefore).toBeGreaterThan(0); + + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + + expect(blocks).toHaveLength(3); + const expected = blocks.reduce( + (total, block) => total + estimateDaemonTranscriptBlockBytes(block), + 0, + ); + expect(retainedBytes).toBeGreaterThan(retainedBefore); + expect(retainedBytes).toBe(expected); + }); + + it('keeps a media-heavy history page intact while materializing it', async () => { + // The pagination scratch store is deliberately trim-free (unlimited block + // count). The retention byte budget must not evict the page's oldest + // records mid-build there: the pagination anchor is exclusive of the + // oldest fetched record, so an evicted record is never re-fetched and + // leaves a permanent silent gap in the timeline. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const replayEvent = ( + id: number, + text: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text }, + _meta: { 'qwen.session.recordId': recordId }, + }, + }, + }); + // ~10 MB estimated per block; 14 of them cross the default 128 MiB + // retention budget so an active budget would evict during materialization. + const heavyToolEvent = (id: number, index: number): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: `tool-${index}`, + title: `Tool ${index}`, + status: 'completed', + rawInput: { payload: `page-${index}-` + 'A'.repeat(5_000_000) }, + _meta: { + 'qwen.session.recordId': `record-${index}`, + qwenTranscript: { sourceRecordIds: [`record-${index}`] }, + }, + }, + }, + }); + const session = createMockSession({ + sessionId: 'session-history-heavy-page', + historyHasMore: true, + replaySnapshot: { + compactedReplay: [replayEvent(20, 'recent prompt', 'record-recent')], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: Array.from({ length: 14 }, (_, index) => + heavyToolEvent(index + 1, index), + ), + hasMore: false, + }); + let history: ReturnType | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + history = useDaemonTranscriptHistory(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + }); + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + + const toolCallIds = blocks + .filter((block) => block.kind === 'tool') + .map((block) => (block as { toolCallId?: string }).toolCallId); + expect(toolCallIds).toHaveLength(14); + expect(toolCallIds).toContain('tool-0'); + }); + it('drops fetched transcript events whose records are already displayed', async () => { // The pagination anchor can sit inside the retained window (e.g. the // daemon's transcript backfill for a live-journal overflow returns the diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 621261245e6..f390f9036e7 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -21,6 +21,7 @@ import { DaemonHttpError, DaemonSessionClient, createDaemonTranscriptStore, + estimateDaemonTranscriptBlockBytes, extractServerTimestamp, matchTurnEvent, normalizeDaemonEvent, @@ -285,6 +286,10 @@ function materializeTranscriptHistory( ); const historyStore = createDaemonTranscriptStore({ maxBlocks: Number.MAX_SAFE_INTEGER, + // Trim-free by intent: a media-heavy page would otherwise cross the + // default byte budget mid-build and evict the page's oldest records, + // which the exclusive pagination anchor can never re-fetch. + maxRetainedBytes: Number.POSITIVE_INFINITY, nextOrdinal: current.nextOrdinal, retainSubagentBlocks: current.retainSubagentBlocks, }); @@ -305,9 +310,14 @@ function applyTranscriptHistory( current: DaemonTranscriptState, history: TranscriptHistoryMaterialization, ): DaemonTranscriptState { + let prependedBytes = 0; + for (const block of history.blocks) { + prependedBytes += estimateDaemonTranscriptBlockBytes(block); + } return { ...current, blocks: [...history.blocks, ...current.blocks], + retainedBytes: current.retainedBytes + prependedBytes, nextOrdinal: history.nextOrdinal, toolBlockByCallId: { ...history.toolBlockByCallId, From 94c8668184a1fc362ba2e17b63a91c5e7d73370f Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Tue, 18 Aug 2026 00:58:11 +0000 Subject: [PATCH 07/16] fix(webui): reject history pages that overflow the transcript byte budget (#9303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pagination admission only checked the block cap, so a page could merge over the retention byte budget: idle sessions stayed over budget, and on live sessions the next trim evicted the freshly prepended oldest records, which the exclusive pagination anchor can never re-fetch — a permanent silent gap. Admit pages only when the merged estimate fits the budget, mirroring the atomic block-cap rejection. --- .../session/DaemonSessionProvider.test.tsx | 38 ++++++++++++------- .../daemon/session/DaemonSessionProvider.tsx | 19 +++++++--- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 0cd4cc3c5a5..4f5ca8aea58 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -11964,12 +11964,14 @@ describe('DaemonSessionProvider', () => { expect(retainedBytes).toBe(expected); }); - it('keeps a media-heavy history page intact while materializing it', async () => { - // The pagination scratch store is deliberately trim-free (unlimited block - // count). The retention byte budget must not evict the page's oldest - // records mid-build there: the pagination anchor is exclusive of the - // oldest fetched record, so an evicted record is never re-fetched and - // leaves a permanent silent gap in the timeline. + it('rejects a history page that would overflow the retention byte budget', async () => { + // Pagination admission must be byte-budget-aware, not just block-count + // aware: merging a page that pushes the retained estimate over the + // budget leaves it over budget while the session is idle, and the next + // live trim evicts the freshly prepended oldest records, which the + // exclusive pagination anchor can never re-fetch — a permanent silent + // gap. The whole page is rejected atomically instead, mirroring the + // block-cap rejection. sdkMocks.capabilities.mockResolvedValue({ workspaceCwd: '/mock-workspace', features: ['session_transcript_pagination'], @@ -11991,7 +11993,7 @@ describe('DaemonSessionProvider', () => { }, }); // ~10 MB estimated per block; 14 of them cross the default 128 MiB - // retention budget so an active budget would evict during materialization. + // retention budget, so admitting the page would overflow it. const heavyToolEvent = (id: number, index: number): DaemonEvent => ({ id, v: 1, @@ -12025,14 +12027,16 @@ describe('DaemonSessionProvider', () => { events: Array.from({ length: 14 }, (_, index) => heavyToolEvent(index + 1, index), ), - hasMore: false, + hasMore: true, }); let history: ReturnType | undefined; let blocks: readonly DaemonTranscriptBlock[] = []; + let retainedBytes = 0; function Harness() { history = useDaemonTranscriptHistory(); blocks = useDaemonTranscriptBlocks(); + retainedBytes = useDaemonTranscriptState().retainedBytes; return null; } @@ -12040,16 +12044,24 @@ describe('DaemonSessionProvider', () => { autoConnect: true, historyPageSize: 25, }); + const retainedBefore = retainedBytes; + expect(retainedBefore).toBeGreaterThan(0); await act(async () => { await history?.loadMore(); await flushPromises(); }); - const toolCallIds = blocks - .filter((block) => block.kind === 'tool') - .map((block) => (block as { toolCallId?: string }).toolCallId); - expect(toolCallIds).toHaveLength(14); - expect(toolCallIds).toContain('tool-0'); + // Atomic rejection: nothing from the page merges (a partial merge would + // mean the scratch store trimmed mid-build and leaked a headless page), + // the byte counter is untouched, and the capacity latch stops paging. + expect(blocks.filter((block) => block.kind === 'tool')).toHaveLength(0); + expect(blocks).toHaveLength(1); + expect(retainedBytes).toBe(retainedBefore); + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: true, + }); }); it('drops fetched transcript events whose records are already displayed', async () => { diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index f390f9036e7..40dc4f6b779 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -156,6 +156,7 @@ interface LiveJournalRepairEpisode { interface TranscriptHistoryMaterialization { blocks: readonly DaemonTranscriptBlock[]; nextOrdinal: number; + retainedBytes: number; toolBlockByCallId: Record; permissionBlockByRequestId: Record; } @@ -298,9 +299,21 @@ function materializeTranscriptHistory( if (history.blocks.length + current.blocks.length > maxBlocks) { return undefined; } + let retainedBytes = 0; + for (const block of history.blocks) { + retainedBytes += estimateDaemonTranscriptBlockBytes(block); + } + // Byte-budget admission: an over-budget merge stays untrimmed while the + // session is idle, and the next live trim evicts the freshly prepended + // oldest records, which the exclusive pagination anchor can never + // re-fetch — a permanent silent gap. Reject atomically like the block cap. + if (current.retainedBytes + retainedBytes > current.maxRetainedBytes) { + return undefined; + } return { blocks: history.blocks, nextOrdinal: history.nextOrdinal, + retainedBytes, toolBlockByCallId: history.toolBlockByCallId, permissionBlockByRequestId: history.permissionBlockByRequestId, }; @@ -310,14 +323,10 @@ function applyTranscriptHistory( current: DaemonTranscriptState, history: TranscriptHistoryMaterialization, ): DaemonTranscriptState { - let prependedBytes = 0; - for (const block of history.blocks) { - prependedBytes += estimateDaemonTranscriptBlockBytes(block); - } return { ...current, blocks: [...history.blocks, ...current.blocks], - retainedBytes: current.retainedBytes + prependedBytes, + retainedBytes: current.retainedBytes + history.retainedBytes, nextOrdinal: history.nextOrdinal, toolBlockByCallId: { ...history.toolBlockByCallId, From 0e640e76dfff7fd55d1da6dc63d0b38d8f2c9430 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Tue, 18 Aug 2026 05:31:10 +0000 Subject: [PATCH 08/16] fix(review): reconcile transcript eviction with the pagination anchor (#9303) --- .../sdk-typescript/src/daemon/ui/store.ts | 15 +- .../src/daemon/ui/transcript.ts | 11 +- .../sdk-typescript/src/daemon/ui/types.ts | 7 + .../sdk-typescript/test/unit/daemonUi.test.ts | 35 ++ .../session/DaemonSessionProvider.test.tsx | 382 +++++++++++++++++- .../daemon/session/DaemonSessionProvider.tsx | 87 +++- 6 files changed, 514 insertions(+), 23 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/store.ts b/packages/sdk-typescript/src/daemon/ui/store.ts index c3ab3c91d4c..ad3aa6675fa 100644 --- a/packages/sdk-typescript/src/daemon/ui/store.ts +++ b/packages/sdk-typescript/src/daemon/ui/store.ts @@ -6,6 +6,7 @@ import type { DaemonTextDeltaMeta, + DaemonTranscriptReducerOptions, DaemonTranscriptState, DaemonTranscriptStore, DaemonUiEvent, @@ -19,9 +20,16 @@ import { } from './transcript.js'; export function createDaemonTranscriptStore( - seed: Partial = {}, + seed: Partial & + Pick = {}, ): DaemonTranscriptStore { - let state = createState(seed); + // Held in the closure (not on the state object) so `reset()` keeps the + // listener registered across wholesale state replacements. + const { onTruncation, ...stateSeed } = seed; + const reducerOptions: DaemonTranscriptReducerOptions = onTruncation + ? { onTruncation } + : {}; + let state = createState(stateSeed); const listeners = new Set<() => void>(); let notifyScheduled = false; @@ -56,7 +64,7 @@ export function createDaemonTranscriptStore( dispatch(event: DaemonUiEvent | DaemonUiEvent[]) { const events = Array.isArray(event) ? event : [event]; if (events.length === 0) return; - state = reduceDaemonTranscriptEvents(state, events); + state = reduceDaemonTranscriptEvents(state, events, reducerOptions); scheduleNotify(); }, appendLocalUserMessage( @@ -69,6 +77,7 @@ export function createDaemonTranscriptStore( images, meta, files, + ...reducerOptions, }); scheduleNotify(); }, diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 05befd5064e..b73ed29f7a3 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -1493,7 +1493,6 @@ function trimTranscriptState( // rebuild. Firing `kind: 'blocks'` with zero removals records a false // truncation and churns snapshot identity on every dispatch. if (removeCount === 0) return state; - truncationCallbacks.get(state)?.({ kind: 'blocks' }); state.retainedBytes = Math.max(0, bytes); const blocks = state.blocks.slice(removeCount); const keptIds = new Set(blocks.map((block) => block.id)); @@ -1563,6 +1562,16 @@ function trimTranscriptState( delete state.activeThoughtBlockByParent[parentId]; } } + // Fired after the mutation completes so listeners observe the post-trim + // window (e.g. re-anchoring an exclusive pagination anchor to the oldest + // retained record — the evicted anchor can never be re-fetched). + const oldestRetainedBlock = state.blocks.find( + (block) => (block.sourceRecordIds?.length ?? 0) > 0, + ); + truncationCallbacks.get(state)?.({ + kind: 'blocks', + oldestRetainedRecordId: oldestRetainedBlock?.sourceRecordIds?.[0], + }); return state; } diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 72bf8bd87c5..adaeb6172de 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -1076,6 +1076,13 @@ export interface DaemonTranscriptTruncationDetail { kind: 'blocks' | 'text'; blockId?: string; sourceRecordIds?: readonly string[]; + /** + * Set for `kind: 'blocks'`: the oldest recordId still retained after the + * eviction, from the oldest retained block that carries one. Undefined when + * no retained block carries a recordId. Lets consumers reconcile exclusive + * pagination anchors with retention trimming. + */ + oldestRetainedRecordId?: string; } export interface DaemonTranscriptStore { diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index d501422643a..320bf95d31a 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -1315,6 +1315,41 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(store.getSnapshot().maxRetainedBytes).toBe(5_000_000); }); + it('keeps a seeded truncation listener across dispatches and resets', () => { + // Store consumers (e.g. the session provider reconciling its pagination + // anchor with eviction) register onTruncation through the seed; the + // listener must fire on evictions and survive reset(), which replaces + // the state wholesale. + const onTruncation = vi.fn(); + const store = createDaemonTranscriptStore({ maxBlocks: 2, onTruncation }); + const toolEvent = (index: number) => ({ + type: 'tool.update' as const, + toolCallId: `tool-${index}`, + title: `Tool ${index}`, + status: 'completed' as const, + sourceRecordIds: [`record-${index}`], + }); + store.dispatch([toolEvent(0), toolEvent(1)]); + expect(onTruncation).not.toHaveBeenCalled(); + store.dispatch(toolEvent(2)); + expect(onTruncation).toHaveBeenCalledTimes(1); + expect(onTruncation).toHaveBeenCalledWith({ + kind: 'blocks', + oldestRetainedRecordId: 'record-1', + }); + + onTruncation.mockClear(); + store.reset({ maxBlocks: 2 }); + store.dispatch([toolEvent(3), toolEvent(4)]); + expect(onTruncation).not.toHaveBeenCalled(); + store.dispatch(toolEvent(5)); + expect(onTruncation).toHaveBeenCalledTimes(1); + expect(onTruncation).toHaveBeenCalledWith({ + kind: 'blocks', + oldestRetainedRecordId: 'record-4', + }); + }); + it('counts every image merged into a user block against the retention budget', () => { const data = 'I'.repeat(100_000); let state = createDaemonTranscriptState({ diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 4f5ca8aea58..c7989fae2ba 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -12586,7 +12586,7 @@ describe('DaemonSessionProvider', () => { expect(notices.at(-1)).toBeUndefined(); }); - it('trims an oversized initial replay to the block cap and stops older pagination', async () => { + it('trims an oversized initial replay to the block cap and re-anchors older pagination', async () => { sdkMocks.capabilities.mockResolvedValue({ workspaceCwd: '/mock-workspace', features: ['session_transcript_pagination'], @@ -12604,7 +12604,10 @@ describe('DaemonSessionProvider', () => { update: { sessionUpdate: kind, content: { type: 'text', text }, - _meta: { 'qwen.session.recordId': recordId }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, }, }, }); @@ -12620,6 +12623,12 @@ describe('DaemonSessionProvider', () => { }, }); sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: [], + hasMore: false, + }); let history: ReturnType | undefined; let blocks: readonly DaemonTranscriptBlock[] = []; @@ -12637,16 +12646,381 @@ describe('DaemonSessionProvider', () => { // The rebuild keeps the most recent blocks within the cap instead of // ratcheting the cap up to the replay size (which retained unbounded - // history and exhausted renderer memory on busy sessions). + // history and exhausted renderer memory on busy sessions). The trim is + // surfaced via capacityReached, and the anchor re-bases to the oldest + // RETAINED record: the pre-trim anchor (record-1) was evicted, and an + // exclusive anchor pointing at it could never re-fetch the trimmed + // stretch. expect(blocks).toMatchObject([ { kind: 'assistant', text: 'recent answer' }, ]); expect(history).toMatchObject({ - hasMore: false, + hasMore: true, loading: false, capacityReached: true, }); expect(sdkMocks.getSessionTranscriptPage).not.toHaveBeenCalled(); + + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledWith( + session.sessionId, + { + beforeRecordId: 'record-2', + limit: 25, + clientId: session.clientId, + }, + ); + }); + + it('surfaces capacity and re-anchors pagination when the rebuild count-trims a live session', async () => { + // Count corner: live (non-restored) sessions report historyHasMore=false, + // so the capacity latch must not be gated on it — the trimmed stretch is + // persisted daemon-side and fetchable through pagination. Pre-fix the + // trim stayed silent (no indicator, no load-older affordance) and the + // anchor pointed inside the trimmed span. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const event = ( + id: number, + kind: 'user_message_chunk' | 'agent_message_chunk', + text: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: kind, + content: { type: 'text', text }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, + }, + }, + }); + const session = createMockSession({ + sessionId: 'session-rebuild-count-trim', + // historyHasMore stays at its false default: live session shape. + replaySnapshot: { + compactedReplay: [ + event(1, 'user_message_chunk', 'turn one prompt', 'record-1'), + event(2, 'agent_message_chunk', 'turn one answer', 'record-2'), + event(3, 'user_message_chunk', 'turn two prompt', 'record-3'), + event(4, 'agent_message_chunk', 'turn two answer', 'record-4'), + ], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: [event(0, 'user_message_chunk', 'older prompt', 'record-0')], + hasMore: false, + }); + let history: ReturnType | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + history = useDaemonTranscriptHistory(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + maxBlocks: 2, + }); + + expect(blocks).toMatchObject([ + { kind: 'user', text: 'turn two prompt' }, + { kind: 'assistant', text: 'turn two answer' }, + ]); + expect(history).toMatchObject({ + hasMore: true, + loading: false, + capacityReached: true, + }); + + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + // Anchored at the oldest RETAINED record, not the evicted pre-trim one + // (record-1): pagination is exclusive of the anchor. The page does not + // fit the saturated window, so the atomic rejection re-latches. + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledWith( + session.sessionId, + { + beforeRecordId: 'record-3', + limit: 25, + clientId: session.clientId, + }, + ); + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: true, + }); + }); + + it('surfaces capacity and re-anchors when the rebuild trims on the byte budget', async () => { + // Byte corner: the rebuild store runs under the default 128 MiB budget. + // Two heavy replay blocks cross it INSIDE the block cap, so a + // count-only saturation check never fires; the eviction is observable + // only through the truncation listener. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + // ~90 MB estimated per block; the second crosses the 128 MiB budget. + const heavyToolEvent = (id: number, recordId: string): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: recordId, + title: `Heavy ${recordId}`, + status: 'completed', + rawInput: { payload: 'A'.repeat(45_000_000) }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, + }, + }, + }); + const session = createMockSession({ + sessionId: 'session-rebuild-byte-trim', + replaySnapshot: { + compactedReplay: [ + heavyToolEvent(1, 'record-heavy-0'), + heavyToolEvent(2, 'record-heavy-1'), + { + id: 3, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'recent prompt' }, + _meta: { 'qwen.session.recordId': 'record-recent' }, + }, + }, + }, + ], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: [], + hasMore: false, + }); + let history: ReturnType | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + history = useDaemonTranscriptHistory(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + }); + + // The oldest heavy block was evicted mid-rebuild while the block count + // stayed far below the cap... + expect(blocks.filter((block) => block.kind === 'tool')).toHaveLength(1); + expect(blocks).toMatchObject([ + { kind: 'tool' }, + { kind: 'user', text: 'recent prompt' }, + ]); + // ...and the trim surfaced with the anchor re-based to the oldest + // retained record. + expect(history).toMatchObject({ + hasMore: true, + loading: false, + capacityReached: true, + }); + + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledWith( + session.sessionId, + { + beforeRecordId: 'record-heavy-1', + limit: 25, + clientId: session.clientId, + }, + ); + }); + + it('re-anchors pagination and releases the capacity latch when live growth evicts paginated-in blocks', async () => { + // An admitted page prepends its blocks; live growth then trims + // oldest-first and evicts the page — including the exclusive anchor. + // The provider must re-anchor to the oldest retained record and re-open + // the load-older affordance once eviction frees capacity, or the stretch + // between the anchor and the window head is lost silently. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const replayEvent = ( + id: number, + kind: 'user_message_chunk' | 'agent_message_chunk', + text: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: kind, + content: { type: 'text', text }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, + }, + }, + }); + const liveGate = createDeferred(); + const session = createMockSession({ + sessionId: 'session-live-trim-anchor', + historyHasMore: true, + replaySnapshot: { + compactedReplay: [ + replayEvent(1, 'user_message_chunk', 'replay prompt', 'record-1'), + replayEvent(2, 'agent_message_chunk', 'replay answer', 'record-2'), + ], + liveJournal: [], + }, + events: async function* liveEventsAfterGate( + opts: { signal?: AbortSignal } = {}, + ) { + await Promise.race([ + liveGate.promise, + new Promise((resolve) => { + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }), + ]); + if (opts.signal?.aborted) return; + yield replayEvent(3, 'user_message_chunk', 'live one', 'record-live-1'); + yield replayEvent( + 4, + 'agent_message_chunk', + 'live two', + 'record-live-2', + ); + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + yield* []; + }, + }); + sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage + .mockResolvedValueOnce({ + v: 1, + sessionId: session.sessionId, + events: [ + replayEvent(10, 'user_message_chunk', 'older prompt', 'record-a'), + replayEvent(11, 'agent_message_chunk', 'older answer', 'record-b'), + ], + hasMore: true, + }) + .mockResolvedValueOnce({ + v: 1, + sessionId: session.sessionId, + events: [], + hasMore: false, + }); + let history: ReturnType | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + history = useDaemonTranscriptHistory(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + maxBlocks: 4, + }); + + // Merge the older page: the window is now exactly full (4 of 4 + // blocks), latching the capacity indicator. + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + expect(blocks.map((block) => block.sourceRecordIds?.[0])).toEqual([ + 'record-a', + 'record-b', + 'record-1', + 'record-2', + ]); + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: true, + }); + + // Live growth evicts the freshly prepended page blocks (oldest-first), + // including the anchor record... + await act(async () => { + liveGate.resolve(); + await flushTranscriptDispatch(); + }); + // ...which releases the latch and re-anchors to the oldest retained + // record. + expect(history).toMatchObject({ + hasMore: true, + loading: false, + capacityReached: false, + }); + + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenNthCalledWith( + 2, + session.sessionId, + { + beforeRecordId: 'record-1', + limit: 25, + clientId: session.clientId, + }, + ); }); it('releases the replay snapshot after injection and never raises the block cap', async () => { diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 40dc4f6b779..cabcc0ef077 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -590,14 +590,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { activeWorkspaceCwdRef.current = resolvedWorkspaceCwd; } - const store = useMemo( - () => - createDaemonTranscriptStore({ - maxBlocks, - retainSubagentBlocks: subagentTranscriptMode === 'full', - }), - [maxBlocks, subagentTranscriptMode], - ); const sessionRef = useRef(undefined); const sessionConfigGenerationRef = useRef( new WeakMap(), @@ -622,6 +614,41 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { capacityReached: false, paginationError: false, }); + const store = useMemo( + () => + createDaemonTranscriptStore({ + maxBlocks, + retainSubagentBlocks: subagentTranscriptMode === 'full', + onTruncation: (detail) => { + if (detail.kind !== 'blocks') return; + const history = transcriptHistoryRef.current; + const activeSession = sessionRef.current; + if (!activeSession || history.sessionId !== activeSession.sessionId) { + return; + } + // Trimming evicts oldest-first, so it can remove the very record + // the exclusive `beforeRecordId` anchor points at; the daemon + // never returns the anchor itself, so the evicted stretch would + // become unreachable. Re-anchor to the oldest retained record. + if (detail.oldestRetainedRecordId !== undefined) { + history.beforeRecordId = detail.oldestRetainedRecordId; + } + if (history.capacityReached) { + // Eviction freed retention capacity, so the page rejected at the + // latch may fit now — re-open the load-older affordance. + history.hasMore = true; + history.capacityReached = false; + setTranscriptHistoryState({ + hasMore: true, + loading: false, + capacityReached: false, + paginationError: history.paginationError, + }); + } + }, + }), + [maxBlocks, subagentTranscriptMode], + ); const eventStreamRef = useRef< | { sessionId: string; @@ -1679,6 +1706,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { (group) => group.transcript, ); let replayExceededCapacity = false; + let replayTrimmedAnchor: string | undefined; const rebuildReplay = repairingEpisode !== undefined || replayTarget !== undefined || @@ -1695,6 +1723,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { repairingEpisode && markerStillVisible ? repairingEpisode.checkpoint.maxBlocks : maxBlocks; + let replayTrimmed = false; const replayStore = createDaemonTranscriptStore( repairingEpisode && markerStillVisible ? { @@ -1705,6 +1734,12 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { maxBlocks: replayMaxBlocks, retainSubagentBlocks: subagentTranscriptModeRef.current === 'full', + // The count cap and the default byte budget can both + // evict mid-rebuild; observe either so the pagination + // anchor and capacity indicator reconcile below. + onTruncation: (detail) => { + if (detail.kind === 'blocks') replayTrimmed = true; + }, }, ); let nextCheckpoint: DaemonTranscriptState | undefined; @@ -1721,12 +1756,20 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { } } const replayState = replayStore.getSnapshot(); - // A saturated rebuild means older blocks were trimmed away - // (or the window is exactly full), so there is no in-store - // room for pagination either way — surface capacityReached. + // A rebuild trim (count cap or byte budget) evicted older + // blocks; a merely saturated window leaves no in-store room + // for pagination either way — surface capacityReached for both. replayExceededCapacity = repairingEpisode === undefined && - replayState.blocks.length >= maxBlocks; + (replayTrimmed || replayState.blocks.length >= maxBlocks); + if (replayExceededCapacity) { + // The pre-trim anchor can sit inside the trimmed stretch; + // re-anchor below to the oldest RETAINED record so + // pagination fetches exactly the dropped records. + replayTrimmedAnchor = replayState.blocks.find( + (block) => (block.sourceRecordIds?.length ?? 0) > 0, + )?.sourceRecordIds?.[0]; + } // Replay must never ratchet the retention window above the // configured cap: the committed cap is what bounds every // later dispatch, and an escalation here turned one large @@ -1786,11 +1829,25 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { setWorkspaceEventSignals, ); } - if (replayExceededCapacity && historyHasMore) { - transcriptHistoryRef.current.hasMore = false; + if (replayExceededCapacity) { + if (replayTrimmedAnchor !== undefined) { + transcriptHistoryRef.current.beforeRecordId = + replayTrimmedAnchor; + } + // Trimmed replay content stays persisted daemon-side and is + // fetchable through pagination (for live sessions too), so + // keep the load-older affordance instead of dropping the + // content silently. + const olderHistoryReachable = + Array.isArray(capabilities?.features) && + capabilities.features.includes( + SESSION_TRANSCRIPT_PAGINATION_FEATURE, + ) && + transcriptHistoryRef.current.beforeRecordId !== undefined; + transcriptHistoryRef.current.hasMore = olderHistoryReachable; transcriptHistoryRef.current.capacityReached = true; setTranscriptHistoryState({ - hasMore: false, + hasMore: olderHistoryReachable, loading: false, capacityReached: true, paginationError: false, From dd0c95a7eaaadeb422f55646f43f49a201524787 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Tue, 18 Aug 2026 17:47:33 +0800 Subject: [PATCH 09/16] fix(webui): gate pagination re-open and drop stale in-flight pages (#9303) Round-7 review fixes for the eviction/pagination reconciliation: - The eviction re-open of the load-older affordance now applies the same gates as the sibling paths: it requires the session_transcript_pagination capability and an existing positional anchor (beforeRecordId or cursor). On non-pagination daemons or anchor-less sessions the capacity latch stays closed instead of exposing an affordance that would call a route the daemon does not serve and latch a pagination error. - A retention trim firing while a load-older page is in flight re-anchors the exclusive beforeRecordId; the stale page fetched against the old anchor is now dropped instead of merged, so it can never advance the anchor below the evicted band. Load-older fetches capture a monotonic pagination generation bumped on every block trim and drop the page when it moved mid-fetch (lossless: every record in the dropped page is older than the new anchor and is re-served by the next fetch). Adds regression tests for both: latch stays closed on live trims without pagination support, and an in-flight page is dropped across a mid-fetch re-anchor with the retry fetching against the re-anchored anchor. --- .../session/DaemonSessionProvider.test.tsx | 303 ++++++++++++++++++ .../daemon/session/DaemonSessionProvider.tsx | 39 ++- 2 files changed, 341 insertions(+), 1 deletion(-) diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index c7989fae2ba..8fa6d83add4 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -13023,6 +13023,309 @@ describe('DaemonSessionProvider', () => { ); }); + it('keeps the capacity latch closed on live trims when pagination is unavailable', async () => { + // The eviction re-open of the load-older affordance must apply the same + // gates as the sibling paths: without the pagination feature (or without + // an anchor) a re-opened affordance would call a route the daemon does + // not serve and latch a pagination error instead of the terminal + // capacity state. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: [], + }); + const chunk = ( + id: number, + kind: 'user_message_chunk' | 'agent_message_chunk', + text: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: kind, + content: { type: 'text', text }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, + }, + }, + }); + const liveGate = createDeferred(); + const session = createMockSession({ + sessionId: 'session-latch-no-pagination', + historyHasMore: true, + replaySnapshot: { + compactedReplay: [ + chunk(1, 'user_message_chunk', 'replay prompt', 'record-1'), + chunk(2, 'agent_message_chunk', 'replay answer', 'record-2'), + ], + liveJournal: [], + }, + events: async function* liveEventAfterGate( + opts: { signal?: AbortSignal } = {}, + ) { + await Promise.race([ + liveGate.promise, + new Promise((resolve) => { + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }), + ]); + if (opts.signal?.aborted) return; + yield chunk(3, 'user_message_chunk', 'live one', 'record-live-1'); + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + yield* []; + }, + }); + sdkMocks.sessions.push(session); + let history: ReturnType | undefined; + + function Harness() { + history = useDaemonTranscriptHistory(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + maxBlocks: 2, + }); + + // Saturated replay on a non-pagination daemon latches the capacity + // state with the affordance closed. + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: true, + }); + + // Live growth count-trims; the re-open must stay gated. + await act(async () => { + liveGate.resolve(); + await flushTranscriptDispatch(); + }); + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: true, + }); + expect(sdkMocks.getSessionTranscriptPage).not.toHaveBeenCalled(); + }); + + it('drops an in-flight history page when a trim re-anchors pagination mid-fetch', async () => { + // A byte/count trim firing while a load-older page is in flight + // re-anchors the exclusive `beforeRecordId`; merging the stale page + // afterwards would advance the anchor below the evicted band and make + // evicted-but-persisted records unreachable. The page must be dropped + // losslessly instead. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const chunk = ( + id: number, + kind: 'user_message_chunk' | 'agent_message_chunk', + text: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: kind, + content: { type: 'text', text }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, + }, + }, + }); + const firstLiveGate = createDeferred(); + const secondLiveGate = createDeferred(); + const session = createMockSession({ + sessionId: 'session-inflight-page-trim', + historyHasMore: true, + replaySnapshot: { + compactedReplay: [ + chunk(1, 'user_message_chunk', 'replay prompt', 'record-1'), + chunk(2, 'agent_message_chunk', 'replay answer', 'record-2'), + ], + liveJournal: [], + }, + events: async function* liveEventsAfterGates( + opts: { signal?: AbortSignal } = {}, + ) { + await Promise.race([ + firstLiveGate.promise, + new Promise((resolve) => { + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }), + ]); + if (opts.signal?.aborted) return; + yield chunk(3, 'user_message_chunk', 'live one', 'record-live-1'); + await Promise.race([ + secondLiveGate.promise, + new Promise((resolve) => { + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }), + ]); + if (opts.signal?.aborted) return; + yield chunk(4, 'agent_message_chunk', 'live two', 'record-live-2'); + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + yield* []; + }, + }); + sdkMocks.sessions.push(session); + // Page 1 merges and saturates the window; page 2 resolves only when the + // test triggers it — after the mid-fetch trim. + const stalePage = createDeferred<{ + v: 1; + sessionId: string; + events: DaemonEvent[]; + hasMore: boolean; + }>(); + sdkMocks.getSessionTranscriptPage + .mockResolvedValueOnce({ + v: 1, + sessionId: session.sessionId, + events: [chunk(10, 'user_message_chunk', 'older prompt', 'record-a')], + hasMore: true, + }) + .mockReturnValueOnce(stalePage.promise) + .mockResolvedValueOnce({ + v: 1, + sessionId: session.sessionId, + events: [], + hasMore: false, + }); + let history: ReturnType | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + history = useDaemonTranscriptHistory(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + maxBlocks: 3, + }); + + // Admit the older page; the window is now full (3 of 3). + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + expect(blocks.map((block) => block.sourceRecordIds?.[0])).toEqual([ + 'record-a', + 'record-1', + 'record-2', + ]); + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: true, + }); + + // Live growth evicts the prepended page block, releases the latch and + // re-anchors to record-1. + await act(async () => { + firstLiveGate.resolve(); + await flushTranscriptDispatch(); + }); + expect(history).toMatchObject({ + hasMore: true, + loading: false, + capacityReached: false, + }); + + // Start the next fetch (against record-1) but leave it in flight... + let pendingLoad: Promise | undefined; + await act(async () => { + pendingLoad = history?.loadMore(); + await flushPromises(); + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenNthCalledWith( + 2, + session.sessionId, + { + beforeRecordId: 'record-1', + limit: 25, + clientId: session.clientId, + }, + ); + + // ...while another live block trims again, re-anchoring to record-2. + await act(async () => { + secondLiveGate.resolve(); + await flushTranscriptDispatch(); + }); + + // The stale page now resolves; it must be dropped, not merged. + await act(async () => { + stalePage.resolve({ + v: 1, + sessionId: session.sessionId, + events: [chunk(11, 'agent_message_chunk', 'older answer', 'record-b')], + hasMore: true, + }); + await pendingLoad; + await flushPromises(); + }); + expect(blocks.map((block) => block.sourceRecordIds?.[0])).toEqual([ + 'record-2', + 'record-live-1', + 'record-live-2', + ]); + expect(history).toMatchObject({ + hasMore: true, + loading: false, + }); + + // A retry fetches against the re-anchored anchor; nothing was lost. + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenNthCalledWith( + 3, + session.sessionId, + { + beforeRecordId: 'record-2', + limit: 25, + clientId: session.clientId, + }, + ); + }); + it('releases the replay snapshot after injection and never raises the block cap', async () => { sdkMocks.capabilities.mockResolvedValue({ workspaceCwd: '/mock-workspace', diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index cabcc0ef077..304ccc075a3 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -614,6 +614,12 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { capacityReached: false, paginationError: false, }); + // Monotonic counter bumped whenever a block trim invalidates the + // pagination position (the anchor record may have been evicted). A + // load-older fetch captures it before the await and drops the page if it + // moved mid-fetch, so a stale page can never advance the anchor below the + // evicted band. + const paginationGenerationRef = useRef(0); const store = useMemo( () => createDaemonTranscriptStore({ @@ -633,9 +639,23 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { if (detail.oldestRetainedRecordId !== undefined) { history.beforeRecordId = detail.oldestRetainedRecordId; } + // Any eviction can invalidate an in-flight page's anchor. + paginationGenerationRef.current += 1; if (history.capacityReached) { // Eviction freed retention capacity, so the page rejected at the - // latch may fit now — re-open the load-older affordance. + // latch may fit now — re-open the load-older affordance, but + // only where the sibling paths would have offered it: the daemon + // must support pagination and a positional anchor must exist. + const features = sessionCapabilitiesRef.current?.features; + const paginationSupported = + Array.isArray(features) && + features.includes(SESSION_TRANSCRIPT_PAGINATION_FEATURE); + const anchored = + history.beforeRecordId !== undefined || + history.cursor !== undefined; + if (!paginationSupported || !anchored) { + return; + } history.hasMore = true; history.capacityReached = false; setTranscriptHistoryState({ @@ -3197,6 +3217,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { capacityReached: false, paginationError: false, }); + const fetchPaginationGeneration = paginationGenerationRef.current; let terminalFailure = false; try { const page = await activeSession.getTranscriptPage({ @@ -3214,6 +3235,22 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ) { return; } + if (paginationGenerationRef.current !== fetchPaginationGeneration) { + // A retention trim re-anchored pagination while this page was in + // flight. The page was fetched against the stale anchor; merging it + // would advance the anchor below the evicted band and make the + // evicted-but-persisted records unreachable. Drop it without + // mutating pagination state — every record it carries is older + // than the new anchor and will be re-served by the next fetch. + history.loading = false; + setTranscriptHistoryState({ + hasMore: history.hasMore, + loading: false, + capacityReached: history.capacityReached, + paginationError: history.paginationError, + }); + return; + } if (page.partial || page.replayError) { terminalFailure = true; throw new Error( From c0f6c912454a9d9774a99579c981522832796040 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Tue, 18 Aug 2026 21:09:20 +0800 Subject: [PATCH 10/16] fix(webui): gate pagination re-open on admission headroom (#9303) Round-8 review fix for the eviction-triggered capacity-latch re-open: - The re-open now checks whether the rejected page would actually be admitted against the post-trim window occupancy (carried on the truncation detail). A rejected page's footprint is remembered at rejection time; the latch re-opens only once enough count AND byte headroom has been freed for that page, stopping the streaming fetch/reject/re-render churn when count trims restore the window to exactly maxBlocks (zero headroom). - A footprint-less latch (replay saturation) re-opens only on real count headroom: while the count window is saturated, count admission rejects every page regardless of freed bytes. - A page that alone exceeds the whole retention window (block count or byte budget) can never be admitted in any occupancy state; it is now a terminal pagination failure (hasMore=false + paginationError) instead of a re-openable capacity state, so everything older than it is no longer silently unreachable. - Adds a maxRetainedBytes provider prop (mirrors maxBlocks) so the byte budget is configurable and testable; the replay rebuild store uses the same budget as the live store. Adds regression tests: churn suppression under count saturation, latch suppression while the freed hole is smaller than the rejected page, footprint-based re-open once eviction frees room (with re-anchored fetch), and terminal failure for a single oversized page. --- .../src/daemon/ui/transcript.ts | 4 + .../sdk-typescript/src/daemon/ui/types.ts | 9 + .../sdk-typescript/test/unit/daemonUi.test.ts | 20 +- .../session/DaemonSessionProvider.test.tsx | 425 +++++++++++++++--- .../daemon/session/DaemonSessionProvider.tsx | 138 +++++- packages/webui/src/daemon/session/types.ts | 8 + 6 files changed, 515 insertions(+), 89 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index b73ed29f7a3..69bcd705859 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -1571,6 +1571,10 @@ function trimTranscriptState( truncationCallbacks.get(state)?.({ kind: 'blocks', oldestRetainedRecordId: oldestRetainedBlock?.sourceRecordIds?.[0], + blockCount: state.blocks.length, + retainedBytes: state.retainedBytes, + maxBlocks: state.maxBlocks, + maxRetainedBytes: state.maxRetainedBytes, }); return state; } diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index adaeb6172de..548b6acb525 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -1083,6 +1083,15 @@ export interface DaemonTranscriptTruncationDetail { * pagination anchors with retention trimming. */ oldestRetainedRecordId?: string; + /** + * Set for `kind: 'blocks'`: the post-trim window occupancy. Lets consumers + * decide whether a previously rejected history page would now be admitted, + * without reading a snapshot that may lag the in-flight dispatch. + */ + blockCount?: number; + retainedBytes?: number; + maxBlocks?: number; + maxRetainedBytes?: number; } export interface DaemonTranscriptStore { diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 320bf95d31a..5b207a19fa3 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -1333,10 +1333,12 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(onTruncation).not.toHaveBeenCalled(); store.dispatch(toolEvent(2)); expect(onTruncation).toHaveBeenCalledTimes(1); - expect(onTruncation).toHaveBeenCalledWith({ - kind: 'blocks', - oldestRetainedRecordId: 'record-1', - }); + expect(onTruncation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'blocks', + oldestRetainedRecordId: 'record-1', + }), + ); onTruncation.mockClear(); store.reset({ maxBlocks: 2 }); @@ -1344,10 +1346,12 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(onTruncation).not.toHaveBeenCalled(); store.dispatch(toolEvent(5)); expect(onTruncation).toHaveBeenCalledTimes(1); - expect(onTruncation).toHaveBeenCalledWith({ - kind: 'blocks', - oldestRetainedRecordId: 'record-4', - }); + expect(onTruncation).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'blocks', + oldestRetainedRecordId: 'record-4', + }), + ); }); it('counts every image merged into a user block against the retention budget', () => { diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 8fa6d83add4..48ba56ae29b 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -12046,21 +12046,28 @@ describe('DaemonSessionProvider', () => { }); const retainedBefore = retainedBytes; expect(retainedBefore).toBeGreaterThan(0); + // The page alone (~140 MB estimated) exceeds the whole 128 MiB byte + // budget, so it can never be admitted in any occupancy state — a + // terminal pagination failure rather than a re-openable capacity latch + // (which would re-offer the same doomed page on every trim and hide + // everything older than it). await act(async () => { - await history?.loadMore(); + await expect(history?.loadMore()).rejects.toThrow( + 'Earlier history page exceeds the transcript retention window', + ); await flushPromises(); }); - // Atomic rejection: nothing from the page merges (a partial merge would - // mean the scratch store trimmed mid-build and leaked a headless page), - // the byte counter is untouched, and the capacity latch stops paging. + // Nothing from the page merges, the byte counter is untouched, and the + // failure latches as a terminal pagination error. expect(blocks.filter((block) => block.kind === 'tool')).toHaveLength(0); expect(blocks).toHaveLength(1); expect(retainedBytes).toBe(retainedBefore); expect(history).toMatchObject({ hasMore: false, loading: false, - capacityReached: true, + capacityReached: false, + paginationError: true, }); }); @@ -12872,12 +12879,10 @@ describe('DaemonSessionProvider', () => { ); }); - it('re-anchors pagination and releases the capacity latch when live growth evicts paginated-in blocks', async () => { - // An admitted page prepends its blocks; live growth then trims - // oldest-first and evicts the page — including the exclusive anchor. - // The provider must re-anchor to the oldest retained record and re-open - // the load-older affordance once eviction frees capacity, or the stretch - // between the anchor and the window head is lost silently. + it('re-anchors pagination and releases the latch once eviction frees room for the rejected page', async () => { + // A byte-budget rejection stores the rejected page's footprint; the + // eviction re-open must fire only once enough capacity has actually been + // freed for THAT page, then fetch against the re-anchored anchor. sdkMocks.capabilities.mockResolvedValue({ workspaceCwd: '/mock-workspace', features: ['session_transcript_pagination'], @@ -12908,8 +12913,8 @@ describe('DaemonSessionProvider', () => { historyHasMore: true, replaySnapshot: { compactedReplay: [ - replayEvent(1, 'user_message_chunk', 'replay prompt', 'record-1'), - replayEvent(2, 'agent_message_chunk', 'replay answer', 'record-2'), + replayEvent(1, 'user_message_chunk', 'a'.repeat(1100), 'record-1'), + replayEvent(2, 'agent_message_chunk', 'b'.repeat(100), 'record-2'), ], liveJournal: [], }, @@ -12925,12 +12930,11 @@ describe('DaemonSessionProvider', () => { }), ]); if (opts.signal?.aborted) return; - yield replayEvent(3, 'user_message_chunk', 'live one', 'record-live-1'); yield replayEvent( - 4, - 'agent_message_chunk', - 'live two', - 'record-live-2', + 3, + 'user_message_chunk', + 'z'.repeat(450), + 'record-live-1', ); await new Promise((resolve) => { if (opts.signal?.aborted) { @@ -12945,22 +12949,15 @@ describe('DaemonSessionProvider', () => { }, }); sdkMocks.sessions.push(session); - sdkMocks.getSessionTranscriptPage - .mockResolvedValueOnce({ - v: 1, - sessionId: session.sessionId, - events: [ - replayEvent(10, 'user_message_chunk', 'older prompt', 'record-a'), - replayEvent(11, 'agent_message_chunk', 'older answer', 'record-b'), - ], - hasMore: true, - }) - .mockResolvedValueOnce({ - v: 1, - sessionId: session.sessionId, - events: [], - hasMore: false, - }); + const olderPage = { + v: 1 as const, + sessionId: session.sessionId, + events: [ + replayEvent(10, 'user_message_chunk', 'p'.repeat(400), 'record-a'), + ], + hasMore: false, + }; + sdkMocks.getSessionTranscriptPage.mockResolvedValue(olderPage); let history: ReturnType | undefined; let blocks: readonly DaemonTranscriptBlock[] = []; @@ -12973,18 +12970,17 @@ describe('DaemonSessionProvider', () => { await renderWithProvider(, { autoConnect: true, historyPageSize: 25, - maxBlocks: 4, + maxBlocks: 8, + maxRetainedBytes: 3600, }); - // Merge the older page: the window is now exactly full (4 of 4 - // blocks), latching the capacity indicator. + // The page alone fits the budget, but not on top of the current window: + // rejected, with its footprint recorded. await act(async () => { await history?.loadMore(); await flushPromises(); }); expect(blocks.map((block) => block.sourceRecordIds?.[0])).toEqual([ - 'record-a', - 'record-b', 'record-1', 'record-2', ]); @@ -12993,15 +12989,15 @@ describe('DaemonSessionProvider', () => { loading: false, capacityReached: true, }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledTimes(1); - // Live growth evicts the freshly prepended page blocks (oldest-first), - // including the anchor record... + // Live growth crosses the budget; the trim evicts the big replay block, + // freeing more than the rejected page needs — the latch re-opens and the + // anchor moves to the oldest retained record. await act(async () => { liveGate.resolve(); await flushTranscriptDispatch(); }); - // ...which releases the latch and re-anchors to the oldest retained - // record. expect(history).toMatchObject({ hasMore: true, loading: false, @@ -13016,11 +13012,16 @@ describe('DaemonSessionProvider', () => { 2, session.sessionId, { - beforeRecordId: 'record-1', + beforeRecordId: 'record-2', limit: 25, clientId: session.clientId, }, ); + expect(blocks.map((block) => block.sourceRecordIds?.[0])).toEqual([ + 'record-a', + 'record-2', + 'record-live-1', + ]); }); it('keeps the capacity latch closed on live trims when pagination is unavailable', async () => { @@ -13189,6 +13190,7 @@ describe('DaemonSessionProvider', () => { ]); if (opts.signal?.aborted) return; yield chunk(4, 'agent_message_chunk', 'live two', 'record-live-2'); + yield chunk(5, 'user_message_chunk', 'live three', 'record-live-3'); await new Promise((resolve) => { if (opts.signal?.aborted) { resolve(); @@ -13202,8 +13204,8 @@ describe('DaemonSessionProvider', () => { }, }); sdkMocks.sessions.push(session); - // Page 1 merges and saturates the window; page 2 resolves only when the - // test triggers it — after the mid-fetch trim. + // Page 1 merges (window 3 of 4, still paging room); page 2 resolves + // only when the test triggers it — after the mid-fetch trim. const stalePage = createDeferred<{ v: 1; sessionId: string; @@ -13236,10 +13238,10 @@ describe('DaemonSessionProvider', () => { await renderWithProvider(, { autoConnect: true, historyPageSize: 25, - maxBlocks: 3, + maxBlocks: 4, }); - // Admit the older page; the window is now full (3 of 3). + // Admit the older page; the window is 3 of 4, paging stays available. await act(async () => { await history?.loadMore(); await flushPromises(); @@ -13250,24 +13252,19 @@ describe('DaemonSessionProvider', () => { 'record-2', ]); expect(history).toMatchObject({ - hasMore: false, + hasMore: true, loading: false, - capacityReached: true, + capacityReached: false, }); - // Live growth evicts the prepended page block, releases the latch and - // re-anchors to record-1. + // One live block fills the window (4 of 4)... await act(async () => { firstLiveGate.resolve(); await flushTranscriptDispatch(); }); - expect(history).toMatchObject({ - hasMore: true, - loading: false, - capacityReached: false, - }); - // Start the next fetch (against record-1) but leave it in flight... + // ...then the next fetch (against record-a, the merged page's oldest + // record) starts, still in flight... let pendingLoad: Promise | undefined; await act(async () => { pendingLoad = history?.loadMore(); @@ -13277,19 +13274,21 @@ describe('DaemonSessionProvider', () => { 2, session.sessionId, { - beforeRecordId: 'record-1', + beforeRecordId: 'record-a', limit: 25, clientId: session.clientId, }, ); - // ...while another live block trims again, re-anchoring to record-2. + // ...while two more live blocks overflow the window; the trim evicts + // record-a AND record-1, re-anchoring to record-2. await act(async () => { secondLiveGate.resolve(); await flushTranscriptDispatch(); }); - // The stale page now resolves; it must be dropped, not merged. + // The stale page (fetched against the evicted record-a anchor) now + // resolves; it must be dropped, not merged. await act(async () => { stalePage.resolve({ v: 1, @@ -13304,6 +13303,7 @@ describe('DaemonSessionProvider', () => { 'record-2', 'record-live-1', 'record-live-2', + 'record-live-3', ]); expect(history).toMatchObject({ hasMore: true, @@ -13326,6 +13326,309 @@ describe('DaemonSessionProvider', () => { ); }); + it('does not churn fetch/reject while count-saturated trims leave zero headroom', async () => { + // A count trim restores the window to exactly maxBlocks, so a rejected + // page still cannot fit afterwards; the eviction re-open must stay gated + // on admission headroom instead of re-offering the doomed page on every + // live block. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const chunk = ( + id: number, + kind: 'user_message_chunk' | 'agent_message_chunk', + text: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: kind, + content: { type: 'text', text }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, + }, + }, + }); + const liveGate = createDeferred(); + const session = createMockSession({ + sessionId: 'session-churn-guard', + historyHasMore: true, + replaySnapshot: { + compactedReplay: [ + chunk(1, 'user_message_chunk', 'replay prompt', 'record-1'), + chunk(2, 'agent_message_chunk', 'replay answer', 'record-2'), + ], + liveJournal: [], + }, + events: async function* liveEventAfterGate( + opts: { signal?: AbortSignal } = {}, + ) { + await Promise.race([ + liveGate.promise, + new Promise((resolve) => { + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }), + ]); + if (opts.signal?.aborted) return; + yield chunk(3, 'user_message_chunk', 'live one', 'record-live-1'); + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + yield* []; + }, + }); + sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: [ + chunk(10, 'user_message_chunk', 'older prompt', 'record-a'), + chunk(11, 'agent_message_chunk', 'older answer', 'record-b'), + ], + hasMore: true, + }); + let history: ReturnType | undefined; + + function Harness() { + history = useDaemonTranscriptHistory(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + maxBlocks: 2, + }); + + // The two-block page cannot join the saturated 2/2 window; the latch + // records its footprint. + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: true, + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledTimes(1); + + // Live growth count-trims back to exactly maxBlocks — zero headroom — + // so the latch must NOT re-open into another doomed fetch. + await act(async () => { + liveGate.resolve(); + await flushTranscriptDispatch(); + }); + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: true, + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledTimes(1); + }); + + it('keeps the capacity latch while the freed hole is smaller than the rejected page', async () => { + // A byte-budget rejection must not be re-offered on every later trim: + // trimming stops as soon as the estimate is back under budget, so the + // freed hole is typically smaller than the rejected page. The re-open + // gate checks the rejected footprint against the post-trim occupancy. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const chunk = ( + id: number, + kind: 'user_message_chunk' | 'agent_message_chunk', + text: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: kind, + content: { type: 'text', text }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, + }, + }, + }); + const liveGate = createDeferred(); + const session = createMockSession({ + sessionId: 'session-byte-hole-guard', + historyHasMore: true, + replaySnapshot: { + compactedReplay: [ + chunk(1, 'user_message_chunk', 'replay prompt', 'record-1'), + ], + liveJournal: [], + }, + events: async function* liveEventAfterGate( + opts: { signal?: AbortSignal } = {}, + ) { + await Promise.race([ + liveGate.promise, + new Promise((resolve) => { + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }), + ]); + if (opts.signal?.aborted) return; + yield chunk(3, 'user_message_chunk', 'z'.repeat(1800), 'record-live-1'); + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + yield* []; + }, + }); + sdkMocks.sessions.push(session); + // Page estimate lands between "current + page over budget" and "page + // alone over budget": rejectable, but not terminally impossible. + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: [chunk(10, 'user_message_chunk', 'y'.repeat(1700), 'record-a')], + hasMore: true, + }); + let history: ReturnType | undefined; + + function Harness() { + history = useDaemonTranscriptHistory(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + maxBlocks: 8, + maxRetainedBytes: 3900, + }); + + await act(async () => { + await history?.loadMore(); + await flushPromises(); + }); + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: true, + paginationError: false, + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledTimes(1); + + // Live growth crosses the budget; the trim evicts the small replay + // block, freeing far less than the rejected page's footprint, so the + // latch must stay closed. + await act(async () => { + liveGate.resolve(); + await flushTranscriptDispatch(); + }); + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: true, + paginationError: false, + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledTimes(1); + }); + + it('fails terminally when a single history page exceeds the retention budget', async () => { + // A page that alone exceeds the whole byte budget can never be admitted + // in any window state; re-offering it forever would hide everything + // older than it. Surface a terminal pagination failure instead. + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const chunk = ( + id: number, + kind: 'user_message_chunk' | 'agent_message_chunk', + text: string, + recordId: string, + ): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: kind, + content: { type: 'text', text }, + _meta: { + 'qwen.session.recordId': recordId, + qwenTranscript: { sourceRecordIds: [recordId] }, + }, + }, + }, + }); + const session = createMockSession({ + sessionId: 'session-oversized-page', + historyHasMore: true, + replaySnapshot: { + compactedReplay: [ + chunk(1, 'user_message_chunk', 'replay prompt', 'record-1'), + ], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage.mockResolvedValue({ + v: 1, + sessionId: session.sessionId, + events: [chunk(10, 'user_message_chunk', 'y'.repeat(600), 'record-a')], + hasMore: true, + }); + let history: ReturnType | undefined; + + function Harness() { + history = useDaemonTranscriptHistory(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + maxBlocks: 8, + maxRetainedBytes: 1000, + }); + + await act(async () => { + await expect(history?.loadMore()).rejects.toThrow( + 'Earlier history page exceeds the transcript retention window', + ); + await flushPromises(); + }); + expect(history).toMatchObject({ + hasMore: false, + loading: false, + capacityReached: false, + paginationError: true, + }); + }); + it('releases the replay snapshot after injection and never raises the block cap', async () => { sdkMocks.capabilities.mockResolvedValue({ workspaceCwd: '/mock-workspace', diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 304ccc075a3..be15183ae0d 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -161,6 +161,17 @@ interface TranscriptHistoryMaterialization { permissionBlockByRequestId: Record; } +type TranscriptHistoryAdmission = + | { admitted: true; materialization: TranscriptHistoryMaterialization } + | { + admitted: false; + reason: 'count' | 'bytes'; + pageBlocks: number; + pageBytes: number; + /** True when the page can never be admitted, even into an empty window. */ + impossible: boolean; + }; + const SESSION_TRANSCRIPT_PAGINATION_FEATURE = 'session_transcript_pagination'; const CLIENT_IDENTITY_FEATURE = 'client_identity'; const WORKSPACE_ACP_PREHEAT_FEATURE = 'workspace_acp_preheat'; @@ -263,7 +274,7 @@ function materializeTranscriptHistory( current: DaemonTranscriptState, events: DaemonUiEvent[], maxBlocks: number, -): TranscriptHistoryMaterialization | undefined { +): TranscriptHistoryAdmission { // Drop fetched events whose source records are already displayed. // `beforeRecordId` pagination is exclusive of the anchor but the anchor // can sit inside the retained window (e.g. the daemon's transcript @@ -296,26 +307,48 @@ function materializeTranscriptHistory( }); historyStore.dispatch(freshEvents); const history = historyStore.getSnapshot(); - if (history.blocks.length + current.blocks.length > maxBlocks) { - return undefined; - } - let retainedBytes = 0; + let pageBytes = 0; for (const block of history.blocks) { - retainedBytes += estimateDaemonTranscriptBlockBytes(block); + pageBytes += estimateDaemonTranscriptBlockBytes(block); } - // Byte-budget admission: an over-budget merge stays untrimmed while the - // session is idle, and the next live trim evicts the freshly prepended - // oldest records, which the exclusive pagination anchor can never - // re-fetch — a permanent silent gap. Reject atomically like the block cap. - if (current.retainedBytes + retainedBytes > current.maxRetainedBytes) { - return undefined; + const pageBlocks = history.blocks.length; + // Count admission: an over-count merge stays untrimmed while the session + // is idle, and the next live trim evicts the freshly prepended oldest + // records, which the exclusive pagination anchor can never re-fetch — a + // permanent silent gap. Reject atomically. + if (pageBlocks + current.blocks.length > maxBlocks) { + return { + admitted: false, + reason: 'count', + pageBlocks, + pageBytes, + // A page that alone exceeds the whole block window can never be + // admitted, in any occupancy state. + impossible: pageBlocks > maxBlocks, + }; + } + // Byte-budget admission: same silent-gap hazard as the count cap — an + // over-budget merge is evicted oldest-first by the next live trim. + if (current.retainedBytes + pageBytes > current.maxRetainedBytes) { + return { + admitted: false, + reason: 'bytes', + pageBlocks, + pageBytes, + // A page that alone exceeds the whole byte budget can never be + // admitted, even into an empty window. + impossible: pageBytes > current.maxRetainedBytes, + }; } return { - blocks: history.blocks, - nextOrdinal: history.nextOrdinal, - retainedBytes, - toolBlockByCallId: history.toolBlockByCallId, - permissionBlockByRequestId: history.permissionBlockByRequestId, + admitted: true, + materialization: { + blocks: history.blocks, + nextOrdinal: history.nextOrdinal, + retainedBytes: pageBytes, + toolBlockByCallId: history.toolBlockByCallId, + permissionBlockByRequestId: history.permissionBlockByRequestId, + }, }; } @@ -548,6 +581,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { createSessionRequest, maxQueued = 1024, maxBlocks = DEFAULT_MAX_BLOCKS, + maxRetainedBytes, historyPageSize, subagentTranscriptMode = 'full', suppressOwnUserEcho = true, @@ -602,6 +636,13 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { loading: boolean; capacityReached: boolean; paginationError: boolean; + /** + * Footprint of the last page rejected by admission. The eviction + * re-open of the capacity latch consults it so the affordance only + * reappears once enough capacity has been freed for that page to be + * admitted; undefined when the latch came from replay saturation. + */ + rejectedPage?: { blocks: number; bytes: number }; }>({ hasMore: false, loading: false, @@ -624,6 +665,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { () => createDaemonTranscriptStore({ maxBlocks, + ...(maxRetainedBytes !== undefined ? { maxRetainedBytes } : {}), retainSubagentBlocks: subagentTranscriptMode === 'full', onTruncation: (detail) => { if (detail.kind !== 'blocks') return; @@ -656,6 +698,32 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { if (!paginationSupported || !anchored) { return; } + // Admission-headroom gate: only re-open when the rejected page + // would actually be admitted now. A count trim restores the + // window to exactly maxBlocks (zero headroom), so without this + // check every live block during streaming would re-open the + // latch into an immediate fetch/reject cycle. Caps are stable + // across a trim; the snapshot only backs detail-field fallbacks. + const windowCaps = store.getSnapshot(); + const postTrimBlockCount = + detail.blockCount ?? windowCaps.blocks.length; + const postTrimRetainedBytes = + detail.retainedBytes ?? windowCaps.retainedBytes; + const blockCap = detail.maxBlocks ?? windowCaps.maxBlocks; + const byteCap = + detail.maxRetainedBytes ?? windowCaps.maxRetainedBytes; + const rejected = history.rejectedPage; + // A footprint-less latch (replay saturation) re-opens only on + // real count headroom: while the count window is saturated, + // count admission rejects every page regardless of bytes. + const admissionHeadroom = rejected + ? rejected.blocks + postTrimBlockCount <= blockCap && + rejected.bytes + postTrimRetainedBytes <= byteCap + : postTrimBlockCount < blockCap; + if (!admissionHeadroom) { + return; + } + history.rejectedPage = undefined; history.hasMore = true; history.capacityReached = false; setTranscriptHistoryState({ @@ -667,7 +735,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { } }, }), - [maxBlocks, subagentTranscriptMode], + [maxBlocks, maxRetainedBytes, subagentTranscriptMode], ); const eventStreamRef = useRef< | { @@ -1754,6 +1822,11 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { maxBlocks: replayMaxBlocks, retainSubagentBlocks: subagentTranscriptModeRef.current === 'full', + // Rebuild under the same byte budget as the live store + // so an oversized replay is trimmed to the same ceiling. + ...(maxRetainedBytes !== undefined + ? { maxRetainedBytes } + : {}), // The count cap and the default byte budget can both // evict mid-rebuild; observe either so the pagination // anchor and capacity indicator reconcile below. @@ -2912,6 +2985,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { sessionScope, maxQueued, maxBlocks, + maxRetainedBytes, store, restoreSessionId, restoreWorkspaceCwd, @@ -3304,7 +3378,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ); } } - const historyMaterialization = + const admission = uiEvents.length > 0 ? materializeTranscriptHistory( store.getSnapshot(), @@ -3312,10 +3386,30 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { maxBlocks, ) : undefined; - if (uiEvents.length > 0 && !historyMaterialization) { + if (admission && !admission.admitted) { + if (admission.impossible) { + // A page that alone exceeds the whole window (block count or + // byte budget) can never be admitted in any occupancy state. + // Surface a terminal pagination failure instead of a re-openable + // capacity latch — otherwise every later trim would re-offer the + // same doomed page, and everything older than it would stay + // unreachable with no terminal signal. + history.rejectedPage = undefined; + terminalFailure = true; + throw new Error( + 'Earlier history page exceeds the transcript retention window', + ); + } history.hasMore = false; history.loading = false; history.capacityReached = true; + // Remember the rejected page's footprint: the eviction re-open + // must only fire once enough capacity has been freed for THIS page + // to be admitted, or streaming trims churn fetch/reject/re-render. + history.rejectedPage = { + blocks: admission.pageBlocks, + bytes: admission.pageBytes, + }; setTranscriptHistoryState({ hasMore: false, loading: false, @@ -3324,7 +3418,11 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { }); return; } + const historyMaterialization = admission?.admitted + ? admission.materialization + : undefined; if (historyMaterialization) { + history.rejectedPage = undefined; store.reset( applyTranscriptHistory(store.getSnapshot(), historyMaterialization), ); diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index 98bba877f6a..2d8af919979 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -138,6 +138,14 @@ export interface DaemonSessionProviderProps { maxQueued?: number; /** Maximum normalized transcript blocks retained in memory. */ maxBlocks?: number; + /** + * Maximum estimated bytes of transcript blocks retained in memory. + * Trimming evicts oldest blocks until the estimate is back under this + * budget; a block-count window alone is not a memory ceiling because + * blocks can carry large raw tool payloads. Defaults to the transcript + * store's built-in budget. + */ + maxRetainedBytes?: number; /** Latest persisted records requested during an existing-session load. */ historyPageSize?: number; /** Keep the full subagent transcript, or retain only bounded root summaries. */ From 72fec63458764f7fdbf7cf4908c46fb4c0a6fe20 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Wed, 19 Aug 2026 13:37:20 +0800 Subject: [PATCH 11/16] fix(webui): reconcile pagination with eviction across rebuild and rewind (#9303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-9/10 review fixes for the retention/pagination reconciliation: - impossible is now evaluated across both admission dimensions with >= at the equality boundary: a page that alone fills the whole block window, or whose estimate alone reaches the byte budget, can never be admitted (an anchored window always retains at least one block / some bytes), so it routes to the terminal pagination failure regardless of which branch rejects first. - applyTranscriptHistory merges sentinel-aware: a pagination-resurrected real tool block mapping wins over the current window's TRIMMED sentinel for the same callId (the resurrected block is no longer orphaned), and the resurrected tool's trimmed-notification flag is cleared. - rewind (truncateTranscriptBeforeBlock) now fires the same 'blocks' truncation signal when it drops blocks, so freed capacity can re-open the pagination latch. The detail carries evictedOldest so consumers only re-anchor on oldest-first eviction (a rewind drops the newest blocks and leaves the oldest anchor intact). - The live-trim re-anchor clears a stale cursor atomically, and fails closed (clears the anchor, closes the affordance) when no retained block carries a recordId and the pre-trim replay had several distinct recordIds (a real evicted band) — with zero/one recordId the pre-trim anchor stays valid. - Replay-rebuild reconciliation runs for live-journal repair rebuilds too (both the ordinary and the marker-visible checkpoint-seeded branches observe trimming), and fails closed when the re-anchor is uncomputable with a real evicted band. - The rebuild affordance only opens with real admission headroom: a positional anchor and byte-budget room. Without byte-budget headroom (e.g. a single oversized block whose estimate alone exceeds the budget) no page can ever be admitted, so the affordance closes instead of burning on the first click. - projectChatRecordsToDaemonTranscript (offline/export projection) passes maxRetainedBytes: Infinity — its only documented truncation knob is maxBlocks; the live-window byte budget must not silently evict rendered or exported transcripts. Updates the affected pagination tests to non-boundary footprints and adds coverage for the eviction-direction signal. --- packages/sdk-typescript/scripts/build.js | 5 +- packages/sdk-typescript/src/daemon/index.ts | 2 + .../src/daemon/ui/chat-record-transcript.ts | 7 + .../sdk-typescript/src/daemon/ui/index.ts | 2 + .../src/daemon/ui/transcript.ts | 30 ++++ .../sdk-typescript/src/daemon/ui/types.ts | 8 + .../session/DaemonSessionProvider.test.tsx | 37 ++-- .../daemon/session/DaemonSessionProvider.tsx | 161 ++++++++++++++---- 8 files changed, 208 insertions(+), 44 deletions(-) diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 9ffa39321ef..ec187de1ed8 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -100,7 +100,10 @@ const rootDir = join(__dirname, '..'); // Bumped from 197KB to 198KB for the retention byte budget (block byte // estimation + budget-aware trimming) and backing-store-detached string caps // (#9303 review round 3). -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 198 * 1024; +// Bumped from 198KB to 199KB for the pagination/eviction reconciliation +// (#9303 review rounds 9-10): eviction-direction signal, rewind truncation +// callback, and the trimmed-tool sentinel helper. +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 199 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 6834317946d..7999e62e34a 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -107,6 +107,7 @@ export { getSessionUpdatePayload, isDaemonUiSensitiveKey, isSubagentChildBlock, + isTrimmedToolBlockId, normalizeDaemonEvent, redactDaemonUiSensitiveFields, rebuildDaemonTranscriptBlockIndex, @@ -156,6 +157,7 @@ export type { DaemonTranscriptSidechannelState, DaemonTranscriptState, DaemonTranscriptStore, + DaemonTranscriptTruncationDetail, DaemonUiAssistantDoneEvent, DaemonUiAuthDeviceFlowAuthorizedEvent, DaemonUiAuthDeviceFlowCancelledEvent, diff --git a/packages/sdk-typescript/src/daemon/ui/chat-record-transcript.ts b/packages/sdk-typescript/src/daemon/ui/chat-record-transcript.ts index 2e21a23becb..1a549fc9ec9 100644 --- a/packages/sdk-typescript/src/daemon/ui/chat-record-transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/chat-record-transcript.ts @@ -105,6 +105,13 @@ export function projectChatRecordsToDaemonTranscript( let truncated = false; let state = createDaemonTranscriptState({ maxBlocks, + // Trim-free by bytes: this offline/export projection's only documented + // truncation knob is `maxBlocks`. The retention byte budget exists for + // the LIVE session window; applying it here would silently evict older + // blocks from rendered/exported transcripts whose `maxBlocks` never + // limited them, and the truncation diagnostic would misname maxBlocks as + // the cause. + maxRetainedBytes: Number.POSITIVE_INFINITY, now: 0, onTruncation: (detail) => { truncated = true; diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index 8abb964e7bc..9a5e10569dc 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -16,6 +16,7 @@ export { estimateDaemonTranscriptBlockBytes, formatBlockTimestamp, isSubagentChildBlock, + isTrimmedToolBlockId, rebuildDaemonTranscriptBlockIndex, reduceDaemonTranscriptEvents, selectApprovalMode, @@ -82,6 +83,7 @@ export type { DaemonTranscriptSidechannelState, DaemonTranscriptState, DaemonTranscriptStore, + DaemonTranscriptTruncationDetail, // Chat-stream events DaemonUiAssistantDoneEvent, DaemonUiDebugReason, diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 91eb131afc1..755e90c4fd7 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -33,6 +33,15 @@ const DEFAULT_MAX_BLOCKS = 1_000; const DEFAULT_MAX_RETAINED_BYTES = 128 * 1024 * 1024; const TRIMMED_TOOL_BLOCK_ID = '__trimmed_tool_block__'; const TRIMMED_PERMISSION_BLOCK_ID = '__trimmed_permission_block__'; + +/** + * True when a `toolBlockByCallId` entry is the trimmed-block sentinel (the + * original block was evicted by retention trimming). Lets consumers merge a + * pagination-resurrected real mapping without letting the stale sentinel win. + */ +export function isTrimmedToolBlockId(blockId: string | undefined): boolean { + return blockId === TRIMMED_TOOL_BLOCK_ID; +} const MAX_TEXT_BLOCK_LENGTH = 100_000; const TEXT_TRUNCATED_SUFFIX = '\n[truncated]\n'; const MAX_CLONE_DEPTH = 16; @@ -1569,6 +1578,7 @@ function trimTranscriptState( truncationCallbacks.get(state)?.({ kind: 'blocks', oldestRetainedRecordId: oldestRetainedBlock?.sourceRecordIds?.[0], + evictedOldest: true, blockCount: state.blocks.length, retainedBytes: state.retainedBytes, maxBlocks: state.maxBlocks, @@ -1652,6 +1662,7 @@ function truncateTranscriptBeforeBlock( blockIndex: number, ): void { takeBlocksOwnership(state); + const originalLength = state.blocks.length; let droppedBytes = 0; for (let index = blockIndex; index < state.blocks.length; index += 1) { const block = state.blocks[index]; @@ -1662,6 +1673,25 @@ function truncateTranscriptBeforeBlock( ownedBlocks.set(state, state.blocks); rebuildTranscriptIndexes(state); ownedBlockIndexes.set(state, state.blockIndexById); + if (state.blocks.length < originalLength) { + // A rewind frees retention capacity just like an eviction does. Fire the + // same 'blocks' truncation signal so consumers reconciling a pagination + // capacity latch on freed capacity observe rewinds too. `evictedOldest` + // is false: a rewind drops the NEWEST blocks, so the oldest pagination + // anchor stays valid and must not be re-anchored. + const oldestRetainedBlock = state.blocks.find( + (block) => (block.sourceRecordIds?.length ?? 0) > 0, + ); + truncationCallbacks.get(state)?.({ + kind: 'blocks', + oldestRetainedRecordId: oldestRetainedBlock?.sourceRecordIds?.[0], + evictedOldest: false, + blockCount: state.blocks.length, + retainedBytes: state.retainedBytes, + maxBlocks: state.maxBlocks, + maxRetainedBytes: state.maxRetainedBytes, + }); + } } function rebuildTranscriptIndexes(state: DaemonTranscriptState): void { diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index b104018c637..1d6382d62f0 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -1084,6 +1084,14 @@ export interface DaemonTranscriptTruncationDetail { * pagination anchors with retention trimming. */ oldestRetainedRecordId?: string; + /** + * Set for `kind: 'blocks'`: whether the eviction removed blocks from the + * OLDEST end. True for retention trimming (oldest-first), which can evict + * the record an exclusive pagination anchor points at; false for a rewind + * (which drops the newest blocks and leaves the oldest anchor intact). + * Consumers should only re-anchor pagination when this is true. + */ + evictedOldest?: boolean; /** * Set for `kind: 'blocks'`: the post-trim window occupancy. Lets consumers * decide whether a previously rejected history page would now be admitted, diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 0a37596852d..389eac3bacb 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -13465,13 +13465,13 @@ describe('DaemonSessionProvider', () => { }, }); sdkMocks.sessions.push(session); + // A single-block page cannot join the saturated 2/2 window (count), but a + // lone block is below the block cap, so it routes to the re-openable + // latch rather than the terminal branch; the latch records its footprint. sdkMocks.getSessionTranscriptPage.mockResolvedValue({ v: 1, sessionId: session.sessionId, - events: [ - chunk(10, 'user_message_chunk', 'older prompt', 'record-a'), - chunk(11, 'agent_message_chunk', 'older answer', 'record-b'), - ], + events: [chunk(10, 'user_message_chunk', 'older prompt', 'record-a')], hasMore: true, }); let history: ReturnType | undefined; @@ -13810,20 +13810,25 @@ describe('DaemonSessionProvider', () => { sessionId: 'session-history-capacity', historyHasMore: true, replaySnapshot: { - compactedReplay: [replayEvent(2, 'recent prompt', 'record-2')], + compactedReplay: [ + replayEvent(1, 'replay prompt', 'record-1'), + replayEvent(2, 'replay answer', 'record-2', 'agent_message_chunk'), + ], liveJournal: [], }, }); sdkMocks.sessions.push(session); - // Two older blocks (distinct kinds so they do not merge): with the - // retained block they overflow the cap, so the terminal page must be - // rejected atomically instead of partially prepended. + // Three older blocks (alternating kinds so they do not merge): with the + // two retained blocks they overflow the cap (2 + 3 > 4), but the page + // alone is below the cap, so it is rejected atomically into the + // re-openable latch rather than partially prepended. sdkMocks.getSessionTranscriptPage.mockResolvedValue({ v: 1, sessionId: session.sessionId, events: [ - replayEvent(0, 'older prompt'), - replayEvent(1, 'older answer', undefined, 'agent_message_chunk'), + replayEvent(10, 'older prompt'), + replayEvent(11, 'older answer', undefined, 'agent_message_chunk'), + replayEvent(12, 'older follow-up'), ], hasMore: false, }); @@ -13836,12 +13841,13 @@ describe('DaemonSessionProvider', () => { return null; } - // maxBlocks 2 leaves headroom over the single retained replay block so - // the load actually proceeds and exercises the atomic page rejection. + // maxBlocks 4 keeps the 3-block page below the cap (so the rejection is + // count-based, not the terminal impossible branch) while the merged total + // still overflows the cap. await renderWithProvider(, { autoConnect: true, historyPageSize: 25, - maxBlocks: 2, + maxBlocks: 4, }); await act(async () => { await history?.loadMore(); @@ -13853,7 +13859,10 @@ describe('DaemonSessionProvider', () => { loading: false, capacityReached: true, }); - expect(blocks).toMatchObject([{ kind: 'user', text: 'recent prompt' }]); + expect(blocks).toMatchObject([ + { kind: 'user', text: 'replay prompt' }, + { kind: 'assistant', text: 'replay answer' }, + ]); }); it('uses a full load for a legacy controlled clientId rebind', async () => { diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index a8aea4bb602..1c307507985 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -23,6 +23,7 @@ import { createDaemonTranscriptStore, estimateDaemonTranscriptBlockBytes, extractServerTimestamp, + isTrimmedToolBlockId, matchTurnEvent, normalizeDaemonEvent, type CreateSessionRequest, @@ -31,6 +32,7 @@ import { type DaemonTranscriptBlock, type DaemonTranscriptState, type DaemonTranscriptStore, + type DaemonTranscriptTruncationDetail, type DaemonTurnCompleteData, type DaemonUiEvent, } from '@qwen-code/sdk/daemon'; @@ -312,6 +314,15 @@ function materializeTranscriptHistory( pageBytes += estimateDaemonTranscriptBlockBytes(block); } const pageBlocks = history.blocks.length; + // `impossible` must be evaluated across BOTH dimensions, regardless of + // which branch rejects: a page that alone fills the whole block window can + // never be admitted (an anchored window always retains at least one block), + // and likewise for the byte budget. Equality is already impossible, hence + // `>=`. A page rejected by one dimension but impossible in the other would + // route to the re-openable latch whose re-open gate is then unsatisfiable — + // terminal either way. + const impossible = + pageBlocks >= maxBlocks || pageBytes >= current.maxRetainedBytes; // Count admission: an over-count merge stays untrimmed while the session // is idle, and the next live trim evicts the freshly prepended oldest // records, which the exclusive pagination anchor can never re-fetch — a @@ -322,9 +333,7 @@ function materializeTranscriptHistory( reason: 'count', pageBlocks, pageBytes, - // A page that alone exceeds the whole block window can never be - // admitted, in any occupancy state. - impossible: pageBlocks > maxBlocks, + impossible, }; } // Byte-budget admission: same silent-gap hazard as the count cap — an @@ -335,9 +344,7 @@ function materializeTranscriptHistory( reason: 'bytes', pageBlocks, pageBytes, - // A page that alone exceeds the whole byte budget can never be - // admitted, even into an empty window. - impossible: pageBytes > current.maxRetainedBytes, + impossible, }; } return { @@ -356,15 +363,39 @@ function applyTranscriptHistory( current: DaemonTranscriptState, history: TranscriptHistoryMaterialization, ): DaemonTranscriptState { + // A page-resurrected real block mapping must win over the current window's + // TRIMMED sentinel for the same callId — otherwise the resurrected block is + // orphaned and every later live update for that tool hits the sentinel + // branch (a false "output trimmed" error block plus dropped updates). + // Real-vs-real collisions cannot occur (the recordId dedup filter drops + // already-displayed records before materialization). + const toolBlockByCallId: Record = { + ...history.toolBlockByCallId, + }; + for (const [callId, blockId] of Object.entries(current.toolBlockByCallId)) { + if ( + isTrimmedToolBlockId(blockId) && + toolBlockByCallId[callId] !== undefined + ) { + continue; + } + toolBlockByCallId[callId] = blockId; + } + // A resurrected tool is live content again; clear its trimmed-notification + // flag so a future re-trim reports it once instead of staying silent. + const trimmedToolNotificationByCallId: Record = { + ...current.trimmedToolNotificationByCallId, + }; + for (const callId of Object.keys(history.toolBlockByCallId)) { + delete trimmedToolNotificationByCallId[callId]; + } return { ...current, blocks: [...history.blocks, ...current.blocks], retainedBytes: current.retainedBytes + history.retainedBytes, nextOrdinal: history.nextOrdinal, - toolBlockByCallId: { - ...history.toolBlockByCallId, - ...current.toolBlockByCallId, - }, + toolBlockByCallId, + trimmedToolNotificationByCallId, permissionBlockByRequestId: { ...history.permissionBlockByRequestId, ...current.permissionBlockByRequestId, @@ -678,12 +709,42 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { // Trimming evicts oldest-first, so it can remove the very record // the exclusive `beforeRecordId` anchor points at; the daemon // never returns the anchor itself, so the evicted stretch would - // become unreachable. Re-anchor to the oldest retained record. - if (detail.oldestRetainedRecordId !== undefined) { - history.beforeRecordId = detail.oldestRetainedRecordId; + // become unreachable. Re-anchor to the oldest retained record and + // atomically drop a stale cursor (loadMore prefers cursor over + // beforeRecordId, and a cursor-addressed position can never be + // re-based after the blocks it points past are evicted). A rewind + // (`evictedOldest === false`) drops the newest blocks and leaves + // the oldest anchor intact, so it must not trigger re-anchoring. + if (detail.evictedOldest !== false) { + if (detail.oldestRetainedRecordId !== undefined) { + history.beforeRecordId = detail.oldestRetainedRecordId; + history.cursor = undefined; + } else { + // Re-anchor uncomputable — no retained block carries a + // recordId. The current anchor points at an evicted record the + // exclusive pagination contract can never return again; fail + // closed instead of offering an affordance that skips the + // evicted band. + history.beforeRecordId = undefined; + history.cursor = undefined; + if (history.hasMore) { + history.hasMore = false; + setTranscriptHistoryState({ + hasMore: false, + loading: history.loading, + capacityReached: history.capacityReached, + paginationError: history.paginationError, + }); + } + } + } + // Oldest-first eviction can invalidate an in-flight page's anchor; + // bump the generation so the stale page is dropped on resolve. A + // rewind leaves the anchor band untouched, so in-flight pages stay + // valid and must not be dropped. + if (detail.evictedOldest !== false) { + paginationGenerationRef.current += 1; } - // Any eviction can invalidate an in-flight page's anchor. - paginationGenerationRef.current += 1; if (history.capacityReached) { // Eviction freed retention capacity, so the page rejected at the // latch may fit now — re-open the load-older affordance, but @@ -1801,6 +1862,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { (group) => group.transcript, ); let replayExceededCapacity = false; + let replayTrimmed = false; let replayTrimmedAnchor: string | undefined; const rebuildReplay = repairingEpisode !== undefined || @@ -1818,12 +1880,21 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { repairingEpisode && markerStillVisible ? repairingEpisode.checkpoint.maxBlocks : maxBlocks; - let replayTrimmed = false; + const observeReplayTrim = ( + detail: DaemonTranscriptTruncationDetail, + ) => { + if (detail.kind === 'blocks') replayTrimmed = true; + }; + // Both rebuild branches can trim (count cap or byte budget), so + // both observe it — a marker-visible repair seeded from the + // checkpoint is just as able to evict the pagination anchor as + // an ordinary rebuild. const replayStore = createDaemonTranscriptStore( repairingEpisode && markerStillVisible ? { ...repairingEpisode.checkpoint, maxBlocks: replayMaxBlocks, + onTruncation: observeReplayTrim, } : { maxBlocks: replayMaxBlocks, @@ -1837,9 +1908,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { // The count cap and the default byte budget can both // evict mid-rebuild; observe either so the pagination // anchor and capacity indicator reconcile below. - onTruncation: (detail) => { - if (detail.kind === 'blocks') replayTrimmed = true; - }, + onTruncation: observeReplayTrim, }, ); let nextCheckpoint: DaemonTranscriptState | undefined; @@ -1859,9 +1928,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { // A rebuild trim (count cap or byte budget) evicted older // blocks; a merely saturated window leaves no in-store room // for pagination either way — surface capacityReached for both. + // Repair rebuilds reconcile too: they evict the anchor just as + // an ordinary rebuild does. replayExceededCapacity = - repairingEpisode === undefined && - (replayTrimmed || replayState.blocks.length >= maxBlocks); + replayTrimmed || replayState.blocks.length >= replayMaxBlocks; if (replayExceededCapacity) { // The pre-trim anchor can sit inside the trimmed stretch; // re-anchor below to the oldest RETAINED record so @@ -1930,20 +2000,53 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ); } if (replayExceededCapacity) { - if (replayTrimmedAnchor !== undefined) { - transcriptHistoryRef.current.beforeRecordId = - replayTrimmedAnchor; + if (replayTrimmed) { + if (replayTrimmedAnchor !== undefined) { + transcriptHistoryRef.current.beforeRecordId = + replayTrimmedAnchor; + } else { + // The rebuild trimmed but no retained block carries a + // recordId, so a re-anchor to a retained record is + // uncomputable. Whether the pre-trim anchor + // (firstPersistedRecordId) is still safe depends on the + // evicted band: if the pre-trim replay carried several + // distinct recordIds, evicting every recordId-bearing block + // skips the non-oldest ones — a silent gap — so fail closed + // (drop the anchor; the affordance closes below). With zero + // or one recordId there is no such band, so the pre-trim + // anchor remains a valid exclusive-before base. + const replayRecordIds = new Set(); + for (const replayEvent of replayEvents) { + const recordId = getPersistedReplayRecordId(replayEvent); + if (recordId !== undefined) { + replayRecordIds.add(recordId); + } + } + if (replayRecordIds.size > 1) { + transcriptHistoryRef.current.beforeRecordId = undefined; + } + } + // A rebuild trim can evict the records a cursor points past; + // drop it so the beforeRecordId (re-anchored or pre-trim) is + // authoritative. + transcriptHistoryRef.current.cursor = undefined; } - // Trimmed replay content stays persisted daemon-side and is - // fetchable through pagination (for live sessions too), so - // keep the load-older affordance instead of dropping the - // content silently. + // Trimmed/saturated replay content stays persisted daemon-side + // and is fetchable through pagination, so keep the load-older + // affordance — but only while admission has real headroom: a + // positional anchor and byte-budget room. Without byte-budget + // headroom (e.g. a single oversized block whose estimate alone + // exceeds the budget) no page can ever be admitted, so offering + // the affordance would burn it on the first click with no + // terminal signal. + const postRebuild = store.getSnapshot(); const olderHistoryReachable = Array.isArray(capabilities?.features) && capabilities.features.includes( SESSION_TRANSCRIPT_PAGINATION_FEATURE, ) && - transcriptHistoryRef.current.beforeRecordId !== undefined; + transcriptHistoryRef.current.beforeRecordId !== undefined && + postRebuild.retainedBytes < postRebuild.maxRetainedBytes; transcriptHistoryRef.current.hasMore = olderHistoryReachable; transcriptHistoryRef.current.capacityReached = true; setTranscriptHistoryState({ From bda98d9518851166124a72dea9bc66256dde3f3e Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Wed, 19 Aug 2026 23:10:47 +0800 Subject: [PATCH 12/16] fix(webui): harden eviction/pagination reconciliation corners (#9303) Round-11 review fixes: - Mirror the sentinel-aware merge for permissionBlockByRequestId: a page-resurrected real mapping wins over the current window's TRIMMED_PERMISSION sentinel, so a resurrected pending permission can flip to resolved (export isTrimmedPermissionBlockId next to the tool helper). - Snap retention eviction to record boundaries: one persisted record fans out into several blocks sharing a sourceRecordIds entry, and block-granular trimming could land mid-record, orphaning the evicted sibling blocks from exclusive-before pagination (the anchor re-sets to the shared record while the dedup filter drops any page still advertising it). Advance the cut until it no longer lands inside a record, keeping the at-least-one-block floor. - Content-aware dedup for recordId-less blocks: the locally echoed user prompt carries no recordId, so recordId dedup is blind to it; once a trim leaves it as the oldest retained block, a load-older page returning that prompt's persisted record would render it twice and double-count bytes. Drop page user blocks matching a displayed recordId-less prompt by text. - Fail closed when the rebuild trimmed and no retained block carries a recordId: with any pre-trim recordId, that record's blocks were all evicted and exclusive-before pagination never returns the anchor record itself, so anchoring there leaves it silently unreachable. Drop the anchor (affordance closes) instead of paging from a stale anchor. Updates the bounded full-snapshot fallback test to the fail-closed outcome. --- packages/sdk-typescript/src/daemon/index.ts | 1 + .../sdk-typescript/src/daemon/ui/index.ts | 1 + .../src/daemon/ui/transcript.ts | 42 +++++++++++ .../session/DaemonSessionProvider.test.tsx | 19 ++--- .../daemon/session/DaemonSessionProvider.tsx | 75 +++++++++++++++---- 5 files changed, 111 insertions(+), 27 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 7999e62e34a..c8f37bfcd40 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -107,6 +107,7 @@ export { getSessionUpdatePayload, isDaemonUiSensitiveKey, isSubagentChildBlock, + isTrimmedPermissionBlockId, isTrimmedToolBlockId, normalizeDaemonEvent, redactDaemonUiSensitiveFields, diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index 9a5e10569dc..295a11b18cb 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -16,6 +16,7 @@ export { estimateDaemonTranscriptBlockBytes, formatBlockTimestamp, isSubagentChildBlock, + isTrimmedPermissionBlockId, isTrimmedToolBlockId, rebuildDaemonTranscriptBlockIndex, reduceDaemonTranscriptEvents, diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 755e90c4fd7..b5416561ffe 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -42,6 +42,18 @@ const TRIMMED_PERMISSION_BLOCK_ID = '__trimmed_permission_block__'; export function isTrimmedToolBlockId(blockId: string | undefined): boolean { return blockId === TRIMMED_TOOL_BLOCK_ID; } + +/** + * True when a `permissionBlockByRequestId` entry is the trimmed-block + * sentinel (the original block was evicted by retention trimming). Lets + * consumers merge a pagination-resurrected real mapping without letting the + * stale sentinel win. + */ +export function isTrimmedPermissionBlockId( + blockId: string | undefined, +): boolean { + return blockId === TRIMMED_PERMISSION_BLOCK_ID; +} const MAX_TEXT_BLOCK_LENGTH = 100_000; const TEXT_TRUNCATED_SUFFIX = '\n[truncated]\n'; const MAX_CLONE_DEPTH = 16; @@ -1475,6 +1487,17 @@ function cloneTranscriptState( return next; } +function sharesSourceRecordId( + a: DaemonTranscriptBlock, + b: DaemonTranscriptBlock, +): boolean { + const aIds = a.sourceRecordIds; + const bIds = b.sourceRecordIds; + if (!aIds?.length || !bIds?.length) return false; + const set = new Set(aIds); + return bIds.some((recordId) => set.has(recordId)); +} + function trimTranscriptState( state: DaemonTranscriptState, ): DaemonTranscriptState { @@ -1496,6 +1519,25 @@ function trimTranscriptState( bytes -= estimateBlockBytes(state.blocks[removeCount]!); removeCount += 1; } + // Snap the cut to record boundaries: one persisted record fans out into + // several blocks sharing a sourceRecordIds entry, and trimming is + // block-granular, so the boundary can land mid-record. A partially evicted + // record is unrecoverable from both directions — exclusive-before + // pagination anchored at the shared record never returns the evicted + // sibling blocks, and the recordId dedup filter drops any later page that + // still advertises the recordId. Advance the cut until it no longer lands + // inside a record, keeping the at-least-one-block floor. + while ( + removeCount > 0 && + removeCount < state.blocks.length - 1 && + sharesSourceRecordId( + state.blocks[removeCount - 1]!, + state.blocks[removeCount]!, + ) + ) { + bytes -= estimateBlockBytes(state.blocks[removeCount]!); + removeCount += 1; + } // Nothing evictable (e.g. one oversized block): skip the callback and // rebuild. Firing `kind: 'blocks'` with zero removals records a false // truncation and churns snapshot identity on every dispatch. diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 389eac3bacb..52ce599e886 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -5288,17 +5288,14 @@ describe('DaemonSessionProvider', () => { expect(blocks.length).toBeLessThanOrEqual(3); expect(JSON.stringify(blocks)).not.toContain('partial tail'); - expect(history?.hasMore).toBe(true); - await act(async () => history?.loadMore()); - expect(sdkMocks.getSessionTranscriptPage).toHaveBeenNthCalledWith( - 2, - initialSession.sessionId, - { - beforeRecordId: 'record-fresh-anchor', - limit: 100, - clientId: initialSession.clientId, - }, - ); + // R11-12: the bounded rebuild evicts the fresh snapshot's anchor record + // (record-fresh-anchor), and no retained block carries a recordId, so the + // re-anchor is uncomputable. Anchoring to the evicted record would leave + // it silently unreachable (exclusive-before never returns the anchor), so + // the affordance fails closed instead of paging from a stale anchor. + expect(history?.hasMore).toBe(false); + expect(history?.capacityReached).toBe(true); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledTimes(1); }); it.each([ diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 1c307507985..8817f1ced39 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -23,6 +23,7 @@ import { createDaemonTranscriptStore, estimateDaemonTranscriptBlockBytes, extractServerTimestamp, + isTrimmedPermissionBlockId, isTrimmedToolBlockId, matchTurnEvent, normalizeDaemonEvent, @@ -289,6 +290,23 @@ function materializeTranscriptHistory( displayedRecordIds.add(recordId); } } + // Secondary content-aware dedup for blocks that carry no recordId — the + // locally echoed user prompt, which `suppressOwnUserEcho` keeps from ever + // unioning the daemon's recordId-stamped echo. RecordId dedup is blind to + // it, so once a trim leaves it as the oldest retained block, a load-older + // page returning that same prompt's persisted record would materialize a + // second user block and double-count it. Collect such prompts' text and + // drop matching page blocks below. + const localEchoUserTexts = new Set(); + for (const block of current.blocks) { + if (block.kind !== 'user' || (block.sourceRecordIds?.length ?? 0) > 0) { + continue; + } + const text = (block as { text?: string }).text; + if (text !== undefined && text !== '') { + localEchoUserTexts.add(text); + } + } const freshEvents = displayedRecordIds.size === 0 ? events @@ -309,11 +327,22 @@ function materializeTranscriptHistory( }); historyStore.dispatch(freshEvents); const history = historyStore.getSnapshot(); + // Drop page user blocks that duplicate a displayed recordId-less local echo + // prompt (matched by text). They are the same prompt already rendered, and + // keeping them would render it twice and double-count retained bytes. + const pageBlockList = + localEchoUserTexts.size === 0 + ? history.blocks + : history.blocks.filter((block) => { + if (block.kind !== 'user') return true; + const text = (block as { text?: string }).text; + return text === undefined || !localEchoUserTexts.has(text); + }); let pageBytes = 0; - for (const block of history.blocks) { + for (const block of pageBlockList) { pageBytes += estimateDaemonTranscriptBlockBytes(block); } - const pageBlocks = history.blocks.length; + const pageBlocks = pageBlockList.length; // `impossible` must be evaluated across BOTH dimensions, regardless of // which branch rejects: a page that alone fills the whole block window can // never be admitted (an anchored window always retains at least one block), @@ -350,7 +379,7 @@ function materializeTranscriptHistory( return { admitted: true, materialization: { - blocks: history.blocks, + blocks: pageBlockList, nextOrdinal: history.nextOrdinal, retainedBytes: pageBytes, toolBlockByCallId: history.toolBlockByCallId, @@ -389,6 +418,24 @@ function applyTranscriptHistory( for (const callId of Object.keys(history.toolBlockByCallId)) { delete trimmedToolNotificationByCallId[callId]; } + // Same sentinel-aware merge for permission blocks: a page-resurrected real + // mapping must win over the current window's TRIMMED_PERMISSION sentinel, or + // a resurrected pending permission never flips to resolved (the permission + // upsert/resolve paths early-return on the sentinel). + const permissionBlockByRequestId: Record = { + ...history.permissionBlockByRequestId, + }; + for (const [requestId, blockId] of Object.entries( + current.permissionBlockByRequestId, + )) { + if ( + isTrimmedPermissionBlockId(blockId) && + permissionBlockByRequestId[requestId] !== undefined + ) { + continue; + } + permissionBlockByRequestId[requestId] = blockId; + } return { ...current, blocks: [...history.blocks, ...current.blocks], @@ -396,10 +443,7 @@ function applyTranscriptHistory( nextOrdinal: history.nextOrdinal, toolBlockByCallId, trimmedToolNotificationByCallId, - permissionBlockByRequestId: { - ...history.permissionBlockByRequestId, - ...current.permissionBlockByRequestId, - }, + permissionBlockByRequestId, }; } @@ -2007,14 +2051,13 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { } else { // The rebuild trimmed but no retained block carries a // recordId, so a re-anchor to a retained record is - // uncomputable. Whether the pre-trim anchor - // (firstPersistedRecordId) is still safe depends on the - // evicted band: if the pre-trim replay carried several - // distinct recordIds, evicting every recordId-bearing block - // skips the non-oldest ones — a silent gap — so fail closed - // (drop the anchor; the affordance closes below). With zero - // or one recordId there is no such band, so the pre-trim - // anchor remains a valid exclusive-before base. + // uncomputable. If the pre-trim replay carried any recordId + // at all, that record's blocks were all evicted, and + // exclusive `beforeRecordId` pagination never returns the + // anchor record itself — anchoring there would leave that + // record silently unreachable. Fail closed (drop the anchor; + // the affordance closes below). With zero pre-trim recordIds + // there is no anchor to drop either way. const replayRecordIds = new Set(); for (const replayEvent of replayEvents) { const recordId = getPersistedReplayRecordId(replayEvent); @@ -2022,7 +2065,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { replayRecordIds.add(recordId); } } - if (replayRecordIds.size > 1) { + if (replayRecordIds.size >= 1) { transcriptHistoryRef.current.beforeRecordId = undefined; } } From cec40b87f34f0ab37499b6b267acb11fb9bf4b9d Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Thu, 20 Aug 2026 08:00:57 +0800 Subject: [PATCH 13/16] fix(webui): close round-12 retention/pagination reconciliation criticals (#9303) Round-12 review fixes: - Scope the local-echo text dedup to the boundary pair (R12-1): the recordId-less echo can only collide with the page's newest user block, the one adjacent to the window's oldest block. Keying on text window-wide instead dropped distinct older prompts sent twice ("yes", a retry), permanently orphaning their assistant replies. Compare only the window's oldest recordId-less echo against the page's newest user block. - Gate observeReplayTrim on evictedOldest !== false (R12-2): a rewind also fires the 'blocks' truncation signal but drops the newest blocks and leaves the oldest pagination anchor valid, so it must not latch the capacity/re-anchor path during a capped rebuild (same gate the live store's onTruncation handler already uses). - Add the pagination generation guard to the loadMore catch block (R12-10): the success path already drops a page when a trim re-anchored mid-fetch, but the catch restored hasMore unconditionally, reviving the load-older affordance in the anchor-less state the fail-closed branch just closed. Leave the fail-closed state intact when the generation changed. - Back the record-boundary snap off the floor (R12-21): when byte pressure evicts to the last block, the forward snap's at-least-one-block floor stopped it from escaping a mid-record cut between the final two sibling blocks. Re-retain the penultimate block when the floor-pinned pair shares a record, trading at most one extra retained block against the budget to keep the record whole. Adds a reducer regression test. --- .../src/daemon/ui/transcript.ts | 19 +++++ .../sdk-typescript/test/unit/daemonUi.test.ts | 43 +++++++++++ .../daemon/session/DaemonSessionProvider.tsx | 76 +++++++++++++------ 3 files changed, 114 insertions(+), 24 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index f688de587dd..bd57112ec8c 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -1626,6 +1626,25 @@ function trimTranscriptState( bytes -= estimateBlockBytes(state.blocks[removeCount]!); removeCount += 1; } + // The forward snap can be pinned one block short by the floor: the byte + // loop evicts down to the last block and the snap's `removeCount < len - 1` + // bound stops there even when the evicted penultimate block shares a record + // with the surviving one — a mid-record cut the snap detects but cannot fix + // by advancing. Back the cut off the floor instead, re-retaining that block + // so the record stays whole. This trades at most one extra retained block + // against the budget, the same "budget + one worst-case block" ceiling the + // byte loop above already documents. + if ( + removeCount > 0 && + removeCount === state.blocks.length - 1 && + sharesSourceRecordId( + state.blocks[removeCount - 1]!, + state.blocks[removeCount]!, + ) + ) { + removeCount -= 1; + bytes += estimateBlockBytes(state.blocks[removeCount]!); + } // Nothing evictable (e.g. one oversized block): skip the callback and // rebuild. Firing `kind: 'blocks'` with zero removals records a false // truncation and churns snapshot identity on every dispatch. diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index d10575227f8..1bec5ecbc31 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -1252,6 +1252,49 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(state.retainedBytes).toBe(expected); }); + it('backs the record-boundary snap off the floor instead of cutting mid-record (R12-21)', () => { + // One persisted record can fan out into several blocks. When byte pressure + // evicts down to the last block, the forward snap's at-least-one-block + // floor stops it from advancing — even when the evicted penultimate block + // shares the record with the survivor, which would ship a mid-record cut. + // The snap must back off the floor and keep the record whole instead. + const large = 'x'.repeat(100_000); + let state = createDaemonTranscriptState({ + maxBlocks: 100, + // Fits one ~100 KB block but not both, so the byte loop evicts to the + // floor (one block left) and the snap is pinned there. + maxRetainedBytes: 150_000, + now: 1, + }); + for (const toolCallId of ['tool-a', 'tool-b']) { + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId, + title: `Tool ${toolCallId}`, + status: 'completed', + rawOutput: large, + sourceRecordIds: ['record-x'], + }, + ], + { now: 2 }, + ); + } + + // Both siblings of record-x stay: evicting only tool-a would leave a + // mid-record cut that exclusive-before pagination can never re-fetch. + const keptToolCallIds = state.blocks.map( + (block) => (block as { toolCallId?: string }).toolCallId, + ); + expect(keptToolCallIds).toEqual(['tool-a', 'tool-b']); + expect(state.blocks).toHaveLength(2); + for (const block of state.blocks) { + expect(block.sourceRecordIds).toContain('record-x'); + } + }); + it('keeps the retention estimate current when a tool payload is replaced', () => { let state = createDaemonTranscriptState({ maxBlocks: 10, diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index bbeffe29768..cc9cde4ca91 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -304,18 +304,18 @@ function materializeTranscriptHistory( // unioning the daemon's recordId-stamped echo. RecordId dedup is blind to // it, so once a trim leaves it as the oldest retained block, a load-older // page returning that same prompt's persisted record would materialize a - // second user block and double-count it. Collect such prompts' text and - // drop matching page blocks below. - const localEchoUserTexts = new Set(); - for (const block of current.blocks) { - if (block.kind !== 'user' || (block.sourceRecordIds?.length ?? 0) > 0) { - continue; - } - const text = (block as { text?: string }).text; - if (text !== undefined && text !== '') { - localEchoUserTexts.add(text); - } - } + // second user block and double-count it. The collision is strictly a + // boundary pair — the window's oldest block (the echo) against the page's + // newest user block (that same prompt's persisted record, adjacent to the + // window) — so only that pair is compared below. Keying on text window-wide + // would instead drop DISTINCT older prompts the user happened to send twice + // ("yes", a retry), permanently orphaning their assistant replies. + const oldestRetainedBlock = current.blocks[0]; + const boundaryEchoText = + oldestRetainedBlock?.kind === 'user' && + (oldestRetainedBlock.sourceRecordIds?.length ?? 0) === 0 + ? ((oldestRetainedBlock as { text?: string }).text ?? '') + : ''; const freshEvents = displayedRecordIds.size === 0 ? events @@ -336,17 +336,23 @@ function materializeTranscriptHistory( }); historyStore.dispatch(freshEvents); const history = historyStore.getSnapshot(); - // Drop page user blocks that duplicate a displayed recordId-less local echo - // prompt (matched by text). They are the same prompt already rendered, and - // keeping them would render it twice and double-count retained bytes. - const pageBlockList = - localEchoUserTexts.size === 0 - ? history.blocks - : history.blocks.filter((block) => { - if (block.kind !== 'user') return true; - const text = (block as { text?: string }).text; - return text === undefined || !localEchoUserTexts.has(text); - }); + // Drop the page's newest user block only when it duplicates the window's + // oldest recordId-less echo (the boundary pair). A same-text user block + // deeper in older history is a distinct prompt and must survive. + let pageBlockList = history.blocks; + if (boundaryEchoText !== '') { + for (let i = history.blocks.length - 1; i >= 0; i -= 1) { + const block = history.blocks[i]; + if (block?.kind !== 'user') continue; + if ((block as { text?: string }).text === boundaryEchoText) { + pageBlockList = [ + ...history.blocks.slice(0, i), + ...history.blocks.slice(i + 1), + ]; + } + break; + } + } let pageBytes = 0; for (const block of pageBlockList) { pageBytes += estimateDaemonTranscriptBlockBytes(block); @@ -1946,7 +1952,13 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const observeReplayTrim = ( detail: DaemonTranscriptTruncationDetail, ) => { - if (detail.kind === 'blocks') replayTrimmed = true; + // A rewind also fires `kind: 'blocks'` but with + // `evictedOldest: false` — it drops the NEWEST blocks and + // leaves the oldest pagination anchor valid, so it must not + // latch the capacity/re-anchor path (same gate as the live + // store's onTruncation handler above). + if (detail.kind === 'blocks' && detail.evictedOldest !== false) + replayTrimmed = true; }; // Both rebuild branches can trim (count cap or byte budget), so // both observe it — a marker-visible repair seeded from the @@ -3636,6 +3648,22 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ) { return; } + if (paginationGenerationRef.current !== fetchPaginationGeneration) { + // A retention trim re-anchored — or the fail-closed branch dropped — + // the pagination anchor while this fetch was in flight. Restoring + // `hasMore` here would revive the load-older affordance in the + // anchor-less state the fail-closed branch just closed, and the next + // fetch (no cursor, no beforeRecordId) would default to the oldest + // page and corrupt the anchor. Leave the fail-closed state intact. + history.loading = false; + setTranscriptHistoryState({ + hasMore: history.hasMore, + loading: false, + capacityReached: history.capacityReached, + paginationError: history.paginationError, + }); + return; + } const retryable = !terminalFailure && (!(error instanceof DaemonHttpError) || From ca93bb01decd1a051e2e36ebfa3f7ee0eb89d9aa Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Thu, 20 Aug 2026 17:51:33 +0800 Subject: [PATCH 14/16] fix(webui): close round-13 retention/pagination reconciliation criticals (#9303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-13 review fixes: - Count binary payloads in the retention estimate: Blob/File, ArrayBuffer, and typed-array/DataView values carry their content in non-enumerable slots, so the record walk only charged the fixed object overhead — an 8 MiB attachment counted as ~64 bytes and the byte budget never fired for media-heavy transcripts (the OOM class this PR targets). Charge Blob.size / byteLength for these shapes. Adds a budget-eviction regression test. - Loop the record-boundary floor back-off (R12-21 follow-up): the single back-off only re-retained one block, still cutting a 3+-sibling record mid-record. Loop while the boundary pair shares a record, keeping the record whole; the removeCount===0 guard retains the whole window when nothing is evictable. Grows the snap test to a 3-block record. - Offer the load-older affordance on a live trim for sessions that loaded unlatched: eviction re-anchored beforeRecordId but the re-open path only ran under capacityReached, so a session whose replay fit under the caps could never surface older persisted history after the window trimmed. Mirror the replay path's olderHistoryReachable gates in the re-anchor branch. - Key the boundary-echo dedup on echo presence and fold media into the comparison (R12-1 follow-up): an empty-text gate skipped image/file-only echoes entirely, and a naive empty-text match would collapse two distinct media-only prompts. Compare text + image/file counts for the boundary pair. --- .../src/daemon/ui/transcript.ts | 30 ++++--- .../sdk-typescript/test/unit/daemonUi.test.ts | 82 +++++++++++++++++++ .../daemon/session/DaemonSessionProvider.tsx | 53 ++++++++++-- 3 files changed, 148 insertions(+), 17 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 5184400ed8c..469e66952cf 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -1650,17 +1650,19 @@ function trimTranscriptState( bytes -= estimateBlockBytes(state.blocks[removeCount]!); removeCount += 1; } - // The forward snap can be pinned one block short by the floor: the byte - // loop evicts down to the last block and the snap's `removeCount < len - 1` - // bound stops there even when the evicted penultimate block shares a record - // with the surviving one — a mid-record cut the snap detects but cannot fix - // by advancing. Back the cut off the floor instead, re-retaining that block - // so the record stays whole. This trades at most one extra retained block - // against the budget, the same "budget + one worst-case block" ceiling the - // byte loop above already documents. - if ( + // The forward snap can be pinned by the floor: the byte loop evicts down to + // the last block and the snap's `removeCount < len - 1` bound stops there even + // when the evicted tail shares a record with the surviving block — a + // mid-record cut the snap detects but cannot fix by advancing. Back the cut + // off the floor instead, re-retaining siblings while the boundary pair still + // shares a record so the record stays whole. A single record can fan out into + // several contiguous blocks, so this loops; when nothing is left to evict the + // `removeCount === 0` guard below keeps the whole window rather than cutting + // mid-record. This trades at most one extra retained record against the + // budget, extending the "budget + one worst-case block" ceiling the byte loop + // above already documents. + while ( removeCount > 0 && - removeCount === state.blocks.length - 1 && sharesSourceRecordId( state.blocks[removeCount - 1]!, state.blocks[removeCount]!, @@ -2035,6 +2037,14 @@ function estimateRetainedBytes(value: unknown, depth = 0): number { if (depth > MAX_CLONE_DEPTH) return 0; if (typeof value === 'string') return value.length * 2; if (typeof value === 'number' || typeof value === 'boolean') return 16; + // Binary payloads (Blob/File, ArrayBuffer, typed-array/DataView views) carry + // their content in non-enumerable internal slots, so the record walk below + // would only charge the fixed object overhead for them. Charge by real binary + // size instead, or media-heavy transcripts never trip the budget — the OOM + // class this budget exists to stop. + if (typeof Blob !== 'undefined' && value instanceof Blob) return value.size; + if (value instanceof ArrayBuffer) return value.byteLength; + if (ArrayBuffer.isView(value)) return value.byteLength; if (Array.isArray(value)) { let total = 32; for (const entry of value) { diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 47dc3d874d6..b40d47231e9 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -1295,6 +1295,88 @@ describe('daemon UI normalizer and transcript reducer', () => { } }); + it('backs the record-boundary snap off the floor across a 3-block record (R12-21)', () => { + // A single record fans out into 3+ contiguous blocks; the floor back-off + // must loop (not stop after one block) or the tail is still cut mid-record. + const large = 'x'.repeat(100_000); + let state = createDaemonTranscriptState({ + maxBlocks: 100, + maxRetainedBytes: 150_000, + now: 1, + }); + for (const toolCallId of ['tool-a', 'tool-b', 'tool-c']) { + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId, + title: `Tool ${toolCallId}`, + status: 'completed', + rawOutput: large, + sourceRecordIds: ['record-x'], + }, + ], + { now: 2 }, + ); + } + // All three record-x siblings stay — the loop re-retains tool-a and tool-b + // after the byte loop pins the cut to the floor. + expect( + state.blocks.map( + (block) => (block as { toolCallId?: string }).toolCallId, + ), + ).toEqual(['tool-a', 'tool-b', 'tool-c']); + }); + + it('counts Blob-backed file payloads against the retention budget', () => { + // Blob/File payloads live in non-enumerable internal slots, so a plain + // record walk would only charge the fixed object overhead — an 8 MiB file + // would count as ~64 bytes and the byte budget would never fire. Charge + // their real size instead. + const eightMiB = new Blob([new Uint8Array(8 * 1024 * 1024)]); + let state = createDaemonTranscriptState({ + maxBlocks: 100, + maxRetainedBytes: 100_000_000, + now: 1, + }); + state = appendLocalUserTranscriptMessage(state, '', { + files: [ + { + name: 'big.bin', + mimeType: 'application/octet-stream', + data: eightMiB, + }, + ], + }); + expect(state.retainedBytes).toBeGreaterThanOrEqual(8 * 1024 * 1024); + }); + + it('evicts Blob-backed file blocks that cross the retention budget', () => { + const fourMiB = new Blob([new Uint8Array(4 * 1024 * 1024)]); + let state = createDaemonTranscriptState({ + maxBlocks: 100, + maxRetainedBytes: 9 * 1024 * 1024, + now: 1, + }); + for (let index = 0; index < 4; index += 1) { + state = appendLocalUserTranscriptMessage(state, `attach ${index}`, { + files: [ + { + name: `f${index}.bin`, + mimeType: 'application/octet-stream', + data: fourMiB, + }, + ], + }); + } + // 4 × 4 MiB = 16 MiB > 9 MiB budget: the byte trim must fire only because + // the Blob payloads are counted. Without it, retainedBytes would stay at + // the fixed record overhead and no block would be evicted. + expect(state.blocks.length).toBeLessThan(4); + expect(state.retainedBytes).toBeGreaterThan(4 * 1024 * 1024); + }); + it('keeps the retention estimate current when a tool payload is replaced', () => { let state = createDaemonTranscriptState({ maxBlocks: 10, diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index cc9cde4ca91..e0f8f475afc 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -310,12 +310,24 @@ function materializeTranscriptHistory( // window) — so only that pair is compared below. Keying on text window-wide // would instead drop DISTINCT older prompts the user happened to send twice // ("yes", a retry), permanently orphaning their assistant replies. + // The key must key on echo PRESENCE, not non-empty text: image/file-only + // prompts submit with empty text, so a `text !== ''` gate would skip their + // dedup and double-render the prompt. Fold media into the key (image/file + // counts) so two distinct media-only prompts at the boundary don't collapse. + const userBlockBoundaryKey = ( + block: DaemonTranscriptBlock | undefined, + ): string | undefined => { + if (block?.kind !== 'user') return undefined; + const text = (block as { text?: string }).text ?? ''; + const images = (block as { images?: unknown[] }).images?.length ?? 0; + const files = (block as { files?: unknown[] }).files?.length ?? 0; + return `${text}img:${images}file:${files}`; + }; const oldestRetainedBlock = current.blocks[0]; - const boundaryEchoText = - oldestRetainedBlock?.kind === 'user' && - (oldestRetainedBlock.sourceRecordIds?.length ?? 0) === 0 - ? ((oldestRetainedBlock as { text?: string }).text ?? '') - : ''; + const boundaryEchoKey = + (oldestRetainedBlock?.sourceRecordIds?.length ?? 0) === 0 + ? userBlockBoundaryKey(oldestRetainedBlock) + : undefined; const freshEvents = displayedRecordIds.size === 0 ? events @@ -340,11 +352,11 @@ function materializeTranscriptHistory( // oldest recordId-less echo (the boundary pair). A same-text user block // deeper in older history is a distinct prompt and must survive. let pageBlockList = history.blocks; - if (boundaryEchoText !== '') { + if (boundaryEchoKey !== undefined) { for (let i = history.blocks.length - 1; i >= 0; i -= 1) { const block = history.blocks[i]; if (block?.kind !== 'user') continue; - if ((block as { text?: string }).text === boundaryEchoText) { + if (userBlockBoundaryKey(block) === boundaryEchoKey) { pageBlockList = [ ...history.blocks.slice(0, i), ...history.blocks.slice(i + 1), @@ -788,6 +800,33 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { if (detail.oldestRetainedRecordId !== undefined) { history.beforeRecordId = detail.oldestRetainedRecordId; history.cursor = undefined; + // A live trim evicts oldest blocks that stay persisted + // daemon-side, so there is now fetchable content older than the + // re-set anchor. A session that loaded unlatched (hasMore=false, + // capacityReached=false) must surface that affordance, or the + // evicted band is unreachable until a reload. Mirror the replay + // path's olderHistoryReachable gates. + if (!history.capacityReached && !history.hasMore) { + const features = sessionCapabilitiesRef.current?.features; + const windowCaps = store.getSnapshot(); + const postTrimRetainedBytes = + detail.retainedBytes ?? windowCaps.retainedBytes; + const byteCap = + detail.maxRetainedBytes ?? windowCaps.maxRetainedBytes; + const olderHistoryReachable = + Array.isArray(features) && + features.includes(SESSION_TRANSCRIPT_PAGINATION_FEATURE) && + postTrimRetainedBytes < byteCap; + if (olderHistoryReachable) { + history.hasMore = true; + setTranscriptHistoryState({ + hasMore: true, + loading: false, + capacityReached: false, + paginationError: history.paginationError, + }); + } + } } else { // Re-anchor uncomputable — no retained block carries a // recordId. The current anchor points at an evicted record the From 0cc8d8177922e16530da162e4d947581386149e6 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Thu, 20 Aug 2026 23:50:18 +0800 Subject: [PATCH 15/16] fix(webui): close round-14 retention/pagination reconciliation criticals (#9303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-14 review fixes: - Clamp the trim floor to keep at least one block (R14-1): a non-positive or non-finite maxBlocks made the count floor evict the whole window, and the record snap then read one past the end of the block array, throwing on every dispatch. Treat maxBlocks < 1 / non-finite as 1 so the window always keeps a block. Adds a regression test. - Drop the rebuild-trim fail-closed anchor unconditionally (R14-2): scanning only the fresh replayEvents missed recordIds trimmed from the repair checkpoint in a marker-visible live-journal repair, leaving a stale anchor with the affordance still on. Mirror the live store's fail-closed branch — when no retained block carries a recordId any pre-trim anchor is stale, and dropping an already-undefined anchor is a no-op. - Grow the latched rejectedPage footprint by a re-anchoring trim's evicted band (R14-3): the daemon re-serves the evicted band on the next exclusive-before fetch, so the page is larger than latched; a stale footprint would churn fetch/reject or misclassify a now-larger page as terminal. The re-open gate now measures the grown page. Reworks the re-open test to the faithful daemon behavior (the latch stays closed once the evicted band re-joins the page). - Refuse anchor-less forced retries (R14-4): after a fail-closed trim drops both cursor and beforeRecordId, a forced retry fetched with neither anchor, and the daemon defaults that to the journal's oldest page — prepended below the window and re-stamping a bogus anchor. Return early until a later trim re-establishes an anchor. --- .../src/daemon/ui/transcript.ts | 11 +++- .../sdk-typescript/test/unit/daemonUi.test.ts | 18 ++++++ .../session/DaemonSessionProvider.test.tsx | 46 ++++++-------- .../daemon/session/DaemonSessionProvider.tsx | 61 +++++++++++++------ 4 files changed, 89 insertions(+), 47 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 469e66952cf..fe8832fd86c 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -1615,8 +1615,15 @@ function trimTranscriptState( ): DaemonTranscriptState { const overByteBudget = state.retainedBytes > state.maxRetainedBytes; if (state.blocks.length <= state.maxBlocks && !overByteBudget) return state; - // Count-based floor: keep at most the last `maxBlocks` blocks. - let removeCount = Math.max(0, state.blocks.length - state.maxBlocks); + // Count-based floor: keep at most the last `maxBlocks` blocks. Keep at least + // one block: a non-positive or non-finite maxBlocks is a degenerate input + // that must not evict the whole window (and would otherwise make the record + // snap below read one past the end of the block array). + const effectiveMaxBlocks = Math.max( + 1, + Number.isFinite(state.maxBlocks) ? state.maxBlocks : 1, + ); + let removeCount = Math.max(0, state.blocks.length - effectiveMaxBlocks); let bytes = state.retainedBytes; for (let i = 0; i < removeCount; i += 1) { bytes -= estimateBlockBytes(state.blocks[i]!); diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index b40d47231e9..66f1a189735 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -1377,6 +1377,24 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(state.retainedBytes).toBeGreaterThan(4 * 1024 * 1024); }); + it('tolerates a degenerate maxBlocks without crashing the trim (R14-1)', () => { + // maxBlocks is a public option with no validated lower bound. A + // non-positive value must not evict down to zero blocks — the record snap + // would then read one past the end of the block array and throw on every + // dispatch. Keep at least one block instead. + const store = createDaemonTranscriptStore({ maxBlocks: 0 }); + expect(() => + store.dispatch({ type: 'user.text.delta', text: 'survives' }), + ).not.toThrow(); + expect(store.getSnapshot().blocks).toHaveLength(1); + // A second dispatch must not throw either (the crash repeated on every + // dispatch before the floor clamp). + expect(() => + store.dispatch({ type: 'user.text.delta', text: 'still alive' }), + ).not.toThrow(); + expect(store.getSnapshot().blocks.length).toBeGreaterThanOrEqual(1); + }); + it('keeps the retention estimate current when a tool payload is replaced', () => { let state = createDaemonTranscriptState({ maxBlocks: 10, diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 0d36cc8f0bf..1d6d59aa1c1 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -13229,10 +13229,12 @@ describe('DaemonSessionProvider', () => { ); }); - it('re-anchors pagination and releases the latch once eviction frees room for the rejected page', async () => { - // A byte-budget rejection stores the rejected page's footprint; the - // eviction re-open must fire only once enough capacity has actually been - // freed for THAT page, then fetch against the re-anchored anchor. + it('grows the latched rejected-page footprint by a re-anchoring trim’s evicted band and keeps the latch closed', async () => { + // A byte-budget rejection stores the rejected page's footprint. A later + // re-anchoring eviction trim re-serves the evicted band on the next fetch, + // so the latched footprint must grow by that band; when the grown page no + // longer fits, the latch stays closed (no fetch/reject churn, no spurious + // terminal failure). sdkMocks.capabilities.mockResolvedValue({ workspaceCwd: '/mock-workspace', features: ['session_transcript_pagination'], @@ -13341,37 +13343,25 @@ describe('DaemonSessionProvider', () => { }); expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledTimes(1); - // Live growth crosses the budget; the trim evicts the big replay block, - // freeing more than the rejected page needs — the latch re-opens and the - // anchor moves to the oldest retained record. + // Live growth crosses the budget; the trim evicts record-1 and re-anchors + // to record-2. A faithful daemon re-serves the evicted record-1 on the next + // exclusive-before fetch (it is persisted and now before the anchor), so + // the re-anchored page is record-1 + record-a — larger than the latched + // footprint. The fix grows the latched footprint by the evicted band, so the + // re-open gate sees that grown page no longer fits and keeps the latch + // closed instead of re-opening into a fetch/reject churn (or misclassifying + // the now-larger page as a terminal failure). await act(async () => { liveGate.resolve(); await flushTranscriptDispatch(); }); expect(history).toMatchObject({ - hasMore: true, + hasMore: false, loading: false, - capacityReached: false, - }); - - await act(async () => { - await history?.loadMore(); - await flushPromises(); + capacityReached: true, + paginationError: false, }); - expect(sdkMocks.getSessionTranscriptPage).toHaveBeenNthCalledWith( - 2, - session.sessionId, - { - beforeRecordId: 'record-2', - limit: 25, - clientId: session.clientId, - }, - ); - expect(blocks.map((block) => block.sourceRecordIds?.[0])).toEqual([ - 'record-a', - 'record-2', - 'record-live-1', - ]); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledTimes(1); }); it('keeps the capacity latch closed on live trims when pagination is unavailable', async () => { diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index e0f8f475afc..fe8fc886d7d 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -800,6 +800,30 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { if (detail.oldestRetainedRecordId !== undefined) { history.beforeRecordId = detail.oldestRetainedRecordId; history.cursor = undefined; + // A re-anchoring trim invalidates a latched rejectedPage + // footprint: the daemon (exclusive-before, served from disk) + // re-serves the evicted band on the next fetch, so the page will + // be larger than latched. Grow the latched footprint by the + // evicted band so the re-open gate measures the page the + // re-anchored fetch actually gets — a stale (too-small) footprint + // would churn fetch/reject, or misclassify a now-larger page as + // terminal. `store.getSnapshot()` is still pre-trim here: the + // store swaps its state only after the reduce completes. + if (history.rejectedPage) { + const preTrim = store.getSnapshot(); + const postTrimBlockCount = + detail.blockCount ?? preTrim.blocks.length; + const postTrimRetainedBytes = + detail.retainedBytes ?? preTrim.retainedBytes; + history.rejectedPage = { + blocks: + history.rejectedPage.blocks + + Math.max(0, preTrim.blocks.length - postTrimBlockCount), + bytes: + history.rejectedPage.bytes + + Math.max(0, preTrim.retainedBytes - postTrimRetainedBytes), + }; + } // A live trim evicts oldest blocks that stay persisted // daemon-side, so there is now fetchable content older than the // re-set anchor. A session that loaded unlatched (hasMore=false, @@ -2121,23 +2145,15 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { } else { // The rebuild trimmed but no retained block carries a // recordId, so a re-anchor to a retained record is - // uncomputable. If the pre-trim replay carried any recordId - // at all, that record's blocks were all evicted, and - // exclusive `beforeRecordId` pagination never returns the - // anchor record itself — anchoring there would leave that - // record silently unreachable. Fail closed (drop the anchor; - // the affordance closes below). With zero pre-trim recordIds - // there is no anchor to drop either way. - const replayRecordIds = new Set(); - for (const replayEvent of replayEvents) { - const recordId = getPersistedReplayRecordId(replayEvent); - if (recordId !== undefined) { - replayRecordIds.add(recordId); - } - } - if (replayRecordIds.size >= 1) { - transcriptHistoryRef.current.beforeRecordId = undefined; - } + // uncomputable. Any pre-trim anchor points at an evicted + // record the exclusive pagination contract can never return + // again — drop it unconditionally, mirroring the live store's + // fail-closed branch. Scanning only the fresh replayEvents + // would miss recordIds trimmed from the repair checkpoint in + // a marker-visible live-journal repair, leaving a stale anchor + // with the affordance still on; when no recordId ever existed + // the anchor is already undefined, so the drop is a no-op. + transcriptHistoryRef.current.beforeRecordId = undefined; } // A rebuild trim can evict the records a cursor points past; // drop it so the beforeRecordId (re-anchored or pre-trim) is @@ -3509,6 +3525,17 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { if (options?.force !== true) { return; } + // A fail-closed trim can drop both anchors. With neither cursor nor + // beforeRecordId, the daemon defaults the request to the journal's + // oldest page, which would be prepended below the window and re-stamp + // a bogus anchor. Refuse to re-arm anchor-less; the affordance stays + // closed until a later trim re-establishes an anchor. + if ( + history.beforeRecordId === undefined && + history.cursor === undefined + ) { + return; + } // The failed page's cursor was never advanced, so clearing the // latched error retries that exact page. history.paginationError = false; From 1fc9980382f090247586c1bac6d9e3c26a4ece3d Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Fri, 21 Aug 2026 07:00:09 +0800 Subject: [PATCH 16/16] fix(sdk): integral-ize a fractional maxBlocks in the trim floor (#9303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-15 review fix (R15-1): the R14-1 degenerate-maxBlocks clamp floored non-positive and non-finite values to 1 but passed positive fractional values through unchanged, leaving removeCount fractional so the record-boundary snap indexed blocks[removeCount] one past the end and threw on later dispatches — the same crash class R14-1 was added to prevent, re-entering through a sibling input. Floor the clamped value so the window always trims by a whole block count. Math.floor is a no-op for the integer maxBlocks every in-repo caller passes. Extends the degenerate-maxBlocks regression test with a fractional case. --- .../sdk-typescript/src/daemon/ui/transcript.ts | 9 +++++---- .../sdk-typescript/test/unit/daemonUi.test.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index fe8832fd86c..1662368d8e2 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -1616,12 +1616,13 @@ function trimTranscriptState( const overByteBudget = state.retainedBytes > state.maxRetainedBytes; if (state.blocks.length <= state.maxBlocks && !overByteBudget) return state; // Count-based floor: keep at most the last `maxBlocks` blocks. Keep at least - // one block: a non-positive or non-finite maxBlocks is a degenerate input - // that must not evict the whole window (and would otherwise make the record - // snap below read one past the end of the block array). + // one block: a non-positive, non-finite, or fractional maxBlocks is a + // degenerate input that must not evict the whole window nor leave removeCount + // fractional (the record snap below indexes blocks[removeCount] and would + // read one past the end of the block array). const effectiveMaxBlocks = Math.max( 1, - Number.isFinite(state.maxBlocks) ? state.maxBlocks : 1, + Math.floor(Number.isFinite(state.maxBlocks) ? state.maxBlocks : 1), ); let removeCount = Math.max(0, state.blocks.length - effectiveMaxBlocks); let bytes = state.retainedBytes; diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 66f1a189735..6b14a4d2aeb 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -1393,6 +1393,22 @@ describe('daemon UI normalizer and transcript reducer', () => { store.dispatch({ type: 'user.text.delta', text: 'still alive' }), ).not.toThrow(); expect(store.getSnapshot().blocks.length).toBeGreaterThanOrEqual(1); + + // A positive fractional maxBlocks must also be integral-ized (R15-1): + // without the floor, removeCount went fractional and the record snap read a + // fractional index one past the end, throwing on later dispatches. + const fractionalStore = createDaemonTranscriptStore({ maxBlocks: 2.5 }); + for (let index = 0; index < 5; index += 1) { + expect(() => + fractionalStore.dispatch({ + type: 'user.text.delta', + text: `delta ${index}`, + }), + ).not.toThrow(); + } + expect(fractionalStore.getSnapshot().blocks.length).toBeGreaterThanOrEqual( + 1, + ); }); it('keeps the retention estimate current when a tool payload is replaced', () => {