Skip to content
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f908d9b
fix(web-shell): bound daemon transcript retention to stop renderer OO…
wenshao Aug 17, 2026
f7301e4
fix(review): address round-1 findings for transcript retention (#9303)
qwen-code-dev-bot Aug 17, 2026
2bf2cd4
Merge remote-tracking branch 'origin/main' into fix/web-shell-transcr…
qwen-code-dev-bot Aug 17, 2026
10d9cb8
Merge remote-tracking branch 'origin/main' into fix/web-shell-transcr…
wenshao Aug 17, 2026
ceb94ac
Merge remote-tracking branch 'wenshao/fix/web-shell-transcript-memory…
wenshao Aug 17, 2026
d36777e
fix(review): cap debug payloads at the producer for transcript retent…
qwen-code-dev-bot Aug 17, 2026
15f31bf
Merge branch 'fix/web-shell-transcript-memory-growth' of https://gith…
qwen-code-dev-bot Aug 17, 2026
1a4189d
Merge remote-tracking branch 'origin/main' into fix/web-shell-transcr…
wenshao Aug 17, 2026
199008c
fix(web-shell): slim consumeReplaySnapshot to fit the SDK browser bun…
wenshao Aug 17, 2026
e9d2752
Merge remote-tracking branch 'wenshao/fix/web-shell-transcript-memory…
wenshao Aug 17, 2026
a94c6ba
fix(web-shell): bound transcript retention in bytes, not just blocks …
wenshao Aug 17, 2026
55f9328
fix(review): close round-4 byte-budget accounting gaps in transcript …
qwen-code-dev-bot Aug 17, 2026
94c8668
fix(webui): reject history pages that overflow the transcript byte bu…
qwen-code-dev-bot Aug 18, 2026
0e640e7
fix(review): reconcile transcript eviction with the pagination anchor…
qwen-code-dev-bot Aug 18, 2026
dd0c95a
fix(webui): gate pagination re-open and drop stale in-flight pages (#…
wenshao Aug 18, 2026
c0f6c91
fix(webui): gate pagination re-open on admission headroom (#9303)
wenshao Aug 18, 2026
13a2419
Merge branch 'main' into fix/web-shell-transcript-memory-growth
qwen-code-dev-bot Aug 18, 2026
e23ca17
Merge remote-tracking branch 'origin/main' into fix/web-shell-transcr…
wenshao Aug 19, 2026
72fec63
fix(webui): reconcile pagination with eviction across rebuild and rew…
wenshao Aug 19, 2026
bda98d9
fix(webui): harden eviction/pagination reconciliation corners (#9303)
wenshao Aug 19, 2026
d38a3cb
Merge remote-tracking branch 'origin/main' into fix/web-shell-transcr…
wenshao Aug 19, 2026
cec40b8
fix(webui): close round-12 retention/pagination reconciliation critic…
wenshao Aug 20, 2026
9739124
Merge branch 'main' into fix/web-shell-transcript-memory-growth
qwen-code-dev-bot Aug 20, 2026
43ec3f5
Merge remote-tracking branch 'origin/main' into fix/web-shell-transcr…
wenshao Aug 20, 2026
8447f29
Merge remote branch 'wenshao/fix/web-shell-transcript-memory-growth' …
wenshao Aug 20, 2026
ca93bb0
fix(webui): close round-13 retention/pagination reconciliation critic…
wenshao Aug 20, 2026
0cc8d81
fix(webui): close round-14 retention/pagination reconciliation critic…
wenshao Aug 20, 2026
1fc9980
fix(sdk): integral-ize a fractional maxBlocks in the trim floor (#9303)
wenshao Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions packages/sdk-typescript/scripts/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,25 @@ const rootDir = join(__dirname, '..');
// Bumped from 195KB to 196KB for transient-vs-gone media hydration errors and
// the reference-only replay placeholder.
// Bumped from 196KB to 197KB for the workspace session live-state daemon
// surface (catalog version + live snapshot accessors) and immutable,
// identity-stable transcript block indexes used by browser renderers.
// surface (catalog version + live snapshot accessors), immutable,
// identity-stable transcript block indexes used by browser renderers, and the
// daemon transcript-retention work (replay-snapshot release + capped debug
// payloads, #9303) landing on top of the session media references bundle.
// Bumped from 197KB to 198KB for the unrecognized-diagnostic sidechannel
// (`unrecognizedDiagnostics` routing + selector, #8823).
// Bumped from 198KB to 199KB for persistent session attachment read/remove and
// binary resource hydration.
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 199 * 1024;
// Bumped from 199KB to 200KB for the retention byte budget (block byte
// estimation + budget-aware trimming) and backing-store-detached string caps
// (#9303 review round 3).
// Bumped from 200KB to 206KB for the pagination/eviction reconciliation and the
// #8823 × #9303 merge (#9303 review rounds 9-12): eviction-direction signal,
// rewind truncation callback, trimmed tool/permission sentinel helpers,
// record-boundary eviction snap, and the floor back-off — each bump budgeted
// its own delta in isolation, and the combined feature sets land here. The
// attachment read/remove + binary hydration feature that separately bumped
// main to 199KB merges within this headroom, so no further bump is needed.
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 206 * 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
Expand Down
21 changes: 20 additions & 1 deletion packages/sdk-typescript/src/daemon/DaemonSessionClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,13 @@ export class DaemonSessionClient {
readonly client: DaemonClient;
readonly session: DaemonSession;
readonly state: DaemonSessionState;
readonly replaySnapshot: DaemonReplaySnapshot;
/**
* 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.
*/
replaySnapshot: DaemonReplaySnapshot;
readonly replaySnapshotComplete: boolean;
readonly replayPartial: boolean;
readonly replayError: string | undefined;
Expand Down Expand Up @@ -405,6 +411,19 @@ export class DaemonSessionClient {
return this.lastSeenEpoch;
}

/**
* 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.replaySnapshot;
this.replaySnapshot = { compactedReplay: [], liveJournal: [] };
return snapshot;
}

setLastEventId(lastEventId: number | undefined): void {
this.lastSeenEventId = validateLastEventId(lastEventId);
}
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk-typescript/src/daemon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,16 @@ export {
daemonBlockToPlainText,
daemonToolPreviewToMarkdown,
daemonUiEventToTerminalText,
estimateDaemonTranscriptBlockBytes,
extractContentPart,
extractServerTimestamp,
formatBlockTimestamp,
getOutputText as getDaemonUiOutputText,
getSessionUpdatePayload,
isDaemonUiSensitiveKey,
isSubagentChildBlock,
isTrimmedPermissionBlockId,
isTrimmedToolBlockId,
isUnrecognizedDiagnosticReason,
normalizeDaemonEvent,
redactDaemonUiSensitiveFields,
Expand Down Expand Up @@ -159,6 +162,7 @@ export type {
DaemonTranscriptSidechannelState,
DaemonTranscriptState,
DaemonTranscriptStore,
DaemonTranscriptTruncationDetail,
DaemonUiAssistantDoneEvent,
DaemonUiAuthDeviceFlowAuthorizedEvent,
DaemonUiAuthDeviceFlowCancelledEvent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk-typescript/src/daemon/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ export { createDaemonToolPreview } from './toolPreview.js';
export {
appendLocalUserTranscriptMessage,
createDaemonTranscriptState,
estimateDaemonTranscriptBlockBytes,
formatBlockTimestamp,
isSubagentChildBlock,
isTrimmedPermissionBlockId,
isTrimmedToolBlockId,
rebuildDaemonTranscriptBlockIndex,
reduceDaemonTranscriptEvents,
selectApprovalMode,
Expand Down Expand Up @@ -88,6 +91,7 @@ export type {
DaemonTranscriptSidechannelState,
DaemonTranscriptState,
DaemonTranscriptStore,
DaemonTranscriptTruncationDetail,
DaemonUnrecognizedDiagnostic,
DaemonUnrecognizedDiagnosticReason,
// Chat-stream events
Expand Down
29 changes: 19 additions & 10 deletions packages/sdk-typescript/src/daemon/ui/normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
} from './types.js';
import { DAEMON_PLAN_TOOL_CALL_ID } from './types.js';
import {
capDetails,
getFirstString,
getOutputText,
getString,
Expand Down Expand Up @@ -59,7 +60,6 @@ const MCP_RESTART_REFUSED_REASONS = new Set<string>([
]);

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.';

Expand Down Expand Up @@ -407,7 +407,10 @@ function normalizeUnrecognizedEvent(
...base,
type: 'debug',
debugReason: 'unrecognized_event',
text: `${event.type} (unrecognized daemon event): ${stringifyRedactedJson(event.data)}`,
text: debugBlockText(
`${event.type} (unrecognized daemon event)`,
event.data,
),
Comment thread
wenshao marked this conversation as resolved.
},
];
}
Expand Down Expand Up @@ -768,7 +771,7 @@ function normalizeSessionUpdate(
...base,
type: 'debug',
debugReason: 'malformed_payload',
text: `session_update: ${stringifyRedactedJson(event.data)}`,
text: debugBlockText('session_update', event.data),
},
];
}
Expand Down Expand Up @@ -981,7 +984,7 @@ function normalizeSessionUpdate(
debugReason: kind?.trim()
? 'unrecognized_session_update'
: 'malformed_payload',
text: `${kind ?? 'session_update'}: ${stringifyRedactedJson(update)}`,
text: debugBlockText(kind ?? 'session_update', update),
},
];
}
Expand Down Expand Up @@ -1256,9 +1259,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(
Expand All @@ -1271,7 +1280,7 @@ function normalizePermissionRequest(
...base,
type: 'debug',
debugReason: 'malformed_payload',
text: `permission_request: ${stringifyRedactedJson(event.data)}`,
text: debugBlockText('permission_request', event.data),
},
];
}
Expand All @@ -1283,7 +1292,7 @@ function normalizePermissionRequest(
...base,
type: 'debug',
debugReason: 'malformed_payload',
text: `permission_request: ${stringifyRedactedJson(event.data)}`,
text: debugBlockText('permission_request', event.data),
},
];
}
Expand Down Expand Up @@ -1317,7 +1326,7 @@ function normalizePermissionResolved(
...base,
type: 'debug',
debugReason: 'malformed_payload',
text: `${event.type}: ${stringifyRedactedJson(event.data)}`,
text: debugBlockText(event.type, event.data),
},
];
}
Expand Down
26 changes: 23 additions & 3 deletions packages/sdk-typescript/src/daemon/ui/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,30 @@

import type {
DaemonTextDeltaMeta,
DaemonTranscriptReducerOptions,
DaemonTranscriptState,
DaemonTranscriptStore,
DaemonUiEvent,
} from './types.js';
import {
appendLocalUserTranscriptMessage,
createDaemonTranscriptState,
estimateDaemonTranscriptBlockBytes,
rebuildDaemonTranscriptBlockIndex,
reduceDaemonTranscriptEvents,
} from './transcript.js';

export function createDaemonTranscriptStore(
seed: Partial<DaemonTranscriptState> = {},
seed: Partial<DaemonTranscriptState> &
Pick<DaemonTranscriptReducerOptions, 'onTruncation'> = {},
): 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;

Expand Down Expand Up @@ -55,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(
Expand All @@ -74,12 +83,14 @@ export function createDaemonTranscriptStore(
images,
meta,
files,
...reducerOptions,
});
scheduleNotify();
},
reset(nextSeed: Partial<DaemonTranscriptState> = {}) {
state = createState({
maxBlocks: nextSeed.maxBlocks ?? state.maxBlocks,
maxRetainedBytes: nextSeed.maxRetainedBytes ?? state.maxRetainedBytes,
retainSubagentBlocks:
nextSeed.retainSubagentBlocks ?? state.retainSubagentBlocks,
...nextSeed,
Expand Down Expand Up @@ -147,10 +158,19 @@ function createState(
return {
...createDaemonTranscriptState({
maxBlocks: seed.maxBlocks,
maxRetainedBytes: seed.maxRetainedBytes,
Comment thread
wenshao marked this conversation as resolved.
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:
Comment thread
wenshao marked this conversation as resolved.
seed.retainedBytes ??
blocks.reduce(
(total, block) => total + estimateDaemonTranscriptBlockBytes(block),
0,
),
blockIndexById: rebuildDaemonTranscriptBlockIndex(blocks),
toolBlockByCallId: createNullIndex(seed.toolBlockByCallId),
trimmedToolNotificationByCallId: createNullIndex(
Expand Down
5 changes: 4 additions & 1 deletion packages/sdk-typescript/src/daemon/ui/toolPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
DaemonTranscriptQuestionOption,
} from './types.js';
import {
capDetails,
getFirstString,
isRecord,
isSensitiveKey,
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading