Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
32 changes: 30 additions & 2 deletions packages/sdk-typescript/src/daemon/DaemonSessionClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: [],
};
Expand Down Expand Up @@ -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);
}
Expand Down
8 changes: 7 additions & 1 deletion packages/sdk-typescript/src/daemon/ui/normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`,
),
Comment thread
wenshao marked this conversation as resolved.
Resolved
},
];
}
Expand Down
31 changes: 31 additions & 0 deletions packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
19 changes: 19 additions & 0 deletions packages/sdk-typescript/test/unit/daemonUi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -365,6 +366,7 @@ export function SubagentDetail({
workspaceCwd={workspaceCwd}
clientId={instance.clientId}
maxQueued={256}
maxBlocks={WEB_SHELL_MAX_TRANSCRIPT_BLOCKS}
subagentTranscriptMode="full"
suppressOwnUserEcho
>
Expand Down
119 changes: 113 additions & 6 deletions packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ interface MockSession {
compactedReplay: DaemonEvent[];
liveJournal: DaemonEvent[];
};
consumeReplaySnapshot: () => {
compactedReplay: DaemonEvent[];
liveJournal: DaemonEvent[];
};
events: (opts?: {
signal?: AbortSignal;
maxQueued?: number;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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({
Expand All @@ -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<void>((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(<Harness />, {
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',
Expand All @@ -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 } } : {}),
},
Expand All @@ -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<typeof useDaemonTranscriptHistory> | undefined;
Expand All @@ -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(<Harness />, {
autoConnect: true,
historyPageSize: 25,
maxBlocks: 1,
maxBlocks: 2,
});
await act(async () => {
await history?.loadMore();
Expand Down Expand Up @@ -12442,6 +12544,11 @@ function createMockSession(opts: Partial<MockSession> = {}): MockSession {
compactedReplay: [],
liveJournal: [],
},
consumeReplaySnapshot: vi.fn(() => {
const snapshot = session.replaySnapshot;
session.replaySnapshot = { compactedReplay: [], liveJournal: [] };
return snapshot;
}),
events: opts.events ?? createIdleEvents(),
};
return session;
Expand Down
Loading
Loading