Skip to content

Re-diff only the changed response while a chat reply streams - #342254

Merged
Karthik Nadig (karthiknadig) merged 1 commit into
mainfrom
karthiknadig/bug-swarm/c8-f1-chat-list-incremental-stream-refresh
Oct 31, 2026
Merged

Karthik Nadig (karthiknadig) merged 1 commit into
mainfrom
karthiknadig/bug-swarm/c8-f1-chat-list-incremental-stream-refresh

Conversation

@karthiknadig

@karthiknadig Karthik Nadig (karthiknadig) commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Refs #328610
Refs #316407

This is a partial fix. It removes the chat list's share of the per-update cost in long chats. Other per-update work that grows with transcript length is still there; see Follow-ups.

Problem

When a reply streams into a long chat, every streamed chunk makes the renderer re-check the entire transcript. With the same reply, a session with 2,000 earlier turns used 4.1× the renderer script time of a 20-turn session (median 3,051 ms vs 748 ms on main at cd2259b), and the main thread was 91% busy for the whole stream.

With this PR, the same reply into the 2,000-turn session takes about half the renderer CPU: median script time drops from 3,051 ms to 1,521 ms, and the main thread is 74% busy instead of 91%. 20-turn sessions are unchanged (748 → 786 ms, within run-to-run noise).

At the stream rate used here (one chunk about every 32 ms), the visible text already kept up on both builds. The median time between visible updates is 32–35 ms either way. So the gain is CPU headroom for typing and other work during a stream, not visibly smoother text.

The 2,000-turn session still costs 1.6–1.9× the 20-turn session (3.7–4.3× on main). The rest of that cost comes from code outside the chat list (see Follow-ups).

Root cause

Every progress event on a response fires the view model's onDidChange with null (chatViewModel.ts). ChatWidget batches those events and refreshes the list (chatWidget.ts). ChatListWidget.refresh() then rebuilds the whole request/response tree, builds a diff identity string for every item, and calls setChildren(null, allItems, { diffDepth: 1 }) (chatListWidget.ts).

In the tree, spliceSmart diffs every request. Because diffDepth is 1, it also recurses into every unchanged request (indexTreeModel.ts). Each recursion calls getParentNodeWithListIndex, which adds up the row counts of all earlier requests (indexTreeModel.ts).

So one streamed chunk costs time proportional to the square of the transcript length, plus a string per item, even though only the streaming response changed. In a CPU profile of a 100-chunk reply streamed into the 2,000-turn session on main (at 1e81fc7, when this PR was opened), ChatListWidget.refresh took 1,946 ms (46% of the profile), compared with 610 ms with 20 turns. The difference is tree diffing; re-rendering the changed row costs the same at both sizes.

Fix

  • ChatViewModel now fires { kind: 'changeResponse', response } instead of null when a response changes.
  • ChatWidget passes the batched events to ChatListWidget.refresh(changes).
  • refresh() remembers what its last full refresh rendered: the items, each item's diff identity, which requests follow another request, the request being edited, and the visibility and setting counters.
  • A batch takes the new path only if all of these hold:
    • every event in it is a response change;
    • the item list is the same elements in the same order;
    • none of that remembered state changed;
    • each changed response sits in the tree under a request, and that request's identity is unchanged. A request's identity changes when its response completes or gains content references.
  • On that path, the list calls setChildren(request, itsResponses, { diffIdentityProvider }) for just those requests, inside the same auto-scroll handling as before. This is the same child-level update the full diff would have made, so the tree's stored identities, focus, selection and auto-scroll behave as before.
  • Every other refresh is unchanged and still diffs the whole list: added or removed requests, pending and steering messages, undo/redo and checkpoints, request edits, editing mode, visibility or setting changes, filtered lists, and hidden responses.
  • The identity expression moved unchanged into getDiffIdentity(). The full refresh now computes each item's identity once per refresh and reuses it.

Before / after

Both recordings show the same 120-chunk reply streaming into the 2,000-turn session in real time, from the first update the overlay reports to just before the stream finishes. The banner at the top states the run's result from the median of three runs: renderer busy % and renderer script time. The box below it updates live with renderer busy % so far and the time between visible updates. The recordings were made when this PR was opened, on main at 1e81fc7; the numbers in Measurements are from the current base.

Before

Before: on main, a 120-chunk reply streams into a 2,000-turn chat with the renderer about 87% busy (2.8 s of script)

After

After: with this PR, the same reply into the same chat with the renderer about 69% busy (1.3 s of script), updating as smoothly as on main

Measurements

Conditions for every number below:

  • Build: Code OSS dev build (unminified out/), main at cd2259bdf72 vs this PR rebased on it. Windows x64, Agents window, VSCODE_DEV_DISABLE_DISPOSABLE_TRACKING=1. The PR has since been rebased onto main at 42aaa12cec5. The rebase did not change the PR's code, so these numbers were not re-measured; the rebased change passes its tests (see Validation).
  • Setup: chat connected to the built-in mock Agent Host (--enable-mock-agent), so there is no model or network traffic. Runs were serialized on the same machine.
  • Sessions: one with 20 earlier turns and one with 2,000. Each turn is a user message, a completed file-read tool call, and about 400 bytes of markdown.
  • Workload: one reply of 120 small markdown chunks, sent about every 32 ms (about 4.6 s), with two tool calls in the middle.
  • Prompt timeline off (sessions.chatTimeline.display: off, chat.stickyScroll.enabled: false). It has its own per-update cost that is being fixed separately, and that cost would hide the chat list's.
  • Runs: each build did 1 warm-up run and 3 measured runs. Each run streams into the 20-turn session first, then into the 2,000-turn session.

What each metric measures:

  • Renderer script time: the change in the renderer's ScriptDuration (Chrome DevTools Protocol Performance.getMetrics) from pressing Enter until the last chunk is on screen.
  • Main thread busy: the change in TaskDuration over the same interval, divided by its wall-clock time.
  • Time between visible updates: the streaming response's text is sampled once per animation frame. A frame where the text changed counts as a visible update, and the gap is the time between two such frames.
main, 20 turns main, 2,000 turns PR, 20 turns PR, 2,000 turns
Renderer script time per run (ms) 827 / 748 / 715 3,035 / 3,056 / 3,051 968 / 786 / 748 1,537 / 1,521 / 1,442
Median script time (per chunk) 748 ms (6.2 ms) 3,051 ms (25.4 ms) 786 ms (6.6 ms) 1,521 ms (12.7 ms)
2,000-turn ÷ 20-turn, per run 3.7× / 4.1× / 4.3× 1.6× / 1.9× / 1.9×
Main thread busy per run 65 / 43 / 42% 88 / 91 / 92% 73 / 66 / 69% 74 / 77 / 72%
Median time between visible updates per run (ms) 32 / 32 / 32 35 / 33 / 32 32 / 32 / 32 32 / 32 / 32
Longest time between visible updates per run (ms) 70 / 87 / 68 115 / 134 / 104 109 / 74 / 91 127 / 111 / 144

Memory and listeners were measured when this PR was opened (main at 1e81fc7) and were not re-measured after the rebase. They were taken after three forced garbage collections, once after the warm-up and once after the last run (3 more replies per session). The growth was the same on both builds, so this change adds no retention:

  • main: heap 255.5 → 261.8 MB (+6.3), event listeners 3,229 → 3,356 (+127), DOM nodes 3,569 → 3,839 (+270).
  • PR: heap 163.8 → 170.0 MB (+6.2), event listeners 3,177 → 3,303 (+126), DOM nodes 3,531 → 3,791 (+260).

The growth comes from the six new turns the runs add. Absolute heap differed between the two windows; that difference is not attributed to this change.

Follow-ups

These per-update costs still grow with transcript length and live outside the chat list, so they are left out of this PR. The costs are rough self times from a CPU profile of a 100-chunk reply into the 2,000-turn session on main at 1e81fc7:

  • Agent session approval tracking, about 160 ms. The AgentSessionApprovalModel autorun subscribes to every response's change signal and walks all requests on any change.
  • Generated-image extraction, about 50 ms. getGeneratedImageResources scans the whole transcript on each update.
  • Tool-confirmation observation. ChatListItemRenderer.observeToolConfirmations re-filters getItems() and re-keys every response on each view-model change. Its cost was not isolated; it is part of about 65 ms of observable bookkeeping in that profile.
  • ChatViewModel.computeItems, about 65 ms including its callees. It re-filters the whole transcript after every change because the item cache is invalidated on each event.

How to test

  1. Run Code OSS from this branch and open a long chat, or an Agents session with many turns.
  2. Send a prompt that streams a long answer. The answer streams and auto-scrolls as before.
  3. While it streams, check that these still update the list correctly: tool calls and progress, completion (the request's checkpoint and hover controls), editing a request, undo, and queuing or steering a message.
  4. Optional: record a renderer performance trace while streaming into a long chat. ChatListWidget.refresh no longer calls setChildren on the root for each chunk; it updates only the streaming response under its request.
Automated streaming scenario (mock Agent Host + Agents window)
  1. Save the mock host below as mock-agent-host.mjs.
  2. Run it from a built checkout with BS_PORT=8968 node mock-agent-host.mjs. Keep stdin open, because the server exits when stdin closes. It serves sessions with 20, 500 and 2,000 synthetic turns, and the prompt stream:K streams a reply of K chunks.
  3. Launch an Agents window with VSCODE_DEV_DISABLE_DISPOSABLE_TRACKING=1 and these settings: chat.remoteAgentHostsEnabled: true, chat.remoteAgentHosts: [{ "address": "ws://127.0.0.1:8968", "name": "BS Mock" }], chat.agentHost.allowSignedOutWhenUsable: true, sessions.chatTimeline.display: "off", chat.stickyScroll.enabled: false.
  4. Close the sign-in dialog (current builds offer only its close button; older builds show Continue Without Signing In), then open the 20- and 2,000-turn sessions once.
  5. Run the scenario over CDP. It fails while the 2,000-turn stream costs 2× or more of the 20-turn stream. On main at cd2259b it fails at 3.7–4.3×; with this PR it measures 1.6–1.9×.
// mock-agent-host.mjs
// Standalone Agent Host server (compiled from the VS Code worktree's out/) with the ScriptedMockAgent.
// - Seeds mock:/bs-t<N> sessions (BS_SIZES, default 20,500,2000) whose history is N synthetic turns
//   (user message + one completed tool call + small assistant markdown).
// - Prompt `stream:K` streams a reply: one markdown part, then K ChatDelta chunks every BS_DELTA_MS ms (default 20),
//   with a tool call + new markdown part every 50 deltas, then turnComplete.
// Read-only use of the checkout. Usage: node mockhost.mjs  (env BS_PORT, BS_UDD, BS_SIZES, BS_DELTA_MS, BS_PART_BYTES)
import { pathToFileURL } from 'node:url';
import path from 'node:path';

const VSCODE = process.env.BS_VSCODE || process.cwd();
const out = p => pathToFileURL(path.join(VSCODE, 'out', p)).href;
const partBytes = Number(process.env.BS_PART_BYTES || 400);
const deltaMs = Number(process.env.BS_DELTA_MS || 20);
const sizes = (process.env.BS_SIZES || '20,500,2000').split(',').map(Number);
process.env.VSCODE_AGENT_HOST_MOCK_SEED_SESSIONS = sizes.map(n => `mock:/bs-t${n}`).join(',');

const mock = await import(out('vs/platform/agentHost/test/node/mockAgent.js'));
const fx = await import(out('vs/platform/agentHost/test/node/historyRecordFixtures.js'));
const { URI } = await import(out('vs/base/common/uri.js'));

function filler(seed, n, md) {
	const lines = [];
	let size = 0;
	let k = 0;
	while (size < n) {
		const line = md
			? (k % 8 === 0 ? `### Step ${seed}.${k}\n` : `- Item ${seed}.${k}: updated \`module${k % 37}.ts\` to handle edge case ${k * 7 % 101}.\n`)
			: `export function f${seed}_${k}(x: number): number { return x * ${k % 13} + ${seed % 17}; }\n`;
		lines.push(line);
		size += line.length;
		k++;
	}
	return lines.join('');
}

function buildTurns(n, S) {
	const records = [];
	for (let i = 0; i < n; i++) {
		records.push({ type: 'message', role: 'user', session: S, messageId: `u-${i}`, content: `Turn ${i}: please update module${i % 37}.ts and explain the change.` });
		records.push({ type: 'tool_start', session: S, toolCallId: `tc-${i}`, toolName: 'view', displayName: 'Read File', invocationMessage: `Reading module${i % 37}.ts`, toolInput: JSON.stringify({ path: `/src/module${i % 37}.ts` }) });
		records.push({ type: 'tool_complete', session: S, toolCallId: `tc-${i}`, result: { success: true, pastTenseMessage: `Read module${i % 37}.ts`, content: [{ type: 'text', text: filler(i, 200, false) }] } });
		records.push({ type: 'message', role: 'assistant', session: S, messageId: `a-${i}`, content: `Done with turn ${i}.\n\n` + filler(i, partBytes, true) });
	}
	const t = fx.buildTurnsFromHistory(records);
	process.stderr.write(`[bs-mock] built turns=${t.length} json=${(JSON.stringify(t).length / 1048576).toFixed(2)}MB\n`);
	return t;
}
const cache = new Map();
const turnsFor = (n, S) => { if (!cache.has(n)) { cache.set(n, buildTurns(n, S)); } return cache.get(n); };

const P = mock.ScriptedMockAgent.prototype;
const origGet = P.getSessionMessages;
P.getSessionMessages = async function (session) {
	const res = await origGet.call(this, session);
	const str = decodeURIComponent(session.toString());
	if (str.includes('subagent')) { return res; }
	const m = /bs-t(\d+)/.exec(str);
	process.stderr.write(`[bs-mock] getSessionMessages ${str} -> ${m ? m[1] : 'orig'}\n`);
	if (m) { return turnsFor(Number(m[1]), URI.parse(`mock:/bs-t${m[1]}`)); }
	return res;
};
const label = s => { const n = /bs-t(\d+)/.exec(String(s)); return n ? `BS history ${n[1]} turns` : undefined; };
const origFireDisc = P.fireDiscoveredChats;
P.fireDiscoveredChats = function (chats) {
	return origFireDisc.call(this, chats.map(m => ({ ...m, external: false, summary: label(m.session) ?? m.summary })));
};
for (const k of ['listExternalChats', 'listSessions']) {
	const o = P[k];
	P[k] = async function (...a) { return (await o.apply(this, a)).map(m => ({ ...m, summary: label(m.session) ?? m.summary })); };
}
for (const k of ['getSessionMetadata', 'getChatMetadata']) {
	const o = P[k];
	P[k] = async function (...a) { const r = await o.apply(this, a); const l = label(String(a[1]?.toString?.() ?? '') + String(a[0])); return r && l ? { ...r, summary: l } : r; };
}
const origDesc = P.getDescriptor;
P.getDescriptor = function () {
	if (!this.__bsRefire) {
		this.__bsRefire = true;
		for (const ms of [1500, 5000]) {
			setTimeout(() => { void this.listExternalChats().then(chats => { process.stderr.write('[bs-mock] refire discovered chats ' + chats.length + '\n'); this.fireDiscoveredChats(chats); }); }, ms);
		}
	}
	return origDesc.call(this);
};

// Streaming: replace 'Unknown prompt: stream:K' with a realistic streamed reply.
const WORDS = ['the', 'parser', 'now', 'handles', '`edge`', 'cases', 'and', 'returns', '**early**', 'when', 'input', 'is', 'empty;', 'see', '`module7.ts`', 'for', 'details.'];
let partSeq = 0;
const origFire = P._fireSequence;
P._fireSequence = function (signals) {
	const first = signals[0];
	const m = first?.action?.part && typeof first.action.part.content === 'string' && /^Unknown prompt: stream:(\d+)/.exec(first.action.part.content);
	if (!m) { return origFire.call(this, signals); }
	const K = Number(m[1]);
	const resource = first.resource;
	const turnId = first.action.turnId;
	const act = action => ({ kind: 'action', resource, action });
	const seq = [];
	let part = `bs-st-${++partSeq}`;
	seq.push(act({ type: 'chat/responsePart', turnId, part: { kind: 'markdown', id: part, content: `## Streamed reply\n\nStarting the change.\n\n` } }));
	let tool = 0;
	for (let i = 1; i <= K; i++) {
		const w = WORDS[i % WORDS.length];
		const content = (i % 12 === 0) ? `${w}\n\n- point ${i}: ` : `${w} `;
		seq.push(act({ type: 'chat/delta', turnId, partId: part, content }));
		if (i % 50 === 0 && i < K) {
			const tc = `bs-tc-${partSeq}-${++tool}`;
			seq.push(act({ type: 'chat/toolCallStart', turnId, toolCallId: tc, toolName: 'view', displayName: 'Read File' }));
			seq.push(act({ type: 'chat/toolCallReady', turnId, toolCallId: tc, invocationMessage: `Reading module${tool}.ts`, toolInput: JSON.stringify({ path: `/src/module${tool}.ts` }), confirmed: 'not-needed' }));
			seq.push(act({ type: 'chat/toolCallComplete', turnId, toolCallId: tc, result: { success: true, pastTenseMessage: `Read module${tool}.ts`, content: [{ type: 'text', text: 'ok' }] } }));
			part = `bs-st-${++partSeq}`;
			seq.push(act({ type: 'chat/responsePart', turnId, part: { kind: 'markdown', id: part, content: `\n\nContinuing after reading module${tool}.ts: ` } }));
		}
	}
	seq.push(act({ type: 'chat/delta', turnId, partId: part, content: `\n\nBS-STREAM-DONE-${K}` }));
	seq.push(signals[signals.length - 1]);
	process.stderr.write(`[bs-mock] stream ${K} deltas (${seq.length} signals) every ${deltaMs} ms turn=${turnId}\n`);
	let i = 0;
	const t0 = Date.now();
	const timer = setInterval(() => {
		const s = seq[i++];
		this._onDidChatProgress.fire(s);
		if (i >= seq.length) { clearInterval(timer); process.stderr.write(`[bs-mock] stream done in ${Date.now() - t0} ms\n`); }
	}, deltaMs);
};

process.argv = [process.argv[0], process.argv[1], '--enable-mock-agent', '--port', process.env.BS_PORT || '0', '--host', '127.0.0.1', '--without-connection-token', '--quiet', '--user-data-dir', process.env.BS_UDD || path.join(process.env.TEMP, 'mock-agent-host-udd')];
await import(out('vs/platform/agentHost/node/agentHostServerMain.js'));
// repro.cjs
// Streaming a reply into a long Agents session costs renderer time proportional to the whole transcript.
//
// Precondition: a Code OSS Agents window
// (VSCODE_DEV_DISABLE_DISPOSABLE_TRACKING=1) connected via chat.remoteAgentHosts to a mock Agent Host
// (mock-agent-host.mjs below: ScriptedMockAgent, no model traffic) that lists sessions with 20 and 2000 prior turns.
// The prompt `stream:K` streams one reply of K small markdown deltas (plus a tool call every 50 deltas).
// The prompt timeline is turned off (sessions.chatTimeline.display=off, chat.stickyScroll.enabled=false) so that
// its known per-update cost (being fixed separately) is excluded: what remains is the chat list itself.
const K = 120;
const RATIO_LIMIT = 2; // appending the same reply should not cost much more because older turns exist

const OVERLAY = (text) => {
	let el = document.getElementById('bs-overlay');
	if (!el) {
		el = document.createElement('div');
		el.id = 'bs-overlay';
		el.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);top:6px;z-index:100000;background:#000d;color:#fff;font:600 16px monospace;padding:8px 12px;border:2px solid #f80;border-radius:6px;white-space:pre;pointer-events:none';
		document.body.appendChild(el);
	}
	el.textContent = text;
};

async function dismissSignIn(page) {
	const cont = page.getByText('Continue Without Signing In');
	if (await cont.count()) { await cont.first().click().catch(() => { }); await page.waitForTimeout(500); }
}

const maxIdx = page => page.evaluate(() => Math.max(-1, ...[...document.querySelectorAll('.monaco-list-row.request, .monaco-list-row.response')].filter(e => e.offsetParent).map(e => Number(e.dataset.index))));

// Session rows under the "Agent mock" group; a session with N prior turns shows ~2N list items.
async function findSession(page, n) {
	const ids = await page.evaluate(() => {
		const rows = [...document.querySelectorAll('.monaco-list-row')];
		const header = rows.findIndex(r => (r.getAttribute('aria-label') || '').startsWith('Agent mock'));
		return header < 0 ? [] : rows.slice(header + 1).filter(r => /^Session,/.test(r.getAttribute('aria-label') || '')).map(r => r.id);
	});
	for (const id of ids) {
		await dismissSignIn(page);
		await page.locator(`[id="${id}"]`).click();
		const ok = await page.waitForFunction(([n, id]) => {
			if (document.getElementById(id)?.getAttribute('aria-selected') !== 'true') { return false; }
			const idx = [...document.querySelectorAll('.monaco-list-row.request, .monaco-list-row.response')].filter(e => e.offsetParent).map(e => Number(e.dataset.index));
			const max = Math.max(-1, ...idx);
			return max >= 2 * n - 1 && max < 2 * n + 80;
		}, [n, id], { timeout: 8000, polling: 100 }).then(() => true, () => false);
		if (ok) { await page.waitForTimeout(1500); return id; }
	}
	throw new Error(`No mock session with ${n} prior turns found (rows: ${ids.join(', ')})`);
}

const median = a => { const s = [...a].sort((x, y) => x - y); return s.length ? s[Math.floor((s.length - 1) / 2)] : NaN; };

async function streamAndMeasure(ctx, n, label) {
	const { page, cdp } = ctx;
	await findSession(page, n);
	const input = page.locator('.interactive-input-part .monaco-editor:visible').last();
	await input.click();
	await page.keyboard.type(`stream:${K}`, { delay: 5 });
	await cdp.send('Performance.enable');
	const metrics = async () => Object.fromEntries((await cdp.send('Performance.getMetrics')).metrics.map(m => [m.name, m.value]));
	// Typing latency: keydown event timestamp -> first DOM mutation of the composer's view lines.
	await page.evaluate(() => {
		window.__bsLat = [];
		window.__bsKeyTs = undefined;
		window.__bsKeyHandler && window.removeEventListener('keydown', window.__bsKeyHandler, true);
		window.__bsKeyHandler = e => { if (e.key === 'x') { window.__bsKeyTs = e.timeStamp; } };
		window.addEventListener('keydown', window.__bsKeyHandler, true);
		window.__bsMo?.disconnect();
		const lines = [...document.querySelectorAll('.interactive-input-part .monaco-editor .view-lines')].filter(e => e.offsetParent).at(-1);
		window.__bsMo = new MutationObserver(() => { if (window.__bsKeyTs !== undefined) { window.__bsLat.push(Math.round(performance.now() - window.__bsKeyTs)); window.__bsKeyTs = undefined; } });
		window.__bsMo.observe(lines, { subtree: true, childList: true, characterData: true });
	});
	const before = await maxIdx(page);
	// Visible updates: sample the streaming response's text once per animation frame; a frame whose text differs
	// from the previous frame is a visible update. Gaps between them show how smoothly the reply streams.
	await page.evaluate(() => {
		window.__bsGaps = [];
		window.__bsLastText = undefined;
		window.__bsLastUpdate = undefined;
		window.__bsSampling = true;
		const sample = () => {
			if (!window.__bsSampling) { return; }
			const rows = [...document.querySelectorAll('.monaco-list-row.response')].filter(e => e.offsetParent);
			const last = rows.sort((a, b) => Number(b.dataset.index) - Number(a.dataset.index))[0];
			const text = last && last.textContent.includes('Streamed reply') ? last.textContent : undefined;
			if (text !== undefined && text !== window.__bsLastText) {
				const now = performance.now();
				if (window.__bsLastUpdate !== undefined && window.__bsLastText !== undefined) { window.__bsGaps.push(now - window.__bsLastUpdate); }
				window.__bsLastUpdate = now;
				window.__bsLastText = text;
			}
			requestAnimationFrame(sample);
		};
		requestAnimationFrame(sample);
	});
	const m0 = await metrics();
	const t0 = Date.now();
	await page.keyboard.press('Enter');
	let done = false;
	const finished = page.waitForFunction(([k, before]) => {
		const rows = [...document.querySelectorAll('.monaco-list-row.response')].filter(e => e.offsetParent);
		const last = rows.sort((a, b) => Number(b.dataset.index) - Number(a.dataset.index))[0];
		return last && Number(last.dataset.index) >= before + 2 && last.textContent.includes(`BS-STREAM-DONE-${k}`);
	}, [K, before], { timeout: 180000, polling: 100 }).then(() => { done = true; });
	await page.waitForTimeout(600);
	for (let i = 0; i < 10 && !done; i++) {
		await page.keyboard.press('x');
		await page.waitForTimeout(250);
		const lat = await page.evaluate(() => window.__bsLat);
		const now = await metrics();
		const busy = Math.round(100 * 1000 * (now.TaskDuration - m0.TaskDuration) / (Date.now() - t0));
		const gaps = await page.evaluate(() => window.__bsGaps.map(Math.round));
		await page.evaluate(OVERLAY, `${label}: ${n}-turn session, streaming a ${K}-chunk reply\nrenderer busy so far: ${busy}%\ntime between visible updates: median ${median(gaps)} ms, max ${Math.max(0, ...gaps)} ms\ncomposer key->echo: ${lat.join(', ')} ms`);
	}
	await finished;
	const wallMs = Date.now() - t0;
	const m1 = await metrics();
	const lat = await page.evaluate(() => { window.__bsMo?.disconnect(); return window.__bsLat; });
	const gaps = await page.evaluate(() => { window.__bsSampling = false; return window.__bsGaps.map(Math.round); });
	const sortedGaps = [...gaps].sort((a, b) => a - b);
	// clear the typed x's
	await input.click();
	await page.keyboard.press('Control+A');
	await page.keyboard.press('Backspace');
	const scriptMs = Math.round((m1.ScriptDuration - m0.ScriptDuration) * 1000);
	const taskMs = Math.round((m1.TaskDuration - m0.TaskDuration) * 1000);
	return { n, wallMs, scriptMs, taskMs, busyPct: Math.round(100 * taskMs / wallMs), scriptPerDeltaMs: +(scriptMs / K).toFixed(1), keyLatMs: lat, keyLatMedian: median(lat), keyLatMax: Math.max(0, ...lat), visibleUpdates: gaps.length + 1, gapMedianMs: median(gaps), gapP95Ms: sortedGaps[Math.floor(0.95 * (sortedGaps.length - 1))] ?? NaN, gapMaxMs: Math.max(0, ...gaps) };
}

let control;
module.exports = {
	id: 'agents-stream-into-long-session',
	title: 'Streaming a reply into a 2000-turn Agents session costs no more than into a 20-turn one',
	stepPauseMs: 1500,
	steps: [
		{
			id: 'S1', title: 'Baseline: mock sessions with 20 and 2000 prior turns are listed',
			async run({ page }) {
				await dismissSignIn(page);
				await page.locator('.monaco-list-row[aria-label^="Agent mock"]').first().waitFor({ timeout: 30000 });
				const timeline = await page.evaluate(() => document.querySelectorAll('[class*="prompt-timeline"]').length);
				await page.evaluate(OVERLAY, `Mock Agent Host (no model traffic)\nsessions: 20 turns (control) and 2000 turns\nprompt timeline off (excluded from this measurement)`);
				return `Agent mock group listed; prompt timeline elements: ${timeline}.`;
			}
		},
		{
			id: 'S2', title: `Control: stream a ${K}-delta reply into the 20-turn session`,
			async run(ctx) {
				control = await streamAndMeasure(ctx, 20, 'CONTROL');
				console.log('BSMETRIC control ' + JSON.stringify(control));
				await ctx.page.evaluate(OVERLAY, `20-turn session: renderer busy ${control.busyPct}% while streaming\nrenderer script ${control.scriptMs} ms\ntime between visible updates: median ${control.gapMedianMs} ms, max ${control.gapMaxMs} ms`);
				return `20 turns: script ${control.scriptMs} ms (${control.scriptPerDeltaMs} ms/delta), busy ${control.busyPct}%, visible-update gap median ${control.gapMedianMs} ms (p95 ${control.gapP95Ms}, max ${control.gapMaxMs}, ${control.visibleUpdates} updates), key->echo ${control.keyLatMs.join(', ')} ms.`;
			}
		},
		{
			id: 'S3', title: `Stream the same reply into the 2000-turn session (expect script time < ${RATIO_LIMIT}x control)`,
			async run(ctx) {
				const r = await streamAndMeasure(ctx, 2000, 'LARGE');
				console.log('BSMETRIC large ' + JSON.stringify(r));
				const ratio = +(r.scriptMs / control.scriptMs).toFixed(1);
				const verdict = ratio < RATIO_LIMIT ? 'PASS' : 'FAIL';
				await ctx.page.evaluate(OVERLAY, `2000-turn session: renderer busy ${r.busyPct}% while streaming (20-turn: ${control.busyPct}%)\nrenderer script ${r.scriptMs} ms = ${ratio}x the 20-turn session (expected < ${RATIO_LIMIT}x): ${verdict}\ntime between visible updates: median ${r.gapMedianMs} ms, max ${r.gapMaxMs} ms (20-turn: ${control.gapMedianMs} / ${control.gapMaxMs} ms)`);
				const msg = `2000 turns: script ${r.scriptMs} ms (${r.scriptPerDeltaMs} ms/delta) vs control ${control.scriptMs} ms = ${ratio}x; busy ${r.busyPct}% vs ${control.busyPct}%; visible-update gap median ${r.gapMedianMs} ms (p95 ${r.gapP95Ms}, max ${r.gapMaxMs}, ${r.visibleUpdates} updates) vs ${control.gapMedianMs} ms (p95 ${control.gapP95Ms}, max ${control.gapMaxMs}); key->echo median ${r.keyLatMedian} ms (max ${r.keyLatMax}) vs ${control.keyLatMedian} ms`;
				if (ratio >= RATIO_LIMIT) {
					throw new Error(`${msg}. Every streamed update re-diffs the whole transcript (ChatListWidget.refresh -> ObjectTree.setChildren over all items).`);
				}
				return msg;
			}
		},
	],
};

Validation

Check Command Result
Test window setup Process-local GITHUB_WORKSPACE=<empty temp dir> for every unit test run below Needed on this machine. test/unit/electron/index.js only shows the test window (inactive) on CI. Locally the window stays hidden, Windows throttles its animation frames, and layout tests time out on main too. This runs the window the way CI does, which also uses CI's 30 s test timeout.
Chat list, chat widget and view model tests on main @ 42aaa12 scripts\test.bat --run src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts --run src/vs/workbench/contrib/chat/test/common/model/chatViewModel.test.ts --run src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts 311 passing, 4 pending, 9 failing. All 9 are pixel-geometry checks that fail at this machine's 125% display scaling: the 6 "scroll arrow returns to the bottom" tests (a 1 px border measures 0.8 px), "uses the request bubble as the sticky source and content" (off by 0.0125 px), and 2 "image generation preserves scroll position" tests (0.2375 vs 0.25 and 0.7375 vs 0.75).
Same tests with this PR, rebased on 42aaa12 same command 313 passing, 4 pending, the same 9 failing. The 2 extra passing tests are the new "streaming into a long transcript" tests.
Scroll position restore from #345176 with the new path scripts\test.bat --run src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts --grep "streaming into a long transcript|restores the row at the top", with one temporary (not committed) extra test ✅ 4 passing. Main's "restores the row at the top of the viewport after switching to another chat and back" passes. The temporary test scrolled a 20-turn transcript up, streamed 5 paragraphs through the new path (0 full-list refreshes), and checked that the saved top row and offset were the same before, during and after streaming, and that restoreViewState returned to them after scrolling to the bottom. The restore reads row positions from the tree, which the new path keeps up to date like a full refresh does.
New tests with the new path disabled (when this PR was opened, on 1e81fc7) Same branch with refreshChangedResponses forced to return false, then scripts\test.bat --grep "streaming into a long transcript" ❌ 2 failing, as expected. 5 streamed paragraphs caused 5 full-list refreshes that diffed 1,010 elements (100 earlier turns) or 60 elements (5 earlier turns). With the new path, 0 full-list refreshes and 5 elements diffed regardless of length; the last paragraph is shown, the list stays scrolled to the bottom, and it falls back to a full refresh on completion, a new request, and undo.
Type check npm run typecheck-client ✅ passed (rebased on 42aaa12)
Lint npm run eslint -- <the 4 changed files> ✅ passed (rebased on 42aaa12)
Streaming scenario on main @ cd2259b the scenario above, 1 warm-up + 3 runs ❌ 2,000-turn stream costs 3.7× / 4.1× / 4.3× the 20-turn stream (median 3,051 vs 748 ms); main thread 91% busy
Streaming scenario with this PR, rebased on cd2259b same ✅ 1.6× / 1.9× / 1.9× (median 1,521 vs 786 ms); main thread 74% busy. The "under 2×" check now passes in 3 of 3 runs, but with little margin; the follow-ups above are the remaining cost.
Memory, listeners and DOM nodes (when this PR was opened, on 1e81fc7) forced GC, then CDP Performance.getMetrics after the warm-up and after the last run Same growth as main: +6.2 vs +6.3 MB heap, +126 vs +127 listeners, +260 vs +270 nodes

Found and fixed with automated exploratory testing. Draft until reviewed by a human.

Copilot AI balanced review requested due to automatic review settings October 17, 2026 09:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The shared tree-update path needs human validation of focus, selection, scrolling, and cache invalidation beyond the supplied tests.

No open issues 🎉

What changed in this PR

Reduces chat-list processing during streamed replies in long conversations, partially addressing the reported responsiveness issues.

Changes:

  • Passes response-specific change events from the view model to the list.
  • Updates only affected response subtrees when safe, retaining full-refresh fallbacks.
  • Adds tests for streaming, scrolling, and fallback behavior.
File Description
src/​vs/​workbench/​contrib/​chat/​test/​browser/​widget/​chatListWidget.test.ts Tests incremental updates and full-refresh fallbacks.
src/​vs/​workbench/​contrib/​chat/​common/​model/​chatViewModel.ts Emits response-specific change events.
src/​vs/​workbench/​contrib/​chat/​browser/​widget/​chatWidget.ts Forwards batched changes to the list.
src/​vs/​workbench/​contrib/​chat/​browser/​widget/​chatListWidget.ts Caches rendered state and selectively updates response subtrees.

Review effort: Balanced

@karthiknadig
Karthik Nadig (karthiknadig) force-pushed the karthiknadig/bug-swarm/c8-f1-chat-list-incremental-stream-refresh branch from f762bad to cf4a4dd Compare October 18, 2026 05:16
@karthiknadig
Karthik Nadig (karthiknadig) force-pushed the karthiknadig/bug-swarm/c8-f1-chat-list-incremental-stream-refresh branch from cf4a4dd to 8ff4c2e Compare October 30, 2026 03:46
@karthiknadig
Karthik Nadig (karthiknadig) marked this pull request as ready for review October 30, 2026 04:01
Every streamed update to a response refreshed the chat list by diffing the
whole transcript, so the cost of each update grew with the number of earlier
turns. The view model now reports which response changed, and the list
re-diffs only that response under its request when nothing else changed,
falling back to the full refresh otherwise.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@karthiknadig
Karthik Nadig (karthiknadig) merged commit 75d5f6e into main Oct 31, 2026
59 of 78 checks passed
@karthiknadig
Karthik Nadig (karthiknadig) deleted the karthiknadig/bug-swarm/c8-f1-chat-list-incremental-stream-refresh branch October 31, 2026 05:31
@vs-code-engineering vs-code-engineering Bot added this to the 1.145.0 milestone Oct 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants