Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
64 changes: 64 additions & 0 deletions docs/design/web-shell-thinking-stream-performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Web Shell thinking stream performance

## Problem

Streaming thought deltas update the collapsed "Thinking" row without showing
the thought body, but each visible transcript tick still projects the complete
transcript. MessageList also limits its existing tail-only cache to assistant
messages, so thinking streams repeat compact-message merging and display-item
derivation. Both costs grow with retained history and compete with the
paint-bound thinking shimmer on the browser main thread.

## Design

First, extend MessageList's committed tail cache to thinking messages. In
non-compact mode the new thinking message replaces the previous tail directly.
In compact mode the streaming thought is represented by the final synthetic
tool summary, so update only that summary and its final thought while preserving
all earlier message, tool, and display-item identities. Any dependency or
structural change uses the complete derivation path.

Second, expose an optional transcript block change summary from the SDK store.
The summary identifies its source store and advances a barrier for every change
except a validated append to the active top-level assistant or thought block.
Web Shell carries the summary with the throttled block snapshot. Equal barriers
from the same source prove that skipped revisions were pure tail appends, so the
message hook can append only the new text to its committed tail message instead
of invoking the complete projector.

Reconciliation-derived keys and resolved background-agent history use the same
barrier, connection session, and resolution snapshot identities. They are
reused only for a proven tail append; tool, permission, notification, history,
reset, session, metadata, and terminal changes rebuild through the existing
paths.

Third, top-level assistant and thought deltas share reducer side indexes that
they cannot mutate, including historical tool, permission, parent, and progress
indexes. The normal cloning path remains in place for nested deltas, mixed
batches, and any update that can cross the effective transcript block limit.

Finally, a virtualized MessageList drives bottom-follow and overflow reporting
from its measured total height and item count instead of message identity.
Content-only updates with unchanged geometry therefore perform no scroll
layout reads or writes. Non-virtual transcripts keep the existing per-message
follow behavior because their row height is not tracked by the virtualizer.

## Compatibility

The store method is optional. Older store implementations retain the existing
reference-scan fallback. There are no daemon protocol, persistence, route, or
animation changes.

## Verification

- Compare incremental compact and non-compact thinking results with complete
derivation.
- Prove pure assistant and thought appends preserve the store barrier while
mixed, structural, terminal, reset, and bounded-text changes advance it.
- Prove pure tail ticks skip the complete projector and reconciliation scans.
- Prove top-level text deltas reuse populated side indexes without sharing
across nested or transcript-trimming paths.
- Prove content-only virtual transcript updates perform no scroll geometry
reads or writes while the streamed tail still updates.
- Extend the deterministic browser performance scenario to stream thought
events and record animation-frame gaps.
4 changes: 3 additions & 1 deletion packages/sdk-typescript/scripts/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ const rootDir = join(__dirname, '..');
// 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;
// Bumped from 206KB to 208KB for transcript block change summaries used to
// avoid complete Web Shell projection on every streamed text update.
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 208 * 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
1 change: 1 addition & 0 deletions packages/sdk-typescript/src/daemon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export type {
DaemonToolPreview,
DaemonToolTranscriptBlock,
DaemonTranscriptBlock,
DaemonTranscriptBlockChangeSummary,
DaemonTranscriptBlockKind,
DaemonTranscriptQuestion,
DaemonTranscriptQuestionOption,
Expand Down
1 change: 1 addition & 0 deletions packages/sdk-typescript/src/daemon/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export type {
DaemonToolPreview,
DaemonToolTranscriptBlock,
DaemonTranscriptBlock,
DaemonTranscriptBlockChangeSummary,
DaemonTranscriptBlockKind,
DaemonTranscriptQuestion,
DaemonTranscriptQuestionOption,
Expand Down
139 changes: 137 additions & 2 deletions packages/sdk-typescript/src/daemon/ui/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
*/

import type {
DaemonTextDeltaMeta,
DaemonTranscriptBlock,
DaemonTranscriptBlockChangeSummary,
DaemonTranscriptReducerOptions,
DaemonTranscriptState,
DaemonTextDeltaMeta,
DaemonTranscriptStore,
DaemonUiEvent,
} from './types.js';
Expand All @@ -30,6 +32,12 @@ export function createDaemonTranscriptStore(
? { onTruncation }
: {};
let state = createState(stateSeed);
const blockChangeSource = {};
let blockChangeSummary: DaemonTranscriptBlockChangeSummary = {
source: blockChangeSource,
revision: 0,
tailAppendBarrierRevision: 0,
};
const listeners = new Set<() => void>();
let notifyScheduled = false;

Expand All @@ -55,6 +63,9 @@ export function createDaemonTranscriptStore(
getSnapshot() {
return state;
},
getBlockChangeSummary() {
return blockChangeSummary;
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
Expand All @@ -64,7 +75,14 @@ export function createDaemonTranscriptStore(
dispatch(event: DaemonUiEvent | DaemonUiEvent[]) {
const events = Array.isArray(event) ? event : [event];
if (events.length === 0) return;
state = reduceDaemonTranscriptEvents(state, events, reducerOptions);
const previous = state;
state = reduceDaemonTranscriptEvents(previous, events, reducerOptions);
blockChangeSummary = nextBlockChangeSummary(
blockChangeSummary,
previous,
state,
events,
);
scheduleNotify();
},
appendLocalUserMessage(
Expand All @@ -79,12 +97,16 @@ export function createDaemonTranscriptStore(
attachmentId?: string;
}>,
) {
const previousBlocks = state.blocks;
state = appendLocalUserTranscriptMessage(state, text, {
images,
meta,
files,
...reducerOptions,
});
if (state.blocks !== previousBlocks) {
blockChangeSummary = invalidateTailAppend(blockChangeSummary);
}
Comment thread
ytahdn marked this conversation as resolved.
Resolved
scheduleNotify();
},
reset(nextSeed: Partial<DaemonTranscriptState> = {}) {
Expand All @@ -95,6 +117,7 @@ export function createDaemonTranscriptStore(
nextSeed.retainSubagentBlocks ?? state.retainSubagentBlocks,
...nextSeed,
});
blockChangeSummary = invalidateTailAppend(blockChangeSummary);
scheduleNotify();
},
// wenshao R4-R6 (qwen3.7-max): explicit recovery from the
Expand Down Expand Up @@ -135,6 +158,118 @@ export function createDaemonTranscriptStore(
};
}

function nextBlockChangeSummary(
current: DaemonTranscriptBlockChangeSummary,
previous: DaemonTranscriptState,
next: DaemonTranscriptState,
events: readonly DaemonUiEvent[],
): DaemonTranscriptBlockChangeSummary {
if (previous.blocks === next.blocks) return current;
const revision = current.revision + 1;
const tailBlockId = streamingTailAppendBlockId(previous, next, events);
return tailBlockId
? {
source: current.source,
revision,
tailAppendBarrierRevision: current.tailAppendBarrierRevision,
tailBlockId,
}
: {
source: current.source,
revision,
tailAppendBarrierRevision: revision,
};
}

function invalidateTailAppend(
current: DaemonTranscriptBlockChangeSummary,
): DaemonTranscriptBlockChangeSummary {
const revision = current.revision + 1;
return {
source: current.source,
revision,
tailAppendBarrierRevision: revision,
};
}

function streamingTailAppendBlockId(
previous: DaemonTranscriptState,
next: DaemonTranscriptState,
events: readonly DaemonUiEvent[],
): string | undefined {
const first = events[0];
if (
!first ||
(first.type !== 'assistant.text.delta' &&
first.type !== 'thought.text.delta') ||
first.parentToolCallId !== undefined ||
events.some(
(event) =>
event.type !== first.type ||
('parentToolCallId' in event && event.parentToolCallId !== undefined),
) ||
previous.blocks.length === 0 ||
previous.blocks.length !== next.blocks.length ||
previous.blockIndexById !== next.blockIndexById
) {
return undefined;
}

const blockId =
first.type === 'assistant.text.delta'
? previous.activeAssistantBlockId
: previous.activeThoughtBlockId;
const nextBlockId =
first.type === 'assistant.text.delta'
? next.activeAssistantBlockId
: next.activeThoughtBlockId;
const before = previous.blocks[previous.blocks.length - 1];
const after = next.blocks[next.blocks.length - 1];
const appendedTextLength = events.reduce(
(length, event) =>
length +
(event.type === 'assistant.text.delta' ||
event.type === 'thought.text.delta'
? event.text.length
: 0),
0,
);
if (
!blockId ||
nextBlockId !== blockId ||
before?.id !== blockId ||
after?.id !== blockId ||
before.kind !== after.kind ||
!isTextBlock(before) ||
!isTextBlock(after) ||
after.streaming !== true ||
before.parentToolCallId !== after.parentToolCallId ||
before.meta !== after.meta ||
before.usage !== after.usage ||
before.branchRecordId !== after.branchRecordId ||
before.clientReceivedAt !== after.clientReceivedAt ||
before.promptId !== after.promptId ||
before.sourceRecordIds !== after.sourceRecordIds ||
after.text.length !== before.text.length + appendedTextLength
) {
return undefined;
}
return blockId;
}

function isTextBlock(
block: DaemonTranscriptBlock,
): block is Extract<
DaemonTranscriptBlock,
{ kind: 'assistant' | 'thought' | 'user' }
> {
return (
block.kind === 'assistant' ||
block.kind === 'thought' ||
block.kind === 'user'
);
}

function reportListenerError(error: unknown): void {
const reporter = (
globalThis as typeof globalThis & {
Expand Down
76 changes: 54 additions & 22 deletions packages/sdk-typescript/src/daemon/ui/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,18 @@ export function reduceDaemonTranscriptEvents(
opts: DaemonTranscriptReducerOptions = {},
): DaemonTranscriptState {
if (events.length === 0) return state;
const next = cloneTranscriptState(state, opts);
const maxBlocks = opts.maxBlocks ?? state.maxBlocks;
const shareSideIndexes =
state.blocks.length + events.length <= maxBlocks &&
events.every(
(event) =>
(event.type === 'assistant.text.delta' ||
event.type === 'thought.text.delta') &&
event.parentToolCallId === undefined,
);
const next = cloneTranscriptState(state, opts, shareSideIndexes);
for (const event of events) applyDaemonTranscriptEvent(next, event);
const result = trimTranscriptState(next);
const result = trimTranscriptState(next, shareSideIndexes);
// With lazy COW, blocks and their index can be shared across snapshots.
// Freeze both at the dispatch boundary so external in-place mutation throws
// in strict mode instead of poisoning every snapshot sharing the reference.
Expand Down Expand Up @@ -1540,6 +1549,7 @@ function createTextBlock(
function cloneTranscriptState(
state: DaemonTranscriptState,
opts: DaemonTranscriptReducerOptions,
shareSideIndexes = false,
): DaemonTranscriptState {
const next: DaemonTranscriptState = {
...state,
Expand All @@ -1560,26 +1570,32 @@ function cloneTranscriptState(
// consumers + the WeakMap caches want.
blocks: state.blocks,
blockIndexById: state.blockIndexById,
toolBlockByCallId: createIndex(state.toolBlockByCallId),
activeAssistantBlockByParent: createIndex(
state.activeAssistantBlockByParent,
),
activeThoughtBlockByParent: createIndex(state.activeThoughtBlockByParent),
trimmedToolNotificationByCallId: createIndex(
state.trimmedToolNotificationByCallId,
),
permissionBlockByRequestId: createIndex(state.permissionBlockByRequestId),
// Deep-clone the inner progress records.
// The outer spread alone shares `{ ratio?, step? }` references between
// snapshots — once `tool.progress` event handlers start mutating in
// place, the prior snapshot leaks. Pre-empt that here; cost is bounded
// by `Object.keys(state.toolProgress).length` which is small (only
// in-flight tools).
toolProgress: createIndex(
Object.fromEntries(
Object.entries(state.toolProgress).map(([k, v]) => [k, { ...v }]),
),
),
// Top-level streamed text cannot mutate these side indexes. Sharing them
// avoids work proportional to historical tool count on every delta.
toolBlockByCallId: shareSideIndexes
? state.toolBlockByCallId
: createIndex(state.toolBlockByCallId),
activeAssistantBlockByParent: shareSideIndexes
? state.activeAssistantBlockByParent
: createIndex(state.activeAssistantBlockByParent),
activeThoughtBlockByParent: shareSideIndexes
? state.activeThoughtBlockByParent
: createIndex(state.activeThoughtBlockByParent),
trimmedToolNotificationByCallId: shareSideIndexes
? state.trimmedToolNotificationByCallId
: createIndex(state.trimmedToolNotificationByCallId),
permissionBlockByRequestId: shareSideIndexes
? state.permissionBlockByRequestId
: createIndex(state.permissionBlockByRequestId),
// Other reducer events may mutate the inner progress records, so those
// paths still deep-clone them before applying updates.
toolProgress: shareSideIndexes
? state.toolProgress
: createIndex(
Object.fromEntries(
Object.entries(state.toolProgress).map(([k, v]) => [k, { ...v }]),
),
),
lastResyncRequired:
state.lastResyncRequired !== undefined
? { ...state.lastResyncRequired }
Expand Down Expand Up @@ -1612,6 +1628,7 @@ function sharesSourceRecordId(

function trimTranscriptState(
state: DaemonTranscriptState,
sideIndexesShared = false,
): DaemonTranscriptState {
const overByteBudget = state.retainedBytes > state.maxRetainedBytes;
if (state.blocks.length <= state.maxBlocks && !overByteBudget) return state;
Expand Down Expand Up @@ -1692,6 +1709,21 @@ function trimTranscriptState(
// future appends in the same dispatch don't copy them again.
ownedBlocks.set(state, state.blocks);
ownedBlockIndexes.set(state, state.blockIndexById);
if (sideIndexesShared) {
state.toolBlockByCallId = createIndex(state.toolBlockByCallId);
Comment thread
ytahdn marked this conversation as resolved.
Resolved
state.activeAssistantBlockByParent = createIndex(
state.activeAssistantBlockByParent,
);
state.activeThoughtBlockByParent = createIndex(
state.activeThoughtBlockByParent,
);
state.trimmedToolNotificationByCallId = createIndex(
state.trimmedToolNotificationByCallId,
);
state.permissionBlockByRequestId = createIndex(
state.permissionBlockByRequestId,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trimTranscriptState with sideIndexesShared = true replaces toolBlockByCallId, activeAssistantBlockByParent, activeThoughtBlockByParent, trimmedToolNotificationByCallId, and permissionBlockByRequestId before mutating them — but leaves toolProgress pointing at the previous snapshot's shared object.

pruneTrimmedToolIndexes(state) then does:

for (const toolCallId of Object.keys(state.toolProgress)) {
  if (!liveToolCallIds.has(toolCallId)) {
    delete state.toolProgress[toolCallId];
  }
}

In a production build (FREEZE_TRANSCRIPT_COLLECTIONS = false), this silently corrupts the previous snapshot — any component still holding a reference to the old snapshot's toolProgress sees entries deleted mid-render.

Suggested fix: add toolProgress to the unsharing block here:

if (sideIndexesShared) {
  state.toolBlockByCallId = createIndex(state.toolBlockByCallId);
  // … existing five …
  state.toolProgress = createIndex(
    Object.fromEntries(Object.entries(state.toolProgress).map(([k, v]) => [k, { ...v }])),
  );
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

感谢指出。这里核对后确认不需要修改:评论中引用的 toolProgress 删除循环实际位于 rebuildTranscriptIndexes,不是 trimTranscriptState 调用的 pruneTrimmedToolIndexes。

pruneTrimmedToolIndexes 只会修改 toolBlockByCallId 和 trimmedToolNotificationByCallId;这两个索引已经在 sideIndexesShared 分支中解除共享。纯顶层 text delta + byte-budget trim 路径不会读写 toolProgress,所以继续共享该对象不会改变旧快照。

rebuildTranscriptIndexes 仅由 session rewind 的 truncateTranscriptBeforeBlock 路径调用,而 rewind 事件不会满足 shareSideIndexes 条件,因此其删除 toolProgress 前拿到的是深拷贝。按建议在 trim 中额外深拷贝 toolProgress 不会修复实际问题,只会增加裁剪路径的开销。

);
}
for (const [toolCallId, blockId] of Object.entries(state.toolBlockByCallId)) {
if (!keptIds.has(blockId)) {
state.toolBlockByCallId[toolCallId] = TRIMMED_TOOL_BLOCK_ID;
Expand Down
Loading
Loading