From eefee357a11100ea75f0060e222ca426de9afc16 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 23 Jul 2026 05:43:05 +0300 Subject: [PATCH] Preserve streamed Markdown across panel restores --- src/chrome/src/run-ui-journal.js | 38 +++++++++++++ src/chrome/src/ui/sidepanel.js | 95 ++++++++++++++++++++++++++++--- src/firefox/src/run-ui-journal.js | 38 +++++++++++++ src/firefox/src/ui/sidepanel.js | 95 ++++++++++++++++++++++++++++--- test/run.js | 21 +++++-- 5 files changed, 264 insertions(+), 23 deletions(-) diff --git a/src/chrome/src/run-ui-journal.js b/src/chrome/src/run-ui-journal.js index 0955e82a1..2be6e5b90 100644 --- a/src/chrome/src/run-ui-journal.js +++ b/src/chrome/src/run-ui-journal.js @@ -1,5 +1,6 @@ export const RUN_UI_EVENT_LIMIT = 256; export const RUN_UI_TEXT_DELTA_PERSIST_DELAY_MS = 200; +export const RUN_UI_STREAM_TEXT_LIMIT = 100000; export function createRunRequestId(tabId, supplied = '') { const clean = String(supplied || '').trim(); @@ -128,6 +129,10 @@ export class RunUiJournal { hadError: false, lastError: '', pendingToolCall: null, + streamedText: '', + streamedTextStartSeq: 0, + streamedTextSeq: 0, + streamedTextTruncated: false, startedAt: Date.now(), endedAt: null, }; @@ -161,6 +166,28 @@ export class RunUiJournal { ts: Date.now(), }; snapshot.events.push(event); + if (type === 'text_delta') { + const chunk = String(event.data?.content || ''); + if (!snapshot.streamedText && !snapshot.streamedTextTruncated) { + snapshot.streamedTextStartSeq = event.seq; + } + snapshot.streamedTextSeq = event.seq; + if (!snapshot.streamedTextTruncated) { + const nextText = snapshot.streamedText + chunk; + if (nextText.length <= RUN_UI_STREAM_TEXT_LIMIT) { + snapshot.streamedText = nextText; + } else { + snapshot.streamedText = ''; + snapshot.streamedTextStartSeq = 0; + snapshot.streamedTextTruncated = true; + } + } + } else if (type === 'text' || type === 'tool_call') { + snapshot.streamedText = ''; + snapshot.streamedTextStartSeq = 0; + snapshot.streamedTextSeq = 0; + snapshot.streamedTextTruncated = false; + } while (snapshot.events.length > this.eventLimit) { const removed = snapshot.events.shift(); snapshot.truncatedBeforeSeq = removed?.seq || snapshot.truncatedBeforeSeq; @@ -246,6 +273,17 @@ export class RunUiJournal { } if (typeof snapshot.mode !== 'string') snapshot.mode = ''; if (snapshot.kind !== 'continue' && snapshot.kind !== 'chat') snapshot.kind = 'chat'; + if (typeof snapshot.streamedText !== 'string') snapshot.streamedText = ''; + if (snapshot.streamedText.length > RUN_UI_STREAM_TEXT_LIMIT) { + snapshot.streamedText = ''; + snapshot.streamedTextTruncated = true; + } else if (snapshot.streamedTextTruncated !== true) { + snapshot.streamedTextTruncated = false; + } + const restoredStreamStartSeq = Number(snapshot.streamedTextStartSeq || 0); + const restoredStreamSeq = Number(snapshot.streamedTextSeq || 0); + snapshot.streamedTextStartSeq = Number.isFinite(restoredStreamStartSeq) ? Math.max(0, restoredStreamStartSeq) : 0; + snapshot.streamedTextSeq = Number.isFinite(restoredStreamSeq) ? Math.max(0, restoredStreamSeq) : 0; if (!snapshot.lastPlanResolution || typeof snapshot.lastPlanResolution !== 'object') { snapshot.lastPlanResolution = null; } diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index e84db0711..8eda490dd 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -1453,6 +1453,7 @@ async function flushRenderedTabChat() { persistTimer = null; persistTimerTabId = null; } + flushPendingStreamedAssistantMarkdownRenders(); await persistTabChat(tabId, messagesEl.innerHTML); } @@ -3591,11 +3592,50 @@ async function applyActiveRunState(numericTabId, state) { runAssistantEl.dataset.runRequestId = String(runUi.requestId); if (runUi.runId) runAssistantEl.dataset.runId = String(runUi.runId); const lastRenderedSeq = Number(runAssistantEl.dataset.lastRenderedSeq || 0); + const replayEvents = Array.isArray(runUi.events) ? runUi.events : []; + const snapshotStreamedText = runUi.streamedTextTruncated === true + ? '' + : String(runUi.streamedText || ''); + const streamedTextStartSeq = Number(runUi.streamedTextStartSeq || 0); + const streamedTextSeq = Number(runUi.streamedTextSeq || 0); + const shouldRestoreStreamedText = !!snapshotStreamedText + && Number(runUi.seq || 0) >= lastRenderedSeq + && ( + !isTerminalRunUiStatus(runUi.status) + || lastRenderedSeq < Number(runUi.seq || 0) + ); + const hasReplayableStreamStart = shouldRestoreStreamedText + && streamedTextStartSeq > lastRenderedSeq + && replayEvents.some((event) => ( + event?.type === 'text_delta' + && Number(event.seq || 0) === streamedTextStartSeq + )); + let restoredSnapshotStream = false; + const restoreSnapshotStream = () => { + if (!shouldRestoreStreamedText || restoredSnapshotStream) return; + const textEl = runAssistantEl.querySelector('.message-text'); + if (!textEl) return; + streamedAssistantTextByEl.set(textEl, snapshotStreamedText); + textEl.dataset.streamedAssistantActive = 'true'; + renderStreamedAssistantMarkdownNow(textEl); + restoredSnapshotStream = true; + }; + if (!hasReplayableStreamStart) restoreSnapshotStream(); if (runUi.truncatedBeforeSeq > lastRenderedSeq) { addContextCompactedNote({ message: 'Some hidden-tab progress was compacted.' }); } - for (const event of Array.isArray(runUi.events) ? runUi.events : []) { + for (const event of replayEvents) { if (Number(event?.seq || 0) <= lastRenderedSeq) continue; + const eventSeq = Number(event.seq || 0); + const representedBySnapshotStream = shouldRestoreStreamedText + && event.type === 'text_delta' + && eventSeq >= streamedTextStartSeq + && eventSeq <= streamedTextSeq; + if (representedBySnapshotStream) { + restoreSnapshotStream(); + runAssistantEl.dataset.lastRenderedSeq = String(event.seq); + continue; + } handleAgentUpdateMessage({ target: 'sidepanel', action: 'agent_update', @@ -6854,14 +6894,22 @@ function handleAgentUpdateMessage(msg) { if (textEl && textEl.dataset.suppressToolCallStream !== 'true') { // Keep the raw Markdown separate from the rendered DOM. Reading back // textContent would discard markers such as ** and backticks after - // the first incremental render, corrupting every later chunk. - const nextText = getStreamedAssistantText(textEl) + String(data.content || ''); + // the first incremental render, corrupting every later chunk. A + // restored chat intentionally persists only an active-stream marker, + // while the background journal normally rehydrates the raw source. + // Use visible text only as a legacy/overflow fallback so a new chunk + // cannot erase the existing response before the authoritative + // terminal render restores exact Markdown formatting. + const previousText = getStreamedAssistantText(textEl) + || (hasStreamedAssistantText(textEl) ? textEl.innerText || textEl.textContent : ''); + const nextText = previousText + String(data.content || ''); if (looksLikeRawToolCallText(nextText)) { textEl.textContent = ''; clearStreamedAssistantText(textEl); textEl.dataset.suppressToolCallStream = 'true'; } else { streamedAssistantTextByEl.set(textEl, nextText); + textEl.dataset.streamedAssistantActive = 'true'; scheduleStreamedAssistantMarkdownRender(textEl); } } @@ -6943,6 +6991,9 @@ function handleAgentUpdateMessage(msg) { if (currentAssistantEl && data?.finalContent) { const textEl = currentAssistantEl.querySelector('.message-text'); const streamedText = getStreamedAssistantText(textEl); + const hasStreamedText = hasStreamedAssistantText(textEl); + const visibleStreamedText = streamedText + || (hasStreamedText ? textEl?.innerText || textEl?.textContent || '' : ''); if (textEl && parseCostAllowanceError(data.finalContent)) { clearAssistantTextStreamState(currentAssistantEl); if (!textEl.classList.contains('cost-allowance-error')) { @@ -6953,7 +7004,7 @@ function handleAgentUpdateMessage(msg) { }); } addMessageCopyButton(currentAssistantEl); - } else if (textEl && streamedText) { + } else if (textEl && hasStreamedText) { // Background/restored runs do not necessarily reach the local // sendRunWithReconnect response handler. Finalize their lightweight // live Markdown here, cancelling any queued frame and enabling the @@ -6961,7 +7012,7 @@ function handleAgentUpdateMessage(msg) { // partial text when the user stopped the run; otherwise the terminal // snapshot is authoritative if it differs from the live stream. const terminalContent = data.status === 'stopped' || data.status === 'cancelled' - ? streamedText + ? visibleStreamedText : String(data.finalContent); renderAssistantTextUpdate(currentAssistantEl, terminalContent, { replace: terminalContent !== streamedText, @@ -7714,27 +7765,52 @@ function getStreamedAssistantText(textEl) { return streamedAssistantTextByEl.get(textEl) || textEl?.dataset?.streamedAssistantText || ''; } +function hasStreamedAssistantText(textEl) { + return !!textEl && ( + streamedAssistantTextByEl.has(textEl) + || textEl.dataset.streamedAssistantActive === 'true' + || !!textEl.dataset.streamedAssistantText + ); +} + function clearStreamedAssistantText(textEl) { if (!textEl) return; const frame = streamedAssistantRenderFrameByEl.get(textEl); if (frame != null) cancelAnimationFrame(frame); streamedAssistantRenderFrameByEl.delete(textEl); streamedAssistantTextByEl.delete(textEl); + delete textEl.dataset.streamedAssistantActive; delete textEl.dataset.streamedAssistantText; } +function renderStreamedAssistantMarkdownNow(textEl) { + if (!textEl || textEl.dataset.suppressToolCallStream === 'true') return; + const streamedText = getStreamedAssistantText(textEl); + if (!streamedText) return; + textEl.innerHTML = formatMarkdown(streamedText, { enhance: false }); + scrollToBottom(); +} + function scheduleStreamedAssistantMarkdownRender(textEl) { if (!textEl || streamedAssistantRenderFrameByEl.has(textEl)) return; const frame = requestAnimationFrame(() => { if (streamedAssistantRenderFrameByEl.get(textEl) !== frame) return; streamedAssistantRenderFrameByEl.delete(textEl); - if (textEl.dataset.suppressToolCallStream === 'true') return; - textEl.innerHTML = formatMarkdown(getStreamedAssistantText(textEl), { enhance: false }); - scrollToBottom(); + renderStreamedAssistantMarkdownNow(textEl); }); streamedAssistantRenderFrameByEl.set(textEl, frame); } +function flushPendingStreamedAssistantMarkdownRenders(root = messagesEl) { + root?.querySelectorAll?.('.message-text[data-streamed-assistant-active="true"]').forEach((textEl) => { + const frame = streamedAssistantRenderFrameByEl.get(textEl); + if (frame == null) return; + cancelAnimationFrame(frame); + streamedAssistantRenderFrameByEl.delete(textEl); + renderStreamedAssistantMarkdownNow(textEl); + }); +} + function clearAssistantTextStreamState(assistantEl) { const textEl = assistantEl?.querySelector('.message-text'); if (!textEl) return; @@ -7772,9 +7848,10 @@ function renderAssistantTextUpdate(assistantEl, content, options = {}) { } const streamedText = getStreamedAssistantText(textEl); + const restoredStreamNeedsReplacement = hasStreamedAssistantText(textEl) && !streamedText; const isDuplicateStreamFinal = streamedText && streamedText === String(content); - if (options.replace === true) { + if (options.replace === true || restoredStreamNeedsReplacement) { // A rejected streamed terminal must replace its already-rendered deltas // even in Verbose mode; appending would leave the invalid plan visible. // Empty content clears the bubble (plan-only retry before recovery tools). diff --git a/src/firefox/src/run-ui-journal.js b/src/firefox/src/run-ui-journal.js index 0955e82a1..2be6e5b90 100644 --- a/src/firefox/src/run-ui-journal.js +++ b/src/firefox/src/run-ui-journal.js @@ -1,5 +1,6 @@ export const RUN_UI_EVENT_LIMIT = 256; export const RUN_UI_TEXT_DELTA_PERSIST_DELAY_MS = 200; +export const RUN_UI_STREAM_TEXT_LIMIT = 100000; export function createRunRequestId(tabId, supplied = '') { const clean = String(supplied || '').trim(); @@ -128,6 +129,10 @@ export class RunUiJournal { hadError: false, lastError: '', pendingToolCall: null, + streamedText: '', + streamedTextStartSeq: 0, + streamedTextSeq: 0, + streamedTextTruncated: false, startedAt: Date.now(), endedAt: null, }; @@ -161,6 +166,28 @@ export class RunUiJournal { ts: Date.now(), }; snapshot.events.push(event); + if (type === 'text_delta') { + const chunk = String(event.data?.content || ''); + if (!snapshot.streamedText && !snapshot.streamedTextTruncated) { + snapshot.streamedTextStartSeq = event.seq; + } + snapshot.streamedTextSeq = event.seq; + if (!snapshot.streamedTextTruncated) { + const nextText = snapshot.streamedText + chunk; + if (nextText.length <= RUN_UI_STREAM_TEXT_LIMIT) { + snapshot.streamedText = nextText; + } else { + snapshot.streamedText = ''; + snapshot.streamedTextStartSeq = 0; + snapshot.streamedTextTruncated = true; + } + } + } else if (type === 'text' || type === 'tool_call') { + snapshot.streamedText = ''; + snapshot.streamedTextStartSeq = 0; + snapshot.streamedTextSeq = 0; + snapshot.streamedTextTruncated = false; + } while (snapshot.events.length > this.eventLimit) { const removed = snapshot.events.shift(); snapshot.truncatedBeforeSeq = removed?.seq || snapshot.truncatedBeforeSeq; @@ -246,6 +273,17 @@ export class RunUiJournal { } if (typeof snapshot.mode !== 'string') snapshot.mode = ''; if (snapshot.kind !== 'continue' && snapshot.kind !== 'chat') snapshot.kind = 'chat'; + if (typeof snapshot.streamedText !== 'string') snapshot.streamedText = ''; + if (snapshot.streamedText.length > RUN_UI_STREAM_TEXT_LIMIT) { + snapshot.streamedText = ''; + snapshot.streamedTextTruncated = true; + } else if (snapshot.streamedTextTruncated !== true) { + snapshot.streamedTextTruncated = false; + } + const restoredStreamStartSeq = Number(snapshot.streamedTextStartSeq || 0); + const restoredStreamSeq = Number(snapshot.streamedTextSeq || 0); + snapshot.streamedTextStartSeq = Number.isFinite(restoredStreamStartSeq) ? Math.max(0, restoredStreamStartSeq) : 0; + snapshot.streamedTextSeq = Number.isFinite(restoredStreamSeq) ? Math.max(0, restoredStreamSeq) : 0; if (!snapshot.lastPlanResolution || typeof snapshot.lastPlanResolution !== 'object') { snapshot.lastPlanResolution = null; } diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 3b9fc031d..a88bd1f7d 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -1318,6 +1318,7 @@ async function flushRenderedTabChat() { persistTimer = null; persistTimerTabId = null; } + flushPendingStreamedAssistantMarkdownRenders(); await persistTabChat(tabId, messagesEl.innerHTML); } @@ -3435,11 +3436,50 @@ async function applyActiveRunState(numericTabId, state) { runAssistantEl.dataset.runRequestId = String(runUi.requestId); if (runUi.runId) runAssistantEl.dataset.runId = String(runUi.runId); const lastRenderedSeq = Number(runAssistantEl.dataset.lastRenderedSeq || 0); + const replayEvents = Array.isArray(runUi.events) ? runUi.events : []; + const snapshotStreamedText = runUi.streamedTextTruncated === true + ? '' + : String(runUi.streamedText || ''); + const streamedTextStartSeq = Number(runUi.streamedTextStartSeq || 0); + const streamedTextSeq = Number(runUi.streamedTextSeq || 0); + const shouldRestoreStreamedText = !!snapshotStreamedText + && Number(runUi.seq || 0) >= lastRenderedSeq + && ( + !isTerminalRunUiStatus(runUi.status) + || lastRenderedSeq < Number(runUi.seq || 0) + ); + const hasReplayableStreamStart = shouldRestoreStreamedText + && streamedTextStartSeq > lastRenderedSeq + && replayEvents.some((event) => ( + event?.type === 'text_delta' + && Number(event.seq || 0) === streamedTextStartSeq + )); + let restoredSnapshotStream = false; + const restoreSnapshotStream = () => { + if (!shouldRestoreStreamedText || restoredSnapshotStream) return; + const textEl = runAssistantEl.querySelector('.message-text'); + if (!textEl) return; + streamedAssistantTextByEl.set(textEl, snapshotStreamedText); + textEl.dataset.streamedAssistantActive = 'true'; + renderStreamedAssistantMarkdownNow(textEl); + restoredSnapshotStream = true; + }; + if (!hasReplayableStreamStart) restoreSnapshotStream(); if (runUi.truncatedBeforeSeq > lastRenderedSeq) { addContextCompactedNote({ message: 'Some hidden-tab progress was compacted.' }); } - for (const event of Array.isArray(runUi.events) ? runUi.events : []) { + for (const event of replayEvents) { if (Number(event?.seq || 0) <= lastRenderedSeq) continue; + const eventSeq = Number(event.seq || 0); + const representedBySnapshotStream = shouldRestoreStreamedText + && event.type === 'text_delta' + && eventSeq >= streamedTextStartSeq + && eventSeq <= streamedTextSeq; + if (representedBySnapshotStream) { + restoreSnapshotStream(); + runAssistantEl.dataset.lastRenderedSeq = String(event.seq); + continue; + } handleAgentUpdateMessage({ target: 'sidepanel', action: 'agent_update', tabId: numericTabId, requestId: runUi.requestId, runId: runUi.runId, seq: event.seq, @@ -6375,14 +6415,22 @@ function handleAgentUpdateMessage(msg) { if (textEl && textEl.dataset.suppressToolCallStream !== 'true') { // Keep the raw Markdown separate from the rendered DOM. Reading back // textContent would discard markers such as ** and backticks after - // the first incremental render, corrupting every later chunk. - const nextText = getStreamedAssistantText(textEl) + String(data.content || ''); + // the first incremental render, corrupting every later chunk. A + // restored chat intentionally persists only an active-stream marker, + // while the background journal normally rehydrates the raw source. + // Use visible text only as a legacy/overflow fallback so a new chunk + // cannot erase the existing response before the authoritative + // terminal render restores exact Markdown formatting. + const previousText = getStreamedAssistantText(textEl) + || (hasStreamedAssistantText(textEl) ? textEl.innerText || textEl.textContent : ''); + const nextText = previousText + String(data.content || ''); if (looksLikeRawToolCallText(nextText)) { textEl.textContent = ''; clearStreamedAssistantText(textEl); textEl.dataset.suppressToolCallStream = 'true'; } else { streamedAssistantTextByEl.set(textEl, nextText); + textEl.dataset.streamedAssistantActive = 'true'; scheduleStreamedAssistantMarkdownRender(textEl); } } @@ -6457,6 +6505,9 @@ function handleAgentUpdateMessage(msg) { if (currentAssistantEl && data?.finalContent) { const textEl = currentAssistantEl.querySelector('.message-text'); const streamedText = getStreamedAssistantText(textEl); + const hasStreamedText = hasStreamedAssistantText(textEl); + const visibleStreamedText = streamedText + || (hasStreamedText ? textEl?.innerText || textEl?.textContent || '' : ''); if (textEl && parseCostAllowanceError(data.finalContent)) { clearAssistantTextStreamState(currentAssistantEl); if (!textEl.classList.contains('cost-allowance-error')) { @@ -6467,7 +6518,7 @@ function handleAgentUpdateMessage(msg) { }); } addMessageCopyButton(currentAssistantEl); - } else if (textEl && streamedText) { + } else if (textEl && hasStreamedText) { // Background/restored runs do not necessarily reach the local // sendRunWithReconnect response handler. Finalize their lightweight // live Markdown here, cancelling any queued frame and enabling the @@ -6475,7 +6526,7 @@ function handleAgentUpdateMessage(msg) { // partial text when the user stopped the run; otherwise the terminal // snapshot is authoritative if it differs from the live stream. const terminalContent = data.status === 'stopped' || data.status === 'cancelled' - ? streamedText + ? visibleStreamedText : String(data.finalContent); renderAssistantTextUpdate(currentAssistantEl, terminalContent, { replace: terminalContent !== streamedText, @@ -7363,27 +7414,52 @@ function getStreamedAssistantText(textEl) { return streamedAssistantTextByEl.get(textEl) || textEl?.dataset?.streamedAssistantText || ''; } +function hasStreamedAssistantText(textEl) { + return !!textEl && ( + streamedAssistantTextByEl.has(textEl) + || textEl.dataset.streamedAssistantActive === 'true' + || !!textEl.dataset.streamedAssistantText + ); +} + function clearStreamedAssistantText(textEl) { if (!textEl) return; const frame = streamedAssistantRenderFrameByEl.get(textEl); if (frame != null) cancelAnimationFrame(frame); streamedAssistantRenderFrameByEl.delete(textEl); streamedAssistantTextByEl.delete(textEl); + delete textEl.dataset.streamedAssistantActive; delete textEl.dataset.streamedAssistantText; } +function renderStreamedAssistantMarkdownNow(textEl) { + if (!textEl || textEl.dataset.suppressToolCallStream === 'true') return; + const streamedText = getStreamedAssistantText(textEl); + if (!streamedText) return; + textEl.innerHTML = formatMarkdown(streamedText, { enhance: false }); + scrollToBottom(); +} + function scheduleStreamedAssistantMarkdownRender(textEl) { if (!textEl || streamedAssistantRenderFrameByEl.has(textEl)) return; const frame = requestAnimationFrame(() => { if (streamedAssistantRenderFrameByEl.get(textEl) !== frame) return; streamedAssistantRenderFrameByEl.delete(textEl); - if (textEl.dataset.suppressToolCallStream === 'true') return; - textEl.innerHTML = formatMarkdown(getStreamedAssistantText(textEl), { enhance: false }); - scrollToBottom(); + renderStreamedAssistantMarkdownNow(textEl); }); streamedAssistantRenderFrameByEl.set(textEl, frame); } +function flushPendingStreamedAssistantMarkdownRenders(root = messagesEl) { + root?.querySelectorAll?.('.message-text[data-streamed-assistant-active="true"]').forEach((textEl) => { + const frame = streamedAssistantRenderFrameByEl.get(textEl); + if (frame == null) return; + cancelAnimationFrame(frame); + streamedAssistantRenderFrameByEl.delete(textEl); + renderStreamedAssistantMarkdownNow(textEl); + }); +} + function clearAssistantTextStreamState(assistantEl) { const textEl = assistantEl?.querySelector('.message-text'); if (!textEl) return; @@ -7421,9 +7497,10 @@ function renderAssistantTextUpdate(assistantEl, content, options = {}) { } const streamedText = getStreamedAssistantText(textEl); + const restoredStreamNeedsReplacement = hasStreamedAssistantText(textEl) && !streamedText; const isDuplicateStreamFinal = streamedText && streamedText === String(content); - if (options.replace === true) { + if (options.replace === true || restoredStreamNeedsReplacement) { // A rejected streamed terminal must replace its already-rendered deltas // even in Verbose mode; appending would leave the invalid plan visible. // Empty content clears the bubble (plan-only retry before recovery tools). diff --git a/test/run.js b/test/run.js index 15d9cdfa1..9489daedb 100644 --- a/test/run.js +++ b/test/run.js @@ -13888,18 +13888,19 @@ test('sidepanel suppresses streamed raw tool-call text before rendering tool ste assert.match(panel, /function looksLikeRawToolCallText\(text\) \{[\s\S]*?ref_id\\s\*/, `${label}: raw tool-call detector should recognize ref_id payloads`); assert.match(panel, /textEl\.dataset\.suppressToolCallStream = 'true';/, `${label}: text_delta should suppress later raw tool-call chunks`); assert.match(panel, /const streamedAssistantTextByEl = new WeakMap\(\);/, `${label}: streamed final dedupe state should not be stored in serialized DOM attributes`); - assert.match(panel, /const nextText = getStreamedAssistantText\(textEl\) \+ String\(data\.content \|\| ''\);/, `${label}: text_delta should append to raw Markdown rather than reading rendered DOM text`); - assert.match(panel, /streamedAssistantTextByEl\.set\(textEl, nextText\);\s*scheduleStreamedAssistantMarkdownRender\(textEl\);/, `${label}: text_delta should retain and schedule incremental Markdown rendering`); - assert.match(panel, /const streamedAssistantRenderFrameByEl = new WeakMap\(\);[\s\S]*?function scheduleStreamedAssistantMarkdownRender\(textEl\)[\s\S]*?requestAnimationFrame\([\s\S]*?textEl\.innerHTML = formatMarkdown\(getStreamedAssistantText\(textEl\), \{ enhance: false \}\);[\s\S]*?scrollToBottom\(\);/, `${label}: live Markdown should render at most once per animation frame before following output`); + assert.match(panel, /const previousText = getStreamedAssistantText\(textEl\)\s*\|\| \(hasStreamedAssistantText\(textEl\) \? textEl\.innerText \|\| textEl\.textContent : ''\);\s*const nextText = previousText \+ String\(data\.content \|\| ''\);/, `${label}: text_delta should append to raw Markdown while legacy restored streams fall back to visible text without dropping prior output`); + assert.match(panel, /streamedAssistantTextByEl\.set\(textEl, nextText\);\s*textEl\.dataset\.streamedAssistantActive = 'true';\s*scheduleStreamedAssistantMarkdownRender\(textEl\);/, `${label}: text_delta should retain raw Markdown, persist only an active-stream marker, and schedule incremental rendering`); + assert.match(panel, /const streamedAssistantRenderFrameByEl = new WeakMap\(\);[\s\S]*?function renderStreamedAssistantMarkdownNow\(textEl\)[\s\S]*?textEl\.innerHTML = formatMarkdown\(streamedText, \{ enhance: false \}\);[\s\S]*?scrollToBottom\(\);[\s\S]*?function scheduleStreamedAssistantMarkdownRender\(textEl\)[\s\S]*?requestAnimationFrame\([\s\S]*?renderStreamedAssistantMarkdownNow\(textEl\);/, `${label}: live Markdown should render at most once per animation frame before following output`); assert.match(panel, /function clearStreamedAssistantText\(textEl\)[\s\S]*?cancelAnimationFrame\(frame\);[\s\S]*?streamedAssistantRenderFrameByEl\.delete\(textEl\);/, `${label}: terminal and tool transitions should cancel pending stream renders`); + assert.match(panel, /async function flushRenderedTabChat\(\)[\s\S]*?flushPendingStreamedAssistantMarkdownRenders\(\);\s*await persistTabChat\(tabId, messagesEl\.innerHTML\);/, `${label}: tab switches should render a queued frame before serializing its last acknowledged stream chunk`); assert.doesNotMatch(panel, /const nextText = textEl\.textContent \+ data\.content;/, `${label}: rendered Markdown must never become the source for later deltas`); assert.match(panel, /function formatMarkdown\(text, options = \{\}\)[\s\S]*?const enhance = options\.enhance !== false;[\s\S]*?const highlighted = enhance \? highlightCode\(block\.code, block\.lang\) : escapeHtml\(block\.code\);[\s\S]*?if \(enhance\) scheduleMathRender\(\);[\s\S]*?if \(enhance && codeBlocks\.length > 0\)/, `${label}: syntax highlighting and interactive Markdown enhancements should wait for the terminal render`); - assert.match(panel, /case 'run_complete':[\s\S]*?const streamedText = getStreamedAssistantText\(textEl\);[\s\S]*?else if \(textEl && streamedText\)[\s\S]*?const terminalContent = data\.status === 'stopped' \|\| data\.status === 'cancelled'[\s\S]*?renderAssistantTextUpdate\(currentAssistantEl, terminalContent, \{[\s\S]*?replace: terminalContent !== streamedText,[\s\S]*?else if \(textEl && !textEl\.textContent\.trim\(\)\)/, `${label}: restored/background streams should receive one enhanced authoritative terminal render while stopped runs preserve partial text`); + assert.match(panel, /case 'run_complete':[\s\S]*?const streamedText = getStreamedAssistantText\(textEl\);[\s\S]*?const hasStreamedText = hasStreamedAssistantText\(textEl\);[\s\S]*?const visibleStreamedText = streamedText[\s\S]*?\|\| \(hasStreamedText \? textEl\?\.innerText \|\| textEl\?\.textContent \|\| '' : ''\);[\s\S]*?else if \(textEl && hasStreamedText\)[\s\S]*?const terminalContent = data\.status === 'stopped' \|\| data\.status === 'cancelled'[\s\S]*?\? visibleStreamedText[\s\S]*?renderAssistantTextUpdate\(currentAssistantEl, terminalContent, \{[\s\S]*?replace: terminalContent !== streamedText,[\s\S]*?else if \(textEl && !textEl\.textContent\.trim\(\)\)/, `${label}: restored/background streams should receive one enhanced authoritative terminal render while stopped runs preserve visible partial text`); assert.doesNotMatch(panel, /dataset\.streamedAssistantText\s*=/, `${label}: streamed text must not be serialized as a data attribute`); assert.match(panel, /getStreamedAssistantText\(textEl\) === String\(res\.content\)[\s\S]*?renderAssistantTextUpdate\(assistantEl, res\.content\);/, `${label}: completed streams should format the visible final text in place`); assert.match(panel, /clearAssistantTextStreamState\(assistantEl\);/, `${label}: run completion should clear transient streamed-text state before persistence`); assert.match(panel, /case 'text':[\s\S]*?\(data\.content \|\| data\.replace === true\)[\s\S]*?renderAssistantTextUpdate\(currentAssistantEl, data\.content \|\| '', \{ replace: data\.replace === true \}\);/, `${label}: text updates should forward explicit replacement requests, including empty clears`); - assert.match(panel, /function renderAssistantTextUpdate\(assistantEl, content, options = \{\}\) \{[\s\S]*?isDuplicateStreamFinal[\s\S]*?if \(options\.replace === true\) \{[\s\S]*?if \(content\) \{[\s\S]*?textEl\.innerHTML = formatMarkdown\(content\);[\s\S]*?streamedAssistantTextByEl\.set\(textEl, String\(content\)\);[\s\S]*?\} else \{[\s\S]*?textEl\.textContent = '';[\s\S]*?clearStreamedAssistantText\(textEl\);[\s\S]*?\} else if \(verboseMode/, `${label}: explicit replacements should overwrite or clear verbose streamed text`); + assert.match(panel, /function renderAssistantTextUpdate\(assistantEl, content, options = \{\}\) \{[\s\S]*?const restoredStreamNeedsReplacement = hasStreamedAssistantText\(textEl\) && !streamedText;[\s\S]*?isDuplicateStreamFinal[\s\S]*?if \(options\.replace === true \|\| restoredStreamNeedsReplacement\) \{[\s\S]*?if \(content\) \{[\s\S]*?textEl\.innerHTML = formatMarkdown\(content\);[\s\S]*?streamedAssistantTextByEl\.set\(textEl, String\(content\)\);[\s\S]*?\} else \{[\s\S]*?textEl\.textContent = '';[\s\S]*?clearStreamedAssistantText\(textEl\);[\s\S]*?\} else if \(verboseMode/, `${label}: explicit and restored-stream replacements should overwrite or clear verbose streamed text`); assert.match(panel, /function renderAssistantTextUpdate\(assistantEl, content, options = \{\}\) \{[\s\S]*?isDuplicateStreamFinal[\s\S]*?textEl\.innerHTML = formatMarkdown\(content\);/, `${label}: final text should format an already visible stream instead of appending a duplicate`); const start = panel.indexOf("case 'tool_call':"); const end = panel.indexOf("case 'tool_result':", start); @@ -44863,6 +44864,9 @@ test('run UI journal: streamed deltas coalesce durable snapshots without delayin journal.record(40, `${label}-stream`, 'text_delta', { content: 'two' }); assert.equal(persisted.length, 1, `${label}: deltas should not clone/write a durable snapshot immediately`); assert.equal(timers.size, 1, `${label}: consecutive deltas should share one trailing timer`); + assert.equal(journal.get(40).streamedText, 'onetwo', `${label}: the journal should retain exact raw Markdown across panel remounts`); + assert.equal(journal.get(40).streamedTextStartSeq, 1, `${label}: the raw stream snapshot should retain its first represented sequence`); + assert.equal(journal.get(40).streamedTextSeq, 2, `${label}: the raw stream snapshot should retain its latest represented sequence`); timers.values().next().value(); await Promise.resolve(); @@ -44872,10 +44876,16 @@ test('run UI journal: streamed deltas coalesce durable snapshots without delayin journal.record(40, `${label}-stream`, 'thinking', { content: 'done streaming' }); assert.equal(timers.size, 0, `${label}: a non-delta update should cancel the trailing timer`); assert.equal(persisted.at(-1).seq, 4, `${label}: a non-delta update should persist all prior deltas immediately`); + journal.acknowledge(40, `${label}-stream`, 4); + assert.equal(journal.get(40).events.length, 0, `${label}: acknowledged events should still be released`); + assert.equal(journal.get(40).streamedText, 'onetwothree', `${label}: acknowledgement must retain the cumulative raw stream for later remounts`); journal.record(40, `${label}-stream`, 'text_delta', { content: 'cancelled' }); persistence.cancel(40); assert.equal(timers.size, 0, `${label}: clearing a run should discard its deferred persistence timer`); + journal.record(40, `${label}-stream`, 'tool_call', { name: 'click', args: {} }); + assert.equal(journal.get(40).streamedText, '', `${label}: a tool transition should clear the preceding streamed prose snapshot`); + assert.equal(journal.get(40).streamedTextSeq, 0, `${label}: a tool transition should clear the represented stream sequence`); const background = fs.readFileSync(path.join(ROOT, backgroundRel), 'utf8'); assert.match(background, /change\?\.eventType === 'text_delta'[\s\S]*?runUiSnapshotPersistence\.defer\(tabId, snapshot\)/, `${label}: background should defer only text deltas`); @@ -46043,6 +46053,7 @@ test('per-tab run UI protocol is wired into both backgrounds and side panels', ( assert.match(panel, /const localRunRequestIds = new Map\(\);/, `${label}: local request ownership should be tab scoped`); assert.match(panel, /function ensureCurrentRunAssistant\(msg\)/, `${label}: rendering should bind updates to their request bubble`); assert.match(panel, /const runAssistantEl = messagesEl\.querySelector[\s\S]*?runAssistantEl\.dataset\.lastRenderedSeq = String\(event\.seq\);[\s\S]*?const renderedSeq = Number\(runAssistantEl\.dataset\.lastRenderedSeq/, `${label}: terminal replay should retain its assistant reference through acknowledgement`); + assert.match(panel, /const snapshotStreamedText = runUi\.streamedTextTruncated === true[\s\S]*?const restoreSnapshotStream = \(\) => \{[\s\S]*?streamedAssistantTextByEl\.set\(textEl, snapshotStreamedText\);[\s\S]*?renderStreamedAssistantMarkdownNow\(textEl\);[\s\S]*?const representedBySnapshotStream =[\s\S]*?event\.type === 'text_delta'[\s\S]*?if \(representedBySnapshotStream\) \{[\s\S]*?continue;/, `${label}: remounts should rehydrate exact raw Markdown and skip duplicate replay of represented deltas`); assert.match(panel, /const locallyOwnedEvent = [\s\S]*?if \(!locallyOwnedEvent\) \{[\s\S]*?clearPlanReviewActiveRun/, `${label}: live terminal broadcasts should not tear down their locally owned request`); assert.match(panel, /const sequencedRequestAssistantEl = [\s\S]*?if \(sequencedRequestAssistantEl[\s\S]*?return;[\s\S]*?const eventAssistantEl = ensureCurrentRunAssistant\(msg\);/, `${label}: duplicate queued events should be rejected before rebinding the completed assistant as current`); assert.match(panel, /Number\(event\?\.seq \|\| 0\) <= lastRenderedSeq\) continue;/, `${label}: remount should replay only unseen events`);