From d586b6dc41ba94104a7a4caa15abcebe032fdb61 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 23 Jul 2026 17:30:41 +0300 Subject: [PATCH 01/17] Stop active runs before starting new chats --- src/chrome/src/background.js | 18 ++++++++++++++++++ src/chrome/src/ui/sidepanel.js | 1 + src/firefox/src/background.js | 18 ++++++++++++++++++ src/firefox/src/ui/sidepanel.js | 7 +++++-- test/run.js | 20 ++++++++++++++++++-- 5 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index 16121ddb1..6ec7d6f02 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -1483,6 +1483,23 @@ function cancelDetachedRunStart(tabId) { return true; } +async function stopActiveRunBeforeConversationClear(tabId) { + const activeStart = detachedRunStarts.get(tabId) || null; + const running = agent.activeRunState(tabId)?.running === true; + if (!activeStart && !running) return false; + + cancelDetachedRunStart(tabId); + try { agent.abort(tabId); } catch { /* best effort */ } + + // Keep the old conversation alive until its run has unwound. Clearing it + // first leaves the per-tab run guard active while the UI already looks like + // a fresh chat, so the next send fails with "run already in progress". + if (activeStart?.promise) { + await activeStart.promise.catch(() => {}); + } + return true; +} + function acquireRunKeepalive() { let released = false; const touch = () => { @@ -2339,6 +2356,7 @@ async function handleMessage(msg, sender) { if (tabId) { const conversationId = await agent.getConversationId(tabId); await scheduler.cancelForConversation(tabId, conversationId); + await stopActiveRunBeforeConversationClear(tabId); agent.clearConversation(tabId); clearRunUiSnapshot(tabId); } diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index f55f60a4c..deb6f6c48 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -10011,6 +10011,7 @@ document.addEventListener('wb-locale-changed', () => { clearBtn.addEventListener('click', async () => { const tabId = currentTabId; if (!window.confirm(t('sp.clear.confirm'))) return; + if (isTabProcessing(tabId)) await abortRun(); await sendToBackground('clear_conversation', { tabId }); await renderClearedConversationForTab(tabId); }); diff --git a/src/firefox/src/background.js b/src/firefox/src/background.js index a95a91ee8..ead57aed8 100644 --- a/src/firefox/src/background.js +++ b/src/firefox/src/background.js @@ -1582,6 +1582,23 @@ function cancelDetachedRunStart(tabId) { return true; } +async function stopActiveRunBeforeConversationClear(tabId) { + const activeStart = detachedRunStarts.get(tabId) || null; + const running = agent.activeRunState(tabId)?.running === true; + if (!activeStart && !running) return false; + + cancelDetachedRunStart(tabId); + try { agent.abort(tabId); } catch { /* best effort */ } + + // Keep the old conversation alive until its run has unwound. Clearing it + // first leaves the per-tab run guard active while the UI already looks like + // a fresh chat, so the next send fails with "run already in progress". + if (activeStart?.promise) { + await activeStart.promise.catch(() => {}); + } + return true; +} + function acquireRunKeepalive() { let released = false; const touch = () => { @@ -2049,6 +2066,7 @@ async function handleMessage(msg, sender) { if (tabId) { const conversationId = await agent.getConversationId(tabId); await scheduler.cancelForConversation(tabId, conversationId); + await stopActiveRunBeforeConversationClear(tabId); agent.clearConversation(tabId); clearRunUiSnapshot(tabId); } diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index e752bdb8d..8cd309a18 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -9043,7 +9043,7 @@ modeDevBtn?.addEventListener('click', async () => { // --- Stop / Abort --- -stopBtn.addEventListener('click', async () => { +async function abortRun() { const tabId = currentTabId; if (!isTabProcessing(tabId)) return; const requestId = String( @@ -9081,7 +9081,9 @@ stopBtn.addEventListener('click', async () => { await drainQueuedPromptsAfterRunSettles(); } }, 3000); // safety timeout if background takes too long -}); +} + +stopBtn.addEventListener('click', abortRun); // --- Voice input (mic dictation, issue #210) --- // Web Speech API: well-supported in Chrome, absent in stock Firefox (which @@ -9461,6 +9463,7 @@ document.addEventListener('wb-locale-changed', () => { clearBtn.addEventListener('click', async () => { const tabId = currentTabId; if (!window.confirm(t('sp.clear.confirm'))) return; + if (isTabProcessing(tabId)) await abortRun(); await sendToBackground('clear_conversation', { tabId }); await renderClearedConversationForTab(tabId); }); diff --git a/test/run.js b/test/run.js index ff9056eed..7c4116f98 100644 --- a/test/run.js +++ b/test/run.js @@ -12264,12 +12264,28 @@ test('sidepanel New conversation uses a message-plus icon and keeps its confirma const clearBody = panel.slice(clearStart, panel.indexOf('\n});', clearStart) + 4); assert.match( clearBody, - /if \(!window\.confirm\(t\('sp\.clear\.confirm'\)\)\) return;[\s\S]*?await sendToBackground\('clear_conversation', \{ tabId \}\);/, - `${label}: icon change must preserve confirmation before clearing`, + /if \(!window\.confirm\(t\('sp\.clear\.confirm'\)\)\) return;[\s\S]*?if \(isTabProcessing\(tabId\)\) await abortRun\(\);[\s\S]*?await sendToBackground\('clear_conversation', \{ tabId \}\);/, + `${label}: confirmed New conversation should stop an active run before clearing`, ); } }); +test('background waits for an active run to stop before clearing its conversation', () => { + for (const [label, backgroundRel] of [ + ['chrome', 'src/chrome/src/background.js'], + ['firefox', 'src/firefox/src/background.js'], + ]) { + const background = fs.readFileSync(path.join(ROOT, backgroundRel), 'utf8'); + const helperMatch = background.match(/async function stopActiveRunBeforeConversationClear\(tabId\) \{([\s\S]*?)\n\}/); + assert.ok(helperMatch, `${label}: active-run clear helper missing`); + assert.match(helperMatch[1], /cancelDetachedRunStart\(tabId\);[\s\S]*?agent\.abort\(tabId\);[\s\S]*?await activeStart\.promise\.catch\(\(\) => \{\}\);/, `${label}: clear helper should cancel, abort, and await the active run`); + + const clearStart = background.indexOf("case 'clear_conversation':"); + const clearBody = background.slice(clearStart, background.indexOf("case 'compact_conversation':", clearStart)); + assert.match(clearBody, /await scheduler\.cancelForConversation\(tabId, conversationId\);[\s\S]*?await stopActiveRunBeforeConversationClear\(tabId\);[\s\S]*?agent\.clearConversation\(tabId\);/, `${label}: conversation state should clear only after the active run settles`); + } +}); + test('sidepanel provider and language menus glide one highlight between hovered or focused options', () => { for (const [label, prefix] of [ ['chrome', 'src/chrome'], From 0dbc0b5f012095754317844dda0f05cc59d04a2d Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 23 Jul 2026 17:47:32 +0300 Subject: [PATCH 02/17] Wait for direct runs before clearing chats --- src/chrome/src/background.js | 6 ++++++ src/firefox/src/background.js | 6 ++++++ test/run.js | 3 ++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index 6ec7d6f02..d4bf360a8 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -1497,6 +1497,12 @@ async function stopActiveRunBeforeConversationClear(tabId) { if (activeStart?.promise) { await activeStart.promise.catch(() => {}); } + // Direct chat/chat_stream callers do not have a detached-start promise. + // Do not clear their conversation until processMessage's finally block has + // released the agent's per-tab run guard. + while (agent.activeRunState(tabId)?.running) { + await new Promise(resolve => setTimeout(resolve, 50)); + } return true; } diff --git a/src/firefox/src/background.js b/src/firefox/src/background.js index ead57aed8..f9ae0f493 100644 --- a/src/firefox/src/background.js +++ b/src/firefox/src/background.js @@ -1596,6 +1596,12 @@ async function stopActiveRunBeforeConversationClear(tabId) { if (activeStart?.promise) { await activeStart.promise.catch(() => {}); } + // Direct chat/chat_stream callers do not have a detached-start promise. + // Do not clear their conversation until processMessage's finally block has + // released the agent's per-tab run guard. + while (agent.activeRunState(tabId)?.running) { + await new Promise(resolve => setTimeout(resolve, 50)); + } return true; } diff --git a/test/run.js b/test/run.js index 7c4116f98..5ac7f0fb3 100644 --- a/test/run.js +++ b/test/run.js @@ -12278,7 +12278,8 @@ test('background waits for an active run to stop before clearing its conversatio const background = fs.readFileSync(path.join(ROOT, backgroundRel), 'utf8'); const helperMatch = background.match(/async function stopActiveRunBeforeConversationClear\(tabId\) \{([\s\S]*?)\n\}/); assert.ok(helperMatch, `${label}: active-run clear helper missing`); - assert.match(helperMatch[1], /cancelDetachedRunStart\(tabId\);[\s\S]*?agent\.abort\(tabId\);[\s\S]*?await activeStart\.promise\.catch\(\(\) => \{\}\);/, `${label}: clear helper should cancel, abort, and await the active run`); + assert.match(helperMatch[1], /cancelDetachedRunStart\(tabId\);[\s\S]*?agent\.abort\(tabId\);[\s\S]*?await activeStart\.promise\.catch\(\(\) => \{\}\);/, `${label}: clear helper should cancel, abort, and await detached runs`); + assert.match(helperMatch[1], /while \(agent\.activeRunState\(tabId\)\?\.running\) \{[\s\S]*?setTimeout\(resolve, 50\)/, `${label}: clear helper should also wait for direct chat runs to release the agent guard`); const clearStart = background.indexOf("case 'clear_conversation':"); const clearBody = background.slice(clearStart, background.indexOf("case 'compact_conversation':", clearStart)); From 76049fb53bb1cb35ff690be2179842f195f2c54a Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 23 Jul 2026 17:59:56 +0300 Subject: [PATCH 03/17] Settle local run follower before clearing chat --- src/chrome/src/run-reconnect.js | 4 ++++ src/chrome/src/ui/sidepanel.js | 16 ++++++++++++++- src/firefox/src/run-reconnect.js | 4 ++++ src/firefox/src/ui/sidepanel.js | 16 ++++++++++++++- test/run.js | 35 ++++++++++++++++++++++++++++++++ 5 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/chrome/src/run-reconnect.js b/src/chrome/src/run-reconnect.js index 526ac4f01..f20aab798 100644 --- a/src/chrome/src/run-reconnect.js +++ b/src/chrome/src/run-reconnect.js @@ -80,6 +80,7 @@ export async function runDetachedWithReconnect({ onStatus = () => {}, onState = () => {}, shouldResume = () => true, + isCancelled = () => false, wait = defaultWait, pollIntervalMs = 1200, reconnectDelaysMs = [250, 500, 1000, 2000, 4000], @@ -108,6 +109,7 @@ export async function runDetachedWithReconnect({ let skipStart = probeFirst; while (true) { + if (isCancelled()) throw new Error('Run recovery was cancelled.'); let startAcknowledged = skipStart; if (skipStart) { skipStart = false; @@ -133,10 +135,12 @@ export async function runDetachedWithReconnect({ let wasDisconnected = startWasUncertain; while (true) { + if (isCancelled()) throw new Error('Run recovery was cancelled.'); const reconnectDelay = reconnectDelaysMs[ Math.min(connectionFailures, Math.max(0, reconnectDelaysMs.length - 1)) ] ?? pollIntervalMs; await wait(wasDisconnected ? reconnectDelay : pollIntervalMs); + if (isCancelled()) throw new Error('Run recovery was cancelled.'); let state; try { diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index deb6f6c48..5b4469ed7 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -947,6 +947,7 @@ const awaitingPlanReviewTabs = new Set(); const processingTabs = new Set(); const abortRequestedTabs = new Set(); const localRunRequestIds = new Map(); +const localRunFollowers = new Map(); const cancelledRunRecoveryRequestIds = new Set(); const adoptedRunRecoveryRequestIds = new Set(); let recommendationsRequestId = 0; @@ -9306,7 +9307,7 @@ async function sendRunWithReconnect(initialAction, payload, recoveryOptions = {} const tabId = Number(payload?.tabId); const requestId = String(payload?.requestId || ''); cancelledRunRecoveryRequestIds.delete(requestId); - return runDetachedWithReconnect({ + const promise = runDetachedWithReconnect({ initialAction, payload, start: (action, nextPayload) => sendToBackground(action, nextPayload), @@ -9316,6 +9317,8 @@ async function sendRunWithReconnect(initialAction, payload, recoveryOptions = {} }), isConnectionError: isBackgroundConnectionError, onState: state => applyActiveRunState(tabId, state), + isCancelled: () => isTabAbortRequested(tabId) + || cancelledRunRecoveryRequestIds.has(requestId), shouldResume: () => !isTabAbortRequested(tabId) && !cancelledRunRecoveryRequestIds.has(requestId), onStatus: ({ phase }) => { @@ -9330,6 +9333,13 @@ async function sendRunWithReconnect(initialAction, payload, recoveryOptions = {} }, ...recoveryOptions, }); + const follower = { requestId, promise }; + localRunFollowers.set(tabId, follower); + try { + return await promise; + } finally { + if (localRunFollowers.get(tabId) === follower) localRunFollowers.delete(tabId); + } } function formatBackgroundSendError(action, message) { @@ -9493,6 +9503,7 @@ async function abortRun() { || currentAssistantEl?.dataset?.runRequestId || '', ); + const follower = localRunFollowers.get(Number(tabId)); if (requestId) cancelledRunRecoveryRequestIds.add(requestId); setTabAbortRequested(tabId, true); showActivity(t('sp.activity.stopping')); @@ -9502,6 +9513,9 @@ async function abortRun() { } catch { // Best effort } + if (follower?.requestId === requestId) { + await follower.promise.catch(() => {}); + } // Force UI to settle even if background doesn't respond cleanly setTimeout(async () => { diff --git a/src/firefox/src/run-reconnect.js b/src/firefox/src/run-reconnect.js index 526ac4f01..f20aab798 100644 --- a/src/firefox/src/run-reconnect.js +++ b/src/firefox/src/run-reconnect.js @@ -80,6 +80,7 @@ export async function runDetachedWithReconnect({ onStatus = () => {}, onState = () => {}, shouldResume = () => true, + isCancelled = () => false, wait = defaultWait, pollIntervalMs = 1200, reconnectDelaysMs = [250, 500, 1000, 2000, 4000], @@ -108,6 +109,7 @@ export async function runDetachedWithReconnect({ let skipStart = probeFirst; while (true) { + if (isCancelled()) throw new Error('Run recovery was cancelled.'); let startAcknowledged = skipStart; if (skipStart) { skipStart = false; @@ -133,10 +135,12 @@ export async function runDetachedWithReconnect({ let wasDisconnected = startWasUncertain; while (true) { + if (isCancelled()) throw new Error('Run recovery was cancelled.'); const reconnectDelay = reconnectDelaysMs[ Math.min(connectionFailures, Math.max(0, reconnectDelaysMs.length - 1)) ] ?? pollIntervalMs; await wait(wasDisconnected ? reconnectDelay : pollIntervalMs); + if (isCancelled()) throw new Error('Run recovery was cancelled.'); let state; try { diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 8cd309a18..cd6913a46 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -805,6 +805,7 @@ const awaitingPlanReviewTabs = new Set(); const processingTabs = new Set(); const abortRequestedTabs = new Set(); const localRunRequestIds = new Map(); +const localRunFollowers = new Map(); const cancelledRunRecoveryRequestIds = new Set(); const adoptedRunRecoveryRequestIds = new Set(); let recommendationsRequestId = 0; @@ -8938,7 +8939,7 @@ async function sendRunWithReconnect(initialAction, payload, recoveryOptions = {} const tabId = Number(payload?.tabId); const requestId = String(payload?.requestId || ''); cancelledRunRecoveryRequestIds.delete(requestId); - return runDetachedWithReconnect({ + const promise = runDetachedWithReconnect({ initialAction, payload, start: (action, nextPayload) => sendToBackground(action, nextPayload), @@ -8948,6 +8949,8 @@ async function sendRunWithReconnect(initialAction, payload, recoveryOptions = {} }), isConnectionError: isBackgroundConnectionError, onState: state => applyActiveRunState(tabId, state), + isCancelled: () => isTabAbortRequested(tabId) + || cancelledRunRecoveryRequestIds.has(requestId), shouldResume: () => !isTabAbortRequested(tabId) && !cancelledRunRecoveryRequestIds.has(requestId), onStatus: ({ phase }) => { @@ -8962,6 +8965,13 @@ async function sendRunWithReconnect(initialAction, payload, recoveryOptions = {} }, ...recoveryOptions, }); + const follower = { requestId, promise }; + localRunFollowers.set(tabId, follower); + try { + return await promise; + } finally { + if (localRunFollowers.get(tabId) === follower) localRunFollowers.delete(tabId); + } } function formatBackgroundSendError(action, message) { @@ -9051,6 +9061,7 @@ async function abortRun() { || currentAssistantEl?.dataset?.runRequestId || '', ); + const follower = localRunFollowers.get(Number(tabId)); if (requestId) cancelledRunRecoveryRequestIds.add(requestId); setTabAbortRequested(tabId, true); showActivity(t('sp.activity.stopping')); @@ -9060,6 +9071,9 @@ async function abortRun() { } catch { // Best effort } + if (follower?.requestId === requestId) { + await follower.promise.catch(() => {}); + } // Force UI to settle even if background doesn't respond cleanly setTimeout(async () => { diff --git a/test/run.js b/test/run.js index 5ac7f0fb3..00b008cd3 100644 --- a/test/run.js +++ b/test/run.js @@ -46860,6 +46860,38 @@ test('detached run recovery honors a user cancellation instead of auto-resuming' } }); +test('detached run followers settle locally before an aborted conversation can clear their journal', async () => { + for (const [label, runDetachedWithReconnect] of [ + ['chrome', runDetachedWithReconnectCh], + ['firefox', runDetachedWithReconnectFx], + ]) { + const requestId = `${label}-cancel-local-follower`; + let cancelled = false; + let probes = 0; + + await assert.rejects( + runDetachedWithReconnect({ + initialAction: 'chat_start', + payload: { tabId: 45, requestId, mode: 'act', text: 'stop and clear' }, + start: async () => ({ accepted: true, requestId }), + probe: async () => { + probes += 1; + return { running: false, starting: false, runUi: null }; + }, + isConnectionError: () => false, + isCancelled: () => cancelled, + wait: async () => { + cancelled = true; + }, + }), + /recovery was cancelled/i, + `${label}: aborting should settle the local follower without waiting for a missing journal timeout`, + ); + + assert.equal(probes, 0, `${label}: a cancelled follower must not probe a journal that New conversation may clear`); + } +}); + test('detached run recovery preserves background preflight errors', async () => { for (const [label, runDetachedWithReconnect] of [ ['chrome', runDetachedWithReconnectCh], @@ -46978,11 +47010,14 @@ test('reconnect protocol is wired through both sidepanels and backgrounds', () = assert.match(panel, /probeFirst: true,[\s\S]*?requireDurableSubmittedTurn:/, `${label}: remount adoption should probe before any safe continuation`); assert.match(panel, /if \(state\?\.running \|\| state\?\.starting\)/, `${label}: a reserved detached start should keep the composer and Stop UI in their active state`); assert.match(panel, /cancelledRunRecoveryRequestIds/, `${label}: user cancellation should block automatic resume`); + assert.match(panel, /const localRunFollowers = new Map\(\)/, `${label}: locally initiated detached runs should expose their follower settlement`); + assert.match(panel, /const follower = \{ requestId, promise \};[\s\S]*?localRunFollowers\.set\(tabId, follower\);[\s\S]*?return await promise;[\s\S]*?localRunFollowers\.delete\(tabId\)/, `${label}: detached follower promises should remain tracked until they settle`); const stopSection = panel.slice( panel.indexOf('// --- Stop / Abort ---'), panel.indexOf('// --- Voice input', panel.indexOf('// --- Stop / Abort ---')), ); assert.match(stopSection, /localRunRequestIds\.get\(Number\(tabId\)\)[\s\S]*?cancelledRunRecoveryRequestIds\.add\(requestId\)[\s\S]*?setTabAbortRequested\(tabId, true\)/, `${label}: Stop should persist request-scoped cancellation before its UI timeout clears`); + assert.match(stopSection, /const follower = localRunFollowers\.get\(Number\(tabId\)\);[\s\S]*?await sendToBackground\('abort', \{ tabId \}\);[\s\S]*?await follower\.promise\.catch\(\(\) => \{\}\);/, `${label}: Stop should settle the local follower before New conversation can clear its journal`); assert.match(background, /case 'chat_start':[\s\S]*?launchDetachedRun\('chat'/, `${label}: background should acknowledge detached chat starts`); assert.match(background, /case 'continue_start':[\s\S]*?launchDetachedRun\('continue'/, `${label}: background should acknowledge detached continuation starts`); assert.match(background, /case 'chat':[\s\S]*?await beginContinuationRunUiSnapshot\(tabId, msg\.requestId,/, `${label}: replayed fresh chats should preserve journal sequence numbers`); From 4239605284a61fe6a4c1f94eaf4fded8bcfaa161 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 23 Jul 2026 18:01:52 +0300 Subject: [PATCH 04/17] Trace interactive Ask streaming lifecycle --- src/chrome/src/agent/agent.js | 122 +++++++++++++++++++++++--- src/chrome/src/agent/trace-export.js | 27 +++++- src/chrome/src/trace/recorder.js | 9 ++ src/chrome/src/ui/traces.html | 1 + src/chrome/src/ui/traces.js | 20 +++++ src/firefox/src/agent/agent.js | 122 +++++++++++++++++++++++--- src/firefox/src/agent/trace-export.js | 27 +++++- src/firefox/src/trace/recorder.js | 9 ++ src/firefox/src/ui/traces.html | 1 + src/firefox/src/ui/traces.js | 20 +++++ test/run.js | 89 +++++++++++++++++++ 11 files changed, 413 insertions(+), 34 deletions(-) diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 69f273fd1..0ccf67683 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -1135,17 +1135,58 @@ export class Agent { }; } - _shouldStreamInteractiveAsk(provider, mode, runOptions = {}, disabledForRun = false) { + _interactiveAskStreamingDecision(provider, mode, runOptions = {}, disabledForRun = false) { const streamingEnabled = runOptions?.askStreamingEnabled ?? runOptions?.openaiAskStreamingEnabled; - return mode === 'ask' - && runOptions?.interactiveChat === true - && streamingEnabled !== false - && runOptions?.trustedContinuation !== true - && runOptions?.cloudRun !== true - && disabledForRun !== true - && typeof provider?.chatStream === 'function' - && provider?._supportsInteractiveAskStreaming?.() === true; + if (mode !== 'ask') return { eligible: false, reason: 'mode_not_ask' }; + if (runOptions?.interactiveChat !== true) return { eligible: false, reason: 'not_interactive_chat' }; + if (streamingEnabled === false) return { eligible: false, reason: 'disabled_in_settings' }; + if (runOptions?.trustedContinuation === true) return { eligible: false, reason: 'trusted_continuation' }; + if (runOptions?.cloudRun === true) return { eligible: false, reason: 'cloud_run' }; + if (disabledForRun === true) return { eligible: false, reason: 'disabled_after_fallback' }; + if (typeof provider?.chatStream !== 'function') return { eligible: false, reason: 'provider_missing_chat_stream' }; + try { + if (provider?._supportsInteractiveAskStreaming?.() !== true) { + return { eligible: false, reason: 'provider_not_supported' }; + } + } catch { + return { eligible: false, reason: 'provider_capability_check_failed' }; + } + return { eligible: true, reason: 'eligible' }; + } + + _shouldStreamInteractiveAsk(provider, mode, runOptions = {}, disabledForRun = false) { + return this._interactiveAskStreamingDecision(provider, mode, runOptions, disabledForRun).eligible; + } + + _interactiveAskStreamingProtocol(provider) { + try { + if (typeof provider?._usesResponsesApi === 'function') { + return provider._usesResponsesApi() === true ? 'responses' : 'chat_completions'; + } + return 'provider_native'; + } catch { + return 'unknown'; + } + } + + _interactiveAskStreamingFailure(error) { + const rawMessage = String(error?.message || error || 'Streaming request failed.'); + const message = rawMessage + .replace(/\b(Bearer)\s+[^\s,;]+/gi, '$1 [redacted]') + .replace(/((?:api[_ -]?key|access[_ -]?token|token|secret|password)\s*[:=]\s*)["']?[^\s,"';]+/gi, '$1[redacted]') + .replace(/([?&](?:api[_-]?key|access[_-]?token|token|key)=)[^&\s]+/gi, '$1[redacted]') + .replace(/\b(?:sk-(?:or-v1-)?|gsk_|xai-)[a-zA-Z0-9_-]{12,}\b/g, '[redacted]') + .slice(0, 500); + const rawCode = error?.incompleteReason || error?.code || ''; + const errorCode = String(rawCode).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 80); + let reason = 'stream_error'; + if (this._isCostAllowanceError(error)) reason = 'cost_limit'; + else if (errorCode === 'missing_response_completed' || /before (?:its )?terminal event/i.test(rawMessage)) { + reason = 'missing_terminal_event'; + } else if (this._shouldFallbackAskStream(error)) reason = 'transport_error'; + else if (error?.isAskStreamTerminalError === true) reason = 'provider_stream_error'; + return { reason, errorCode: errorCode || null, message }; } // Backward-compatible aliases for integrations/tests that used the original @@ -18496,13 +18537,35 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d let compressionPlaceholderRecoveryAttempted = false; let askStreamingDisabledForRun = false; + // Keep trace persistence ordered without putting IndexedDB on the LLM + // request path. The recorder is diagnostic-only and must never delay a + // stream, fallback, or completed response. + let askStreamingTraceWrite = Promise.resolve(); + const recordAskStreaming = (payload) => { + const traceRunId = runId; + const traceStep = steps; + if (!traceRunId) return; + askStreamingTraceWrite = askStreamingTraceWrite + .then(() => trace.recordStreaming(traceRunId, traceStep, payload)) + .catch(() => {}); + }; + const chatMainTurn = async (chatMessages, chatOptions, requestContext) => { - if (!this._shouldStreamInteractiveAsk( + const decision = this._interactiveAskStreamingDecision( provider, mode, runOptions, askStreamingDisabledForRun, - )) { + ); + const protocol = this._interactiveAskStreamingProtocol(provider); + if (!decision.eligible) { + if (mode === 'ask' && runOptions?.interactiveChat === true) { + recordAskStreaming({ + status: 'skipped', + reason: decision.reason, + protocol, + }); + } return this._chatWithCostAllowance( provider, chatMessages, @@ -18512,9 +18575,24 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ); } + let firstDeltaMs = null; + let textDeltaCount = 0; + let textChars = 0; let emittedText = false; + recordAskStreaming({ + status: 'attempted', + reason: decision.reason, + protocol, + }); + const streamStartedAt = Date.now(); + const streamMetrics = () => ({ + durationMs: Date.now() - streamStartedAt, + firstDeltaMs, + textDeltaCount, + textChars, + }); try { - return await this._chatStreamWithCostAllowance( + const result = await this._chatStreamWithCostAllowance( provider, chatMessages, chatOptions, @@ -18522,13 +18600,31 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d requestContext, (delta) => { emittedText = true; + if (firstDeltaMs == null) firstDeltaMs = Date.now() - streamStartedAt; + textDeltaCount += 1; + textChars += delta.length; onUpdate('text_delta', { content: delta }); }, ); + recordAskStreaming({ + status: 'completed', + reason: 'terminal_event_received', + protocol, + ...streamMetrics(), + toolCallCount: Array.isArray(result?.toolCalls) ? result.toolCalls.length : 0, + }); + return result; } catch (error) { + const fallbackSafe = this._shouldFallbackAskStream(error); + recordAskStreaming({ + status: fallbackSafe ? 'fallback' : 'failed', + protocol, + ...streamMetrics(), + ...this._interactiveAskStreamingFailure(error), + }); if (this._isCostAllowanceError(error)) throw error; if (emittedText) onUpdate('text', { content: '', replace: true }); - if (!this._shouldFallbackAskStream(error)) throw error; + if (!fallbackSafe) throw error; askStreamingDisabledForRun = true; onUpdate('warning', { code: 'ask_stream_fallback', diff --git a/src/chrome/src/agent/trace-export.js b/src/chrome/src/agent/trace-export.js index a62e4845c..08ae86d08 100644 --- a/src/chrome/src/agent/trace-export.js +++ b/src/chrome/src/agent/trace-export.js @@ -7,10 +7,11 @@ * the right source for a tool chain; `this.conversations` is not (it is compacted, * enriched, and wrapped — see the closed PR #348 review). * - * This renders the TOOL CHAIN: user/assistant/planner prose, tool calls (name, - * args, result), and errors — in order. Screenshot / note / vision_sub_call events - * are recorded but deliberately not rendered; the file says so in its footer, so it - * never claims to be a complete record. The complete record is the Traces-page JSON. + * This renders the TOOL CHAIN: user/assistant/planner prose, streaming lifecycle + * metadata, tool calls (name, args, result), and errors — in order. Screenshot / + * note / vision_sub_call events are recorded but deliberately not rendered; the + * file says so in its footer, so it never claims to be a complete record. The + * complete record is the Traces-page JSON. * * Pure and browser-neutral → unit-tested in test/run.js without a DOM or IndexedDB. * @@ -82,6 +83,22 @@ function renderResult(result) { return { text: truncate(oneLine(s), RESULT_LIMIT), failed }; } +function renderStreaming(data) { + const d = data || {}; + const details = [ + oneLine(d.protocol), + oneLine(d.reason), + d.errorCode ? `code ${oneLine(d.errorCode)}` : '', + Number.isFinite(d.textDeltaCount) ? `${d.textDeltaCount} text delta${d.textDeltaCount === 1 ? '' : 's'}` : '', + Number.isFinite(d.textChars) ? `${d.textChars} chars` : '', + Number.isFinite(d.firstDeltaMs) ? `first delta ${d.firstDeltaMs} ms` : '', + Number.isFinite(d.durationMs) ? `${d.durationMs} ms total` : '', + Number.isFinite(d.toolCallCount) ? `${d.toolCallCount} tool call${d.toolCallCount === 1 ? '' : 's'}` : '', + ].filter(Boolean); + const message = oneLine(d.message); + return `- 🌊 Ask stream ${oneLine(d.status || 'event')}${details.length ? ` · ${details.join(' · ')}` : ''}${message ? `: ${message}` : ''}\n`; +} + function exportedRunStatus(run, events = []) { const status = oneLine(run?.status || ''); const sawLoopError = events.some(ev => ev?.kind === 'error' && ev?.data?.phase === 'loop'); @@ -137,6 +154,8 @@ export function tracesToMarkdown(runsWithEvents, { toolCount += 1; const { text, failed } = renderResult(d.result); md += `- 🔧 \`${d.name || 'tool'}\`(${stringifyArgs(d.args)}) → ${failed ? '✗ ' : ''}${text}\n`; + } else if (ev.kind === 'streaming') { + md += renderStreaming(d); } else if (ev.kind === 'error') { md += `- ⚠️ error${d.phase ? ` (${d.phase})` : ''}: ${oneLine(d.message || '')}\n`; } diff --git a/src/chrome/src/trace/recorder.js b/src/chrome/src/trace/recorder.js index 84f233cc0..809d3030b 100644 --- a/src/chrome/src/trace/recorder.js +++ b/src/chrome/src/trace/recorder.js @@ -248,6 +248,15 @@ export function recordError(runId, step, phase, message) { return _appendEvent(runId, 'error', { step, phase, message }); } +/** + * Record the lifecycle of an interactive Ask streaming attempt without + * persisting token contents. Payloads contain only decision/outcome codes, + * protocol, aggregate counts, timing, and a redacted error summary. + */ +export function recordStreaming(runId, step, payload = {}) { + return _appendEvent(runId, 'streaming', { step, ...payload }); +} + /** * Record a vision sub-call: the agent asked a dedicated vision model to * describe a screenshot so the main planning model receives text instead diff --git a/src/chrome/src/ui/traces.html b/src/chrome/src/ui/traces.html index 4660a10d5..157ad8c2e 100644 --- a/src/chrome/src/ui/traces.html +++ b/src/chrome/src/ui/traces.html @@ -276,6 +276,7 @@ } .event.llm_request { opacity: 0.55; } .event.llm_response { border-left: 3px solid var(--accent); } + .event.streaming { border-left: 3px solid #22d3ee; } .event.tool { border-left: 3px solid #3fa7d6; } .event.screenshot { border-left: 3px solid #a78bfa; padding: 8px; } .event.error { border-left: 3px solid var(--error); color: var(--error); } diff --git a/src/chrome/src/ui/traces.js b/src/chrome/src/ui/traces.js index c3ed6b00c..6faf63e22 100644 --- a/src/chrome/src/ui/traces.js +++ b/src/chrome/src/ui/traces.js @@ -348,6 +348,26 @@ function renderEvent(ev, shotCache, compact, objectUrls = new Set()) { ${src ? `${escapeAttr(caption)}` : `${escapeHtml(t('tr.event.screenshot_missing'))}`} `; } + case 'streaming': { + const d = ev.data || {}; + const details = [ + d.protocol, + d.reason, + d.errorCode ? `#${d.errorCode}` : '', + Number.isFinite(d.textDeltaCount) ? `Δ × ${d.textDeltaCount}` : '', + Number.isFinite(d.textChars) ? t('st.skills.item.chars', { count: d.textChars }) : '', + Number.isFinite(d.firstDeltaMs) ? `TTFT ${d.firstDeltaMs} ms` : '', + Number.isFinite(d.durationMs) ? `${t('tr.duration.label')}: ${d.durationMs} ms` : '', + Number.isFinite(d.toolCallCount) ? `🔧 × ${d.toolCallCount}` : '', + ].filter(Boolean).join(' · '); + const message = d.message ? `
${escapeHtml(d.message)}
` : ''; + return ` +
+
🌊 ${escapeHtml(t('st.display.openai_ask_streaming.label'))}: ${escapeHtml(d.status || '?')}${stepBadge}${ts}
+ ${details ? `
${escapeHtml(details)}
` : ''} + ${message} +
`; + } case 'error': { return `
diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 07bac9ad2..28ec803ac 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -1173,17 +1173,58 @@ export class Agent { }; } - _shouldStreamInteractiveAsk(provider, mode, runOptions = {}, disabledForRun = false) { + _interactiveAskStreamingDecision(provider, mode, runOptions = {}, disabledForRun = false) { const streamingEnabled = runOptions?.askStreamingEnabled ?? runOptions?.openaiAskStreamingEnabled; - return mode === 'ask' - && runOptions?.interactiveChat === true - && streamingEnabled !== false - && runOptions?.trustedContinuation !== true - && runOptions?.cloudRun !== true - && disabledForRun !== true - && typeof provider?.chatStream === 'function' - && provider?._supportsInteractiveAskStreaming?.() === true; + if (mode !== 'ask') return { eligible: false, reason: 'mode_not_ask' }; + if (runOptions?.interactiveChat !== true) return { eligible: false, reason: 'not_interactive_chat' }; + if (streamingEnabled === false) return { eligible: false, reason: 'disabled_in_settings' }; + if (runOptions?.trustedContinuation === true) return { eligible: false, reason: 'trusted_continuation' }; + if (runOptions?.cloudRun === true) return { eligible: false, reason: 'cloud_run' }; + if (disabledForRun === true) return { eligible: false, reason: 'disabled_after_fallback' }; + if (typeof provider?.chatStream !== 'function') return { eligible: false, reason: 'provider_missing_chat_stream' }; + try { + if (provider?._supportsInteractiveAskStreaming?.() !== true) { + return { eligible: false, reason: 'provider_not_supported' }; + } + } catch { + return { eligible: false, reason: 'provider_capability_check_failed' }; + } + return { eligible: true, reason: 'eligible' }; + } + + _shouldStreamInteractiveAsk(provider, mode, runOptions = {}, disabledForRun = false) { + return this._interactiveAskStreamingDecision(provider, mode, runOptions, disabledForRun).eligible; + } + + _interactiveAskStreamingProtocol(provider) { + try { + if (typeof provider?._usesResponsesApi === 'function') { + return provider._usesResponsesApi() === true ? 'responses' : 'chat_completions'; + } + return 'provider_native'; + } catch { + return 'unknown'; + } + } + + _interactiveAskStreamingFailure(error) { + const rawMessage = String(error?.message || error || 'Streaming request failed.'); + const message = rawMessage + .replace(/\b(Bearer)\s+[^\s,;]+/gi, '$1 [redacted]') + .replace(/((?:api[_ -]?key|access[_ -]?token|token|secret|password)\s*[:=]\s*)["']?[^\s,"';]+/gi, '$1[redacted]') + .replace(/([?&](?:api[_-]?key|access[_-]?token|token|key)=)[^&\s]+/gi, '$1[redacted]') + .replace(/\b(?:sk-(?:or-v1-)?|gsk_|xai-)[a-zA-Z0-9_-]{12,}\b/g, '[redacted]') + .slice(0, 500); + const rawCode = error?.incompleteReason || error?.code || ''; + const errorCode = String(rawCode).replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 80); + let reason = 'stream_error'; + if (this._isCostAllowanceError(error)) reason = 'cost_limit'; + else if (errorCode === 'missing_response_completed' || /before (?:its )?terminal event/i.test(rawMessage)) { + reason = 'missing_terminal_event'; + } else if (this._shouldFallbackAskStream(error)) reason = 'transport_error'; + else if (error?.isAskStreamTerminalError === true) reason = 'provider_stream_error'; + return { reason, errorCode: errorCode || null, message }; } // Backward-compatible aliases for integrations/tests that used the original @@ -13770,13 +13811,35 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d let compressionPlaceholderRecoveryAttempted = false; let askStreamingDisabledForRun = false; + // Keep trace persistence ordered without putting IndexedDB on the LLM + // request path. The recorder is diagnostic-only and must never delay a + // stream, fallback, or completed response. + let askStreamingTraceWrite = Promise.resolve(); + const recordAskStreaming = (payload) => { + const traceRunId = runId; + const traceStep = steps; + if (!traceRunId) return; + askStreamingTraceWrite = askStreamingTraceWrite + .then(() => trace.recordStreaming(traceRunId, traceStep, payload)) + .catch(() => {}); + }; + const chatMainTurn = async (chatMessages, chatOptions, requestContext) => { - if (!this._shouldStreamInteractiveAsk( + const decision = this._interactiveAskStreamingDecision( provider, mode, runOptions, askStreamingDisabledForRun, - )) { + ); + const protocol = this._interactiveAskStreamingProtocol(provider); + if (!decision.eligible) { + if (mode === 'ask' && runOptions?.interactiveChat === true) { + recordAskStreaming({ + status: 'skipped', + reason: decision.reason, + protocol, + }); + } return this._chatWithCostAllowance( provider, chatMessages, @@ -13786,9 +13849,24 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ); } + let firstDeltaMs = null; + let textDeltaCount = 0; + let textChars = 0; let emittedText = false; + recordAskStreaming({ + status: 'attempted', + reason: decision.reason, + protocol, + }); + const streamStartedAt = Date.now(); + const streamMetrics = () => ({ + durationMs: Date.now() - streamStartedAt, + firstDeltaMs, + textDeltaCount, + textChars, + }); try { - return await this._chatStreamWithCostAllowance( + const result = await this._chatStreamWithCostAllowance( provider, chatMessages, chatOptions, @@ -13796,13 +13874,31 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d requestContext, (delta) => { emittedText = true; + if (firstDeltaMs == null) firstDeltaMs = Date.now() - streamStartedAt; + textDeltaCount += 1; + textChars += delta.length; onUpdate('text_delta', { content: delta }); }, ); + recordAskStreaming({ + status: 'completed', + reason: 'terminal_event_received', + protocol, + ...streamMetrics(), + toolCallCount: Array.isArray(result?.toolCalls) ? result.toolCalls.length : 0, + }); + return result; } catch (error) { + const fallbackSafe = this._shouldFallbackAskStream(error); + recordAskStreaming({ + status: fallbackSafe ? 'fallback' : 'failed', + protocol, + ...streamMetrics(), + ...this._interactiveAskStreamingFailure(error), + }); if (this._isCostAllowanceError(error)) throw error; if (emittedText) onUpdate('text', { content: '', replace: true }); - if (!this._shouldFallbackAskStream(error)) throw error; + if (!fallbackSafe) throw error; askStreamingDisabledForRun = true; onUpdate('warning', { code: 'ask_stream_fallback', diff --git a/src/firefox/src/agent/trace-export.js b/src/firefox/src/agent/trace-export.js index a62e4845c..08ae86d08 100644 --- a/src/firefox/src/agent/trace-export.js +++ b/src/firefox/src/agent/trace-export.js @@ -7,10 +7,11 @@ * the right source for a tool chain; `this.conversations` is not (it is compacted, * enriched, and wrapped — see the closed PR #348 review). * - * This renders the TOOL CHAIN: user/assistant/planner prose, tool calls (name, - * args, result), and errors — in order. Screenshot / note / vision_sub_call events - * are recorded but deliberately not rendered; the file says so in its footer, so it - * never claims to be a complete record. The complete record is the Traces-page JSON. + * This renders the TOOL CHAIN: user/assistant/planner prose, streaming lifecycle + * metadata, tool calls (name, args, result), and errors — in order. Screenshot / + * note / vision_sub_call events are recorded but deliberately not rendered; the + * file says so in its footer, so it never claims to be a complete record. The + * complete record is the Traces-page JSON. * * Pure and browser-neutral → unit-tested in test/run.js without a DOM or IndexedDB. * @@ -82,6 +83,22 @@ function renderResult(result) { return { text: truncate(oneLine(s), RESULT_LIMIT), failed }; } +function renderStreaming(data) { + const d = data || {}; + const details = [ + oneLine(d.protocol), + oneLine(d.reason), + d.errorCode ? `code ${oneLine(d.errorCode)}` : '', + Number.isFinite(d.textDeltaCount) ? `${d.textDeltaCount} text delta${d.textDeltaCount === 1 ? '' : 's'}` : '', + Number.isFinite(d.textChars) ? `${d.textChars} chars` : '', + Number.isFinite(d.firstDeltaMs) ? `first delta ${d.firstDeltaMs} ms` : '', + Number.isFinite(d.durationMs) ? `${d.durationMs} ms total` : '', + Number.isFinite(d.toolCallCount) ? `${d.toolCallCount} tool call${d.toolCallCount === 1 ? '' : 's'}` : '', + ].filter(Boolean); + const message = oneLine(d.message); + return `- 🌊 Ask stream ${oneLine(d.status || 'event')}${details.length ? ` · ${details.join(' · ')}` : ''}${message ? `: ${message}` : ''}\n`; +} + function exportedRunStatus(run, events = []) { const status = oneLine(run?.status || ''); const sawLoopError = events.some(ev => ev?.kind === 'error' && ev?.data?.phase === 'loop'); @@ -137,6 +154,8 @@ export function tracesToMarkdown(runsWithEvents, { toolCount += 1; const { text, failed } = renderResult(d.result); md += `- 🔧 \`${d.name || 'tool'}\`(${stringifyArgs(d.args)}) → ${failed ? '✗ ' : ''}${text}\n`; + } else if (ev.kind === 'streaming') { + md += renderStreaming(d); } else if (ev.kind === 'error') { md += `- ⚠️ error${d.phase ? ` (${d.phase})` : ''}: ${oneLine(d.message || '')}\n`; } diff --git a/src/firefox/src/trace/recorder.js b/src/firefox/src/trace/recorder.js index 7b8c8bfd8..91ff616db 100644 --- a/src/firefox/src/trace/recorder.js +++ b/src/firefox/src/trace/recorder.js @@ -231,6 +231,15 @@ export function recordError(runId, step, phase, message) { return _appendEvent(runId, 'error', { step, phase, message }); } +/** + * Record the lifecycle of an interactive Ask streaming attempt without + * persisting token contents. Payloads contain only decision/outcome codes, + * protocol, aggregate counts, timing, and a redacted error summary. + */ +export function recordStreaming(runId, step, payload = {}) { + return _appendEvent(runId, 'streaming', { step, ...payload }); +} + /** * Record a vision sub-call: the agent asked a dedicated vision model to * describe a screenshot so the main planning model receives text instead diff --git a/src/firefox/src/ui/traces.html b/src/firefox/src/ui/traces.html index 4660a10d5..157ad8c2e 100644 --- a/src/firefox/src/ui/traces.html +++ b/src/firefox/src/ui/traces.html @@ -276,6 +276,7 @@ } .event.llm_request { opacity: 0.55; } .event.llm_response { border-left: 3px solid var(--accent); } + .event.streaming { border-left: 3px solid #22d3ee; } .event.tool { border-left: 3px solid #3fa7d6; } .event.screenshot { border-left: 3px solid #a78bfa; padding: 8px; } .event.error { border-left: 3px solid var(--error); color: var(--error); } diff --git a/src/firefox/src/ui/traces.js b/src/firefox/src/ui/traces.js index 89712dda6..2663d48de 100644 --- a/src/firefox/src/ui/traces.js +++ b/src/firefox/src/ui/traces.js @@ -348,6 +348,26 @@ function renderEvent(ev, shotCache, compact, objectUrls = new Set()) { ${src ? `${escapeAttr(caption)}` : `${escapeHtml(t('tr.event.screenshot_missing'))}`}
`; } + case 'streaming': { + const d = ev.data || {}; + const details = [ + d.protocol, + d.reason, + d.errorCode ? `#${d.errorCode}` : '', + Number.isFinite(d.textDeltaCount) ? `Δ × ${d.textDeltaCount}` : '', + Number.isFinite(d.textChars) ? t('st.skills.item.chars', { count: d.textChars }) : '', + Number.isFinite(d.firstDeltaMs) ? `TTFT ${d.firstDeltaMs} ms` : '', + Number.isFinite(d.durationMs) ? `${t('tr.duration.label')}: ${d.durationMs} ms` : '', + Number.isFinite(d.toolCallCount) ? `🔧 × ${d.toolCallCount}` : '', + ].filter(Boolean).join(' · '); + const message = d.message ? `
${escapeHtml(d.message)}
` : ''; + return ` +
+
🌊 ${escapeHtml(t('st.display.openai_ask_streaming.label'))}: ${escapeHtml(d.status || '?')}${stepBadge}${ts}
+ ${details ? `
${escapeHtml(details)}
` : ''} + ${message} +
`; + } case 'error': { return `
diff --git a/test/run.js b/test/run.js index e2d847c99..e1f27a776 100644 --- a/test/run.js +++ b/test/run.js @@ -3132,6 +3132,60 @@ test('trace export: chrome and firefox serializers are identical', () => { assert.equal(tracesToMarkdownFx(TRACE_RUNS).markdown, tracesToMarkdown(TRACE_RUNS).markdown); }); +test('trace export: renders Ask streaming decisions and aggregate lifecycle metrics', () => { + const streamingRun = [{ + run: { runId: 'streaming', userMessage: 'Explain this page', model: 'test', status: 'done' }, + events: [ + { + runId: 'streaming', + seq: 0, + kind: 'streaming', + data: { step: 1, status: 'attempted', reason: 'eligible', protocol: 'chat_completions' }, + }, + { + runId: 'streaming', + seq: 1, + kind: 'streaming', + data: { + step: 1, + status: 'completed', + reason: 'terminal_event_received', + protocol: 'chat_completions', + textDeltaCount: 7, + textChars: 128, + firstDeltaMs: 42, + durationMs: 310, + toolCallCount: 0, + }, + }, + { + runId: 'streaming', + seq: 2, + kind: 'streaming', + data: { + step: 2, + status: 'fallback', + reason: 'missing_terminal_event', + protocol: 'responses', + errorCode: 'missing_response_completed', + textDeltaCount: 2, + textChars: 18, + firstDeltaMs: 50, + durationMs: 200, + message: 'Responses stream incomplete (missing_response_completed).', + }, + }, + ], + }]; + for (const [label, serialize] of [['chrome', tracesToMarkdown], ['firefox', tracesToMarkdownFx]]) { + const { markdown, toolCount } = serialize(streamingRun); + assert.equal(toolCount, 0, `${label}: lifecycle events must not count as tools`); + assert.match(markdown, /Ask stream attempted · chat_completions · eligible/, `${label}: attempt missing`); + assert.match(markdown, /Ask stream completed · chat_completions · terminal_event_received · 7 text deltas · 128 chars · first delta 42 ms · 310 ms total · 0 tool calls/, `${label}: completion metrics missing`); + assert.match(markdown, /Ask stream fallback · responses · missing_terminal_event · code missing_response_completed · 2 text deltas/, `${label}: fallback reason missing`); + } +}); + test('/export --traces is wired in both side panels and backgrounds', () => { for (const [label, panelRel, bgRel] of [ ['chrome', 'src/chrome/src/ui/sidepanel.js', 'src/chrome/src/background.js'], @@ -27418,6 +27472,41 @@ test('Ask streaming eligibility is limited to interactive runs with a capable pr assert.equal(agent._shouldStreamInteractiveAsk(streamingProvider, 'ask', interactive, true), false, `${label}: per-run circuit breaker must disable streaming`); assert.equal(agent._shouldStreamInteractiveAsk({ ...streamingProvider, _supportsInteractiveAskStreaming: () => false }, 'ask', interactive), false, `${label}: unsupported providers must stay non-streaming`); assert.equal(agent._shouldStreamInteractiveAsk(streamingProvider, 'ask', { interactiveChat: true, openaiAskStreamingEnabled: false }), false, `${label}: legacy kill-switch payloads remain supported`); + + assert.equal(agent._interactiveAskStreamingDecision(streamingProvider, 'act', interactive).reason, 'mode_not_ask', `${label}: mode decision should be traceable`); + assert.equal(agent._interactiveAskStreamingDecision(streamingProvider, 'ask', {}).reason, 'not_interactive_chat', `${label}: scheduled decision should be traceable`); + assert.equal(agent._interactiveAskStreamingDecision(streamingProvider, 'ask', { ...interactive, askStreamingEnabled: false }).reason, 'disabled_in_settings', `${label}: setting decision should be traceable`); + assert.equal(agent._interactiveAskStreamingDecision(streamingProvider, 'ask', interactive, true).reason, 'disabled_after_fallback', `${label}: circuit-breaker decision should be traceable`); + assert.equal(agent._interactiveAskStreamingDecision({ ...streamingProvider, _supportsInteractiveAskStreaming: () => false }, 'ask', interactive).reason, 'provider_not_supported', `${label}: provider decision should be traceable`); + assert.equal(agent._interactiveAskStreamingDecision(streamingProvider, 'ask', interactive).reason, 'eligible', `${label}: eligible decision should be traceable`); + assert.equal(agent._interactiveAskStreamingProtocol(streamingProvider), 'provider_native', `${label}: native provider protocol should not be mislabeled`); + assert.equal(agent._interactiveAskStreamingProtocol({ _usesResponsesApi: () => false }), 'chat_completions', `${label}: compatible provider protocol should be traceable`); + assert.equal(agent._interactiveAskStreamingProtocol({ _usesResponsesApi: () => true }), 'responses', `${label}: Responses protocol should be traceable`); + + const transportError = new Error('failed with Authorization: Bearer secret-token, api_key=secret-key, and sk-or-v1-anothersecretkey'); + transportError.isAskStreamFallbackSafe = true; + const failure = agent._interactiveAskStreamingFailure(transportError); + assert.equal(failure.reason, 'transport_error', `${label}: fallback reason should be classified`); + assert.doesNotMatch(failure.message, /secret-token|secret-key|anothersecretkey/, `${label}: trace error must redact secrets`); + assert.match(failure.message, /\[redacted\]/, `${label}: trace error should retain a redaction marker`); + } +}); + +test('Ask streaming lifecycle tracing is wired through recorder, agent, and Traces UI', () => { + for (const browser of ['chrome', 'firefox']) { + const agentSource = fs.readFileSync(path.join(ROOT, `src/${browser}/src/agent/agent.js`), 'utf8'); + const recorderSource = fs.readFileSync(path.join(ROOT, `src/${browser}/src/trace/recorder.js`), 'utf8'); + const tracesSource = fs.readFileSync(path.join(ROOT, `src/${browser}/src/ui/traces.js`), 'utf8'); + const tracesHtml = fs.readFileSync(path.join(ROOT, `src/${browser}/src/ui/traces.html`), 'utf8'); + + assert.match(recorderSource, /export function recordStreaming\([\s\S]*?_appendEvent\(runId, 'streaming'/, `${browser}: recorder event missing`); + assert.match(agentSource, /let askStreamingTraceWrite = Promise\.resolve\(\)[\s\S]*?\.then\(\(\) => trace\.recordStreaming\(traceRunId, traceStep, payload\)\)/, `${browser}: ordered background recorder queue missing`); + assert.doesNotMatch(agentSource, /const recordAskStreaming = async/, `${browser}: trace writes must stay off the streaming request path`); + assert.match(agentSource, /status: 'attempted'[\s\S]*?\}\);\s*const streamStartedAt = Date\.now\(\)/, `${browser}: stream timing should start after the trace event is queued`); + assert.match(agentSource, /status: 'attempted'[\s\S]*?status: 'completed'[\s\S]*?status: fallbackSafe \? 'fallback' : 'failed'/, `${browser}: lifecycle outcomes missing`); + assert.match(tracesSource, /case 'streaming':[\s\S]*?t\('st\.display\.openai_ask_streaming\.label'\)/, `${browser}: localized Traces UI renderer missing`); + assert.doesNotMatch(tracesSource, /Ask stream:|text delta|first delta|ms total|tool call/, `${browser}: streaming trace copy should not be hard-coded in English`); + assert.match(tracesHtml, /\.event\.streaming \{ border-left:/, `${browser}: Traces UI styling missing`); } }); From 0e7e0c4976f5c5252ae145fceac0bbbe45913ce4 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 23 Jul 2026 18:06:03 +0300 Subject: [PATCH 05/17] Keep stopped runs guarded until terminal state --- src/chrome/src/run-reconnect.js | 4 --- src/chrome/src/ui/sidepanel.js | 2 -- src/firefox/src/run-reconnect.js | 4 --- src/firefox/src/ui/sidepanel.js | 2 -- test/run.js | 56 +++++++++++++++++++------------- 5 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/chrome/src/run-reconnect.js b/src/chrome/src/run-reconnect.js index f20aab798..526ac4f01 100644 --- a/src/chrome/src/run-reconnect.js +++ b/src/chrome/src/run-reconnect.js @@ -80,7 +80,6 @@ export async function runDetachedWithReconnect({ onStatus = () => {}, onState = () => {}, shouldResume = () => true, - isCancelled = () => false, wait = defaultWait, pollIntervalMs = 1200, reconnectDelaysMs = [250, 500, 1000, 2000, 4000], @@ -109,7 +108,6 @@ export async function runDetachedWithReconnect({ let skipStart = probeFirst; while (true) { - if (isCancelled()) throw new Error('Run recovery was cancelled.'); let startAcknowledged = skipStart; if (skipStart) { skipStart = false; @@ -135,12 +133,10 @@ export async function runDetachedWithReconnect({ let wasDisconnected = startWasUncertain; while (true) { - if (isCancelled()) throw new Error('Run recovery was cancelled.'); const reconnectDelay = reconnectDelaysMs[ Math.min(connectionFailures, Math.max(0, reconnectDelaysMs.length - 1)) ] ?? pollIntervalMs; await wait(wasDisconnected ? reconnectDelay : pollIntervalMs); - if (isCancelled()) throw new Error('Run recovery was cancelled.'); let state; try { diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 5b4469ed7..515cb83f6 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -9317,8 +9317,6 @@ async function sendRunWithReconnect(initialAction, payload, recoveryOptions = {} }), isConnectionError: isBackgroundConnectionError, onState: state => applyActiveRunState(tabId, state), - isCancelled: () => isTabAbortRequested(tabId) - || cancelledRunRecoveryRequestIds.has(requestId), shouldResume: () => !isTabAbortRequested(tabId) && !cancelledRunRecoveryRequestIds.has(requestId), onStatus: ({ phase }) => { diff --git a/src/firefox/src/run-reconnect.js b/src/firefox/src/run-reconnect.js index f20aab798..526ac4f01 100644 --- a/src/firefox/src/run-reconnect.js +++ b/src/firefox/src/run-reconnect.js @@ -80,7 +80,6 @@ export async function runDetachedWithReconnect({ onStatus = () => {}, onState = () => {}, shouldResume = () => true, - isCancelled = () => false, wait = defaultWait, pollIntervalMs = 1200, reconnectDelaysMs = [250, 500, 1000, 2000, 4000], @@ -109,7 +108,6 @@ export async function runDetachedWithReconnect({ let skipStart = probeFirst; while (true) { - if (isCancelled()) throw new Error('Run recovery was cancelled.'); let startAcknowledged = skipStart; if (skipStart) { skipStart = false; @@ -135,12 +133,10 @@ export async function runDetachedWithReconnect({ let wasDisconnected = startWasUncertain; while (true) { - if (isCancelled()) throw new Error('Run recovery was cancelled.'); const reconnectDelay = reconnectDelaysMs[ Math.min(connectionFailures, Math.max(0, reconnectDelaysMs.length - 1)) ] ?? pollIntervalMs; await wait(wasDisconnected ? reconnectDelay : pollIntervalMs); - if (isCancelled()) throw new Error('Run recovery was cancelled.'); let state; try { diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index cd6913a46..8a1d890ce 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -8949,8 +8949,6 @@ async function sendRunWithReconnect(initialAction, payload, recoveryOptions = {} }), isConnectionError: isBackgroundConnectionError, onState: state => applyActiveRunState(tabId, state), - isCancelled: () => isTabAbortRequested(tabId) - || cancelledRunRecoveryRequestIds.has(requestId), shouldResume: () => !isTabAbortRequested(tabId) && !cancelledRunRecoveryRequestIds.has(requestId), onStatus: ({ phase }) => { diff --git a/test/run.js b/test/run.js index 00b008cd3..85617e62d 100644 --- a/test/run.js +++ b/test/run.js @@ -46860,35 +46860,47 @@ test('detached run recovery honors a user cancellation instead of auto-resuming' } }); -test('detached run followers settle locally before an aborted conversation can clear their journal', async () => { +test('detached run followers keep Stop active until the terminal journal is observable', async () => { for (const [label, runDetachedWithReconnect] of [ ['chrome', runDetachedWithReconnectCh], ['firefox', runDetachedWithReconnectFx], ]) { - const requestId = `${label}-cancel-local-follower`; - let cancelled = false; + const requestId = `${label}-follow-stopped-run`; let probes = 0; - - await assert.rejects( - runDetachedWithReconnect({ - initialAction: 'chat_start', - payload: { tabId: 45, requestId, mode: 'act', text: 'stop and clear' }, - start: async () => ({ accepted: true, requestId }), - probe: async () => { - probes += 1; - return { running: false, starting: false, runUi: null }; - }, - isConnectionError: () => false, - isCancelled: () => cancelled, - wait: async () => { - cancelled = true; + const states = [ + { + running: true, + starting: false, + runUi: { requestId, status: 'running', events: [] }, + }, + { + running: false, + starting: false, + runUi: { + requestId, + status: 'stopped', + finalContent: 'Stopped by user.', + events: [], }, - }), - /recovery was cancelled/i, - `${label}: aborting should settle the local follower without waiting for a missing journal timeout`, - ); + }, + ]; + + const response = await runDetachedWithReconnect({ + initialAction: 'chat_start', + payload: { tabId: 45, requestId, mode: 'act', text: 'stop and clear' }, + start: async () => ({ accepted: true, requestId }), + probe: async () => { + const state = states[Math.min(probes, states.length - 1)]; + probes += 1; + return state; + }, + isConnectionError: () => false, + shouldResume: () => false, + wait: async () => {}, + }); - assert.equal(probes, 0, `${label}: a cancelled follower must not probe a journal that New conversation may clear`); + assert.equal(probes, 2, `${label}: Stop should keep following the live run until its terminal snapshot`); + assert.equal(response.runStatus, 'stopped', `${label}: the follower should settle from the terminal stopped journal`); } }); From 45425f7b64138d179bda7d58a764a47e992516ac Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 23 Jul 2026 18:14:57 +0300 Subject: [PATCH 06/17] Preserve Ask streaming trace order --- src/chrome/src/agent/agent.js | 52 +++++++++++++++++++++++++------ src/firefox/src/agent/agent.js | 56 ++++++++++++++++++++++++++++------ test/run.js | 5 ++- 3 files changed, 92 insertions(+), 21 deletions(-) diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 0ccf67683..c54fe0f30 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -18426,6 +18426,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d let runId = null; let finalResponse = ''; let _traceStatus = 'done'; // updated on early exits + let askStreamingTraceWrite = Promise.resolve(); + let shouldOrderInteractiveAskTrace = false; + const queueAskStreamingTraceWrite = (write) => { + askStreamingTraceWrite = askStreamingTraceWrite + .then(write) + .catch(() => {}); + return askStreamingTraceWrite; + }; if (mode === 'dev' && provider.promptTier === 'compact') { const msg = this._devModeBlockedMessage(provider); @@ -18537,17 +18545,17 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d let compressionPlaceholderRecoveryAttempted = false; let askStreamingDisabledForRun = false; - // Keep trace persistence ordered without putting IndexedDB on the LLM - // request path. The recorder is diagnostic-only and must never delay a - // stream, fallback, or completed response. - let askStreamingTraceWrite = Promise.resolve(); + // Keep trace persistence ordered without putting IndexedDB on the token + // delivery path. Once generation settles, later response/finalization + // writes flush this queue so lifecycle events cannot arrive afterward. + shouldOrderInteractiveAskTrace = mode === 'ask' && runOptions?.interactiveChat === true; const recordAskStreaming = (payload) => { const traceRunId = runId; const traceStep = steps; if (!traceRunId) return; - askStreamingTraceWrite = askStreamingTraceWrite - .then(() => trace.recordStreaming(traceRunId, traceStep, payload)) - .catch(() => {}); + queueAskStreamingTraceWrite( + () => trace.recordStreaming(traceRunId, traceStep, payload), + ); }; const chatMainTurn = async (chatMessages, chatOptions, requestContext) => { @@ -18707,7 +18715,16 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const chatOpts = { tools: useTools ? tools : undefined, temperature: plannerTemperature, maxTokens: 4096 }; const prunedMessages = this._pruneOldImages(messages, provider); this._logDebug({ type: 'llm_request', step: steps, provider: provider.constructor.name, messages: prunedMessages, options: chatOpts }); - if (runId) trace.recordLLMRequest(runId, steps, { providerClass: provider.constructor.name, model: provider.model, messageCount: prunedMessages.length, toolsCount: (chatOpts.tools || []).length }); + if (runId) { + const writeRequestTrace = () => trace.recordLLMRequest(runId, steps, { + providerClass: provider.constructor.name, + model: provider.model, + messageCount: prunedMessages.length, + toolsCount: (chatOpts.tools || []).length, + }); + if (shouldOrderInteractiveAskTrace) queueAskStreamingTraceWrite(writeRequestTrace); + else writeRequestTrace(); + } const _llmStart = Date.now(); result = await chatMainTurn(prunedMessages, chatOpts, { tabId, generationName: 'main' }); if (result?.usage?.prompt_tokens) { @@ -18718,7 +18735,17 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } const _llmLatency = Date.now() - _llmStart; this._logDebug({ type: 'llm_response', step: steps, content: result.content, toolCalls: result.toolCalls }); - if (runId) trace.recordLLMResponse(runId, steps, { content: result.content, toolCalls: result.toolCalls, usage: result.usage, latencyMs: _llmLatency, model: provider.model }); + if (runId) { + const writeResponseTrace = () => trace.recordLLMResponse(runId, steps, { + content: result.content, + toolCalls: result.toolCalls, + usage: result.usage, + latencyMs: _llmLatency, + model: provider.model, + }); + if (shouldOrderInteractiveAskTrace) await queueAskStreamingTraceWrite(writeResponseTrace); + else writeResponseTrace(); + } } catch (e) { this._logDebug({ type: 'llm_error', step: steps, error: e.message }); if (this._isCostAllowanceError(e)) { @@ -19012,10 +19039,15 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const message = error?.message || String(error); _traceStatus = 'error'; finalResponse = `Error: ${message}`; - if (runId) trace.recordError(runId, null, 'agent', message); + if (runId) { + const writeErrorTrace = () => trace.recordError(runId, null, 'agent', message); + if (shouldOrderInteractiveAskTrace) await queueAskStreamingTraceWrite(writeErrorTrace); + else writeErrorTrace(); + } throw error; } finally { this.currentCostState.delete(tabId); + await askStreamingTraceWrite; this._endTraceRun(tabId, runId, _traceStatus, finalResponse); } } diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 28ec803ac..77cee8981 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -13709,6 +13709,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d let runId = null; let finalResponse = ''; let _traceStatus = 'done'; + let askStreamingTraceWrite = Promise.resolve(); + let shouldOrderInteractiveAskTrace = false; + const queueAskStreamingTraceWrite = (write) => { + askStreamingTraceWrite = askStreamingTraceWrite + .then(write) + .catch(() => {}); + return askStreamingTraceWrite; + }; if (mode === 'dev' && provider.promptTier === 'compact') { const msg = this._devModeBlockedMessage(provider); @@ -13811,17 +13819,17 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d let compressionPlaceholderRecoveryAttempted = false; let askStreamingDisabledForRun = false; - // Keep trace persistence ordered without putting IndexedDB on the LLM - // request path. The recorder is diagnostic-only and must never delay a - // stream, fallback, or completed response. - let askStreamingTraceWrite = Promise.resolve(); + // Keep trace persistence ordered without putting IndexedDB on the token + // delivery path. Once generation settles, later response/finalization + // writes flush this queue so lifecycle events cannot arrive afterward. + shouldOrderInteractiveAskTrace = mode === 'ask' && runOptions?.interactiveChat === true; const recordAskStreaming = (payload) => { const traceRunId = runId; const traceStep = steps; if (!traceRunId) return; - askStreamingTraceWrite = askStreamingTraceWrite - .then(() => trace.recordStreaming(traceRunId, traceStep, payload)) - .catch(() => {}); + queueAskStreamingTraceWrite( + () => trace.recordStreaming(traceRunId, traceStep, payload), + ); }; const chatMainTurn = async (chatMessages, chatOptions, requestContext) => { @@ -13977,7 +13985,18 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const prunedMessages = this._pruneOldImages(messages, provider); this._logDebug({ type: 'llm_request', step: steps, provider: provider.constructor.name, messages: prunedMessages, options: chatOpts }); const _llmStart = Date.now(); - if (runId) { try { await trace.recordLLMRequest(runId, steps, { providerClass: provider.constructor.name, model: provider.model, messageCount: prunedMessages.length, toolsCount: (chatOpts.tools || []).length }); } catch {} } + if (runId) { + const writeRequestTrace = () => trace.recordLLMRequest(runId, steps, { + providerClass: provider.constructor.name, + model: provider.model, + messageCount: prunedMessages.length, + toolsCount: (chatOpts.tools || []).length, + }); + try { + if (shouldOrderInteractiveAskTrace) queueAskStreamingTraceWrite(writeRequestTrace); + else await writeRequestTrace(); + } catch {} + } result = await chatMainTurn(prunedMessages, chatOpts, { tabId, generationName: 'main' }); if (result?.usage?.prompt_tokens) { this._lastInputTokens.set(tabId, result.usage.prompt_tokens); @@ -13985,7 +14004,19 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d // _manageContext can add only the growth since (see its delta logic). this._lastEstCharsAtReport.set(tabId, this._estimateContextChars(messages)); } - if (runId) { try { await trace.recordLLMResponse(runId, steps, { content: result.content, toolCalls: result.toolCalls, usage: result.usage, latencyMs: Date.now() - _llmStart, model: provider.model }); } catch {} } + if (runId) { + const writeResponseTrace = () => trace.recordLLMResponse(runId, steps, { + content: result.content, + toolCalls: result.toolCalls, + usage: result.usage, + latencyMs: Date.now() - _llmStart, + model: provider.model, + }); + try { + if (shouldOrderInteractiveAskTrace) await queueAskStreamingTraceWrite(writeResponseTrace); + else await writeResponseTrace(); + } catch {} + } this._logDebug({ type: 'llm_response', step: steps, content: result.content, toolCalls: result.toolCalls }); } catch (e) { this._logDebug({ type: 'llm_error', step: steps, error: e.message }); @@ -14271,9 +14302,14 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d const message = error?.message || String(error); _traceStatus = 'error'; finalResponse = `Error: ${message}`; - if (runId) trace.recordError(runId, null, 'agent', message); + if (runId) { + const writeErrorTrace = () => trace.recordError(runId, null, 'agent', message); + if (shouldOrderInteractiveAskTrace) await queueAskStreamingTraceWrite(writeErrorTrace); + else writeErrorTrace(); + } throw error; } finally { + await askStreamingTraceWrite; this._endTraceRun(tabId, runId, _traceStatus, finalResponse); } } diff --git a/test/run.js b/test/run.js index e1f27a776..7fc9d6d04 100644 --- a/test/run.js +++ b/test/run.js @@ -27500,10 +27500,13 @@ test('Ask streaming lifecycle tracing is wired through recorder, agent, and Trac const tracesHtml = fs.readFileSync(path.join(ROOT, `src/${browser}/src/ui/traces.html`), 'utf8'); assert.match(recorderSource, /export function recordStreaming\([\s\S]*?_appendEvent\(runId, 'streaming'/, `${browser}: recorder event missing`); - assert.match(agentSource, /let askStreamingTraceWrite = Promise\.resolve\(\)[\s\S]*?\.then\(\(\) => trace\.recordStreaming\(traceRunId, traceStep, payload\)\)/, `${browser}: ordered background recorder queue missing`); + assert.match(agentSource, /let askStreamingTraceWrite = Promise\.resolve\(\)[\s\S]*?const queueAskStreamingTraceWrite = \(write\) => \{[\s\S]*?\.then\(write\)[\s\S]*?queueAskStreamingTraceWrite\(\s*\(\) => trace\.recordStreaming\(traceRunId, traceStep, payload\)/, `${browser}: ordered background recorder queue missing`); assert.doesNotMatch(agentSource, /const recordAskStreaming = async/, `${browser}: trace writes must stay off the streaming request path`); assert.match(agentSource, /status: 'attempted'[\s\S]*?\}\);\s*const streamStartedAt = Date\.now\(\)/, `${browser}: stream timing should start after the trace event is queued`); assert.match(agentSource, /status: 'attempted'[\s\S]*?status: 'completed'[\s\S]*?status: fallbackSafe \? 'fallback' : 'failed'/, `${browser}: lifecycle outcomes missing`); + assert.match(agentSource, /if \(shouldOrderInteractiveAskTrace\) queueAskStreamingTraceWrite\(writeRequestTrace\)/, `${browser}: request trace must lead the streaming lifecycle queue`); + assert.match(agentSource, /if \(shouldOrderInteractiveAskTrace\) await queueAskStreamingTraceWrite\(writeResponseTrace\)/, `${browser}: response trace must flush after streaming lifecycle events`); + assert.match(agentSource, /finally \{[\s\S]{0,120}?await askStreamingTraceWrite;\s*this\._endTraceRun/, `${browser}: run finalization must wait for streaming lifecycle traces`); assert.match(tracesSource, /case 'streaming':[\s\S]*?t\('st\.display\.openai_ask_streaming\.label'\)/, `${browser}: localized Traces UI renderer missing`); assert.doesNotMatch(tracesSource, /Ask stream:|text delta|first delta|ms total|tool call/, `${browser}: streaming trace copy should not be hard-coded in English`); assert.match(tracesHtml, /\.event\.streaming \{ border-left:/, `${browser}: Traces UI styling missing`); From b9c3bcf4c1e77b7aa9250089c4a5ab3d370b8380 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 23 Jul 2026 18:17:57 +0300 Subject: [PATCH 07/17] Guard Stop fallback with active run state --- src/chrome/src/ui/sidepanel.js | 63 +++++++++++++++++++++++++-------- src/firefox/src/ui/sidepanel.js | 63 +++++++++++++++++++++++++-------- test/run.js | 19 ++++++---- 3 files changed, 108 insertions(+), 37 deletions(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 515cb83f6..8de81693c 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -9506,19 +9506,36 @@ async function abortRun() { setTabAbortRequested(tabId, true); showActivity(t('sp.activity.stopping')); - try { - await sendToBackground('abort', { tabId }); - } catch { - // Best effort - } - if (follower?.requestId === requestId) { - await follower.promise.catch(() => {}); - } - - // Force UI to settle even if background doesn't respond cleanly - setTimeout(async () => { - if (isTabAbortRequested(tabId)) { - if (!sameTabId(currentTabId, tabId) || !sameTabId(renderedTabId, tabId)) return; + let fallbackTimer = null; + let fallbackCancelled = false; + const fallbackPromise = new Promise(resolve => { + const settleWhenInactive = async () => { + if (fallbackCancelled) { + resolve(); + return; + } + if (!isTabAbortRequested(tabId)) { + resolve(); + return; + } + let state; + try { + state = await sendToBackground('agent_run_state', { tabId, requestId }); + } catch { + state = null; + } + if (fallbackCancelled) { + resolve(); + return; + } + if (!state || state.running || state.starting) { + fallbackTimer = setTimeout(settleWhenInactive, 1000); + return; + } + if (!sameTabId(currentTabId, tabId) || !sameTabId(renderedTabId, tabId)) { + resolve(); + return; + } finalizeSteps(); if (currentAssistantEl) { const textEl = currentAssistantEl.querySelector('.message-text'); @@ -9533,8 +9550,24 @@ async function abortRun() { setTabAbortRequested(tabId, false); await flushRenderedTabChat(); await drainQueuedPromptsAfterRunSettles(); - } - }, 3000); // safety timeout if background takes too long + resolve(); + }; + fallbackTimer = setTimeout(settleWhenInactive, 3000); + }); + + try { + await sendToBackground('abort', { tabId }); + } catch { + // Best effort + } + if (follower?.requestId === requestId) { + await Promise.race([ + follower.promise.catch(() => {}), + fallbackPromise, + ]); + fallbackCancelled = true; + clearTimeout(fallbackTimer); + } } stopBtn.addEventListener('click', abortRun); diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 8a1d890ce..572a7de14 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -9064,19 +9064,36 @@ async function abortRun() { setTabAbortRequested(tabId, true); showActivity(t('sp.activity.stopping')); - try { - await sendToBackground('abort', { tabId }); - } catch { - // Best effort - } - if (follower?.requestId === requestId) { - await follower.promise.catch(() => {}); - } - - // Force UI to settle even if background doesn't respond cleanly - setTimeout(async () => { - if (isTabAbortRequested(tabId)) { - if (!sameTabId(currentTabId, tabId) || !sameTabId(renderedTabId, tabId)) return; + let fallbackTimer = null; + let fallbackCancelled = false; + const fallbackPromise = new Promise(resolve => { + const settleWhenInactive = async () => { + if (fallbackCancelled) { + resolve(); + return; + } + if (!isTabAbortRequested(tabId)) { + resolve(); + return; + } + let state; + try { + state = await sendToBackground('agent_run_state', { tabId, requestId }); + } catch { + state = null; + } + if (fallbackCancelled) { + resolve(); + return; + } + if (!state || state.running || state.starting) { + fallbackTimer = setTimeout(settleWhenInactive, 1000); + return; + } + if (!sameTabId(currentTabId, tabId) || !sameTabId(renderedTabId, tabId)) { + resolve(); + return; + } finalizeSteps(); if (currentAssistantEl) { const textEl = currentAssistantEl.querySelector('.message-text'); @@ -9091,8 +9108,24 @@ async function abortRun() { setTabAbortRequested(tabId, false); await flushRenderedTabChat(); await drainQueuedPromptsAfterRunSettles(); - } - }, 3000); // safety timeout if background takes too long + resolve(); + }; + fallbackTimer = setTimeout(settleWhenInactive, 3000); + }); + + try { + await sendToBackground('abort', { tabId }); + } catch { + // Best effort + } + if (follower?.requestId === requestId) { + await Promise.race([ + follower.promise.catch(() => {}), + fallbackPromise, + ]); + fallbackCancelled = true; + clearTimeout(fallbackTimer); + } } stopBtn.addEventListener('click', abortRun); diff --git a/test/run.js b/test/run.js index 85617e62d..773cf0c06 100644 --- a/test/run.js +++ b/test/run.js @@ -14802,9 +14802,11 @@ test('sidepanel flushes run chat before queue settlement after immediate tab swi assert.notEqual(scheduledDrainIdx, -1, `${label}: scheduled completion should drain queued prompts`); assert.equal(scheduledFlushIdx < scheduledDrainIdx, true, `${label}: scheduled completion must flush before draining queued prompts`); - const abortMatch = panel.match(/setTimeout\(async \(\) => \{[\s\S]*?if \(isTabAbortRequested\(tabId\)\) \{([\s\S]*?)\n \}\n \}, 3000\);/); - assert.ok(abortMatch, `${label}: abort safety timeout body missing`); - const abortBody = abortMatch[1]; + const abortStart = panel.indexOf('const settleWhenInactive = async () => {'); + const abortEnd = panel.indexOf('fallbackTimer = setTimeout(settleWhenInactive, 3000);', abortStart); + assert.notEqual(abortStart, -1, `${label}: abort safety timeout body missing`); + assert.notEqual(abortEnd, -1, `${label}: abort safety timeout registration missing`); + const abortBody = panel.slice(abortStart, abortEnd); const abortFlushIdx = abortBody.indexOf('flushRenderedTabChat()'); const abortDrainIdx = abortBody.indexOf('await drainQueuedPromptsAfterRunSettles();'); assert.notEqual(abortFlushIdx, -1, `${label}: abort timeout should flush the stopped transcript`); @@ -17141,9 +17143,11 @@ test('sidepanel abort safety timeout drains queued prompts', () => { ['firefox', 'src/firefox/src/ui/sidepanel.js'], ]) { const panel = fs.readFileSync(path.join(ROOT, panelRel), 'utf8'); - const match = panel.match(/setTimeout\(async \(\) => \{[\s\S]*?if \(isTabAbortRequested\(tabId\)\) \{([\s\S]*?)\n \}\n \}, 3000\);/); - assert.ok(match, `${label}: abort safety timeout body missing`); - const body = match[1]; + const abortStart = panel.indexOf('const settleWhenInactive = async () => {'); + const abortEnd = panel.indexOf('fallbackTimer = setTimeout(settleWhenInactive, 3000);', abortStart); + assert.notEqual(abortStart, -1, `${label}: abort safety timeout body missing`); + assert.notEqual(abortEnd, -1, `${label}: abort safety timeout registration missing`); + const body = panel.slice(abortStart, abortEnd); const idleIdx = body.indexOf('setTabProcessing(tabId, false);'); const helperIdx = body.indexOf('await drainQueuedPromptsAfterRunSettles();'); assert.notEqual(idleIdx, -1, `${label}: abort timeout should clear processing state`); @@ -47029,7 +47033,8 @@ test('reconnect protocol is wired through both sidepanels and backgrounds', () = panel.indexOf('// --- Voice input', panel.indexOf('// --- Stop / Abort ---')), ); assert.match(stopSection, /localRunRequestIds\.get\(Number\(tabId\)\)[\s\S]*?cancelledRunRecoveryRequestIds\.add\(requestId\)[\s\S]*?setTabAbortRequested\(tabId, true\)/, `${label}: Stop should persist request-scoped cancellation before its UI timeout clears`); - assert.match(stopSection, /const follower = localRunFollowers\.get\(Number\(tabId\)\);[\s\S]*?await sendToBackground\('abort', \{ tabId \}\);[\s\S]*?await follower\.promise\.catch\(\(\) => \{\}\);/, `${label}: Stop should settle the local follower before New conversation can clear its journal`); + assert.match(stopSection, /const follower = localRunFollowers\.get\(Number\(tabId\)\);[\s\S]*?fallbackTimer = setTimeout\(settleWhenInactive, 3000\);[\s\S]*?await sendToBackground\('abort', \{ tabId \}\);[\s\S]*?await Promise\.race\(\[[\s\S]*?follower\.promise\.catch\(\(\) => \{\}\),[\s\S]*?fallbackPromise,[\s\S]*?\]\);/, `${label}: Stop should start its fallback before settling the local follower for New conversation`); + assert.match(stopSection, /sendToBackground\('agent_run_state', \{ tabId, requestId \}\)[\s\S]*?if \(!state \|\| state\.running \|\| state\.starting\) \{[\s\S]*?fallbackTimer = setTimeout\(settleWhenInactive, 1000\);[\s\S]*?setTabProcessing\(tabId, false\);/, `${label}: the Stop fallback must keep polling without unlocking while the background run guard is active`); assert.match(background, /case 'chat_start':[\s\S]*?launchDetachedRun\('chat'/, `${label}: background should acknowledge detached chat starts`); assert.match(background, /case 'continue_start':[\s\S]*?launchDetachedRun\('continue'/, `${label}: background should acknowledge detached continuation starts`); assert.match(background, /case 'chat':[\s\S]*?await beginContinuationRunUiSnapshot\(tabId, msg\.requestId,/, `${label}: replayed fresh chats should preserve journal sequence numbers`); From 7d15930dc8714197a29123382e18a87bec20c967 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 23 Jul 2026 18:22:28 +0300 Subject: [PATCH 08/17] Redact JSON-shaped streaming secrets --- src/chrome/src/agent/agent.js | 2 +- src/firefox/src/agent/agent.js | 2 +- test/run.js | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index c54fe0f30..f073bdd0f 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -1174,7 +1174,7 @@ export class Agent { const rawMessage = String(error?.message || error || 'Streaming request failed.'); const message = rawMessage .replace(/\b(Bearer)\s+[^\s,;]+/gi, '$1 [redacted]') - .replace(/((?:api[_ -]?key|access[_ -]?token|token|secret|password)\s*[:=]\s*)["']?[^\s,"';]+/gi, '$1[redacted]') + .replace(/((?:^|[^a-zA-Z0-9_])["']?(?:api[_ -]?key|access[_ -]?token|token|secret|password)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}\]]+)/gi, '$1[redacted]') .replace(/([?&](?:api[_-]?key|access[_-]?token|token|key)=)[^&\s]+/gi, '$1[redacted]') .replace(/\b(?:sk-(?:or-v1-)?|gsk_|xai-)[a-zA-Z0-9_-]{12,}\b/g, '[redacted]') .slice(0, 500); diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 77cee8981..7536d6641 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -1212,7 +1212,7 @@ export class Agent { const rawMessage = String(error?.message || error || 'Streaming request failed.'); const message = rawMessage .replace(/\b(Bearer)\s+[^\s,;]+/gi, '$1 [redacted]') - .replace(/((?:api[_ -]?key|access[_ -]?token|token|secret|password)\s*[:=]\s*)["']?[^\s,"';]+/gi, '$1[redacted]') + .replace(/((?:^|[^a-zA-Z0-9_])["']?(?:api[_ -]?key|access[_ -]?token|token|secret|password)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}\]]+)/gi, '$1[redacted]') .replace(/([?&](?:api[_-]?key|access[_-]?token|token|key)=)[^&\s]+/gi, '$1[redacted]') .replace(/\b(?:sk-(?:or-v1-)?|gsk_|xai-)[a-zA-Z0-9_-]{12,}\b/g, '[redacted]') .slice(0, 500); diff --git a/test/run.js b/test/run.js index 7fc9d6d04..ef6945f2d 100644 --- a/test/run.js +++ b/test/run.js @@ -27483,11 +27483,11 @@ test('Ask streaming eligibility is limited to interactive runs with a capable pr assert.equal(agent._interactiveAskStreamingProtocol({ _usesResponsesApi: () => false }), 'chat_completions', `${label}: compatible provider protocol should be traceable`); assert.equal(agent._interactiveAskStreamingProtocol({ _usesResponsesApi: () => true }), 'responses', `${label}: Responses protocol should be traceable`); - const transportError = new Error('failed with Authorization: Bearer secret-token, api_key=secret-key, and sk-or-v1-anothersecretkey'); + const transportError = new Error('failed with Authorization: Bearer secret-token, api_key=secret-key, {"api_key":"json-secret","password":"quoted-secret"}, and sk-or-v1-anothersecretkey'); transportError.isAskStreamFallbackSafe = true; const failure = agent._interactiveAskStreamingFailure(transportError); assert.equal(failure.reason, 'transport_error', `${label}: fallback reason should be classified`); - assert.doesNotMatch(failure.message, /secret-token|secret-key|anothersecretkey/, `${label}: trace error must redact secrets`); + assert.doesNotMatch(failure.message, /secret-token|secret-key|json-secret|quoted-secret|anothersecretkey/, `${label}: trace error must redact secrets`); assert.match(failure.message, /\[redacted\]/, `${label}: trace error should retain a redaction marker`); } }); From f94e8f398ee84aaaf7001ac1980eb752534413fc Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 23 Jul 2026 18:25:32 +0300 Subject: [PATCH 09/17] Discard queued prompts before clearing chats --- src/chrome/src/ui/sidepanel.js | 3 +++ src/firefox/src/ui/sidepanel.js | 3 +++ test/run.js | 4 ++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 8de81693c..ab9472ab3 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -10056,6 +10056,9 @@ document.addEventListener('wb-locale-changed', () => { clearBtn.addEventListener('click', async () => { const tabId = currentTabId; if (!window.confirm(t('sp.clear.confirm'))) return; + clearQueuedComposerMessagesForTab(tabId); + clearQueuedForTab(tabId); + await sendToBackground('clear_context_menu_prompt', { tabId }).catch(() => {}); if (isTabProcessing(tabId)) await abortRun(); await sendToBackground('clear_conversation', { tabId }); await renderClearedConversationForTab(tabId); diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 572a7de14..bdfcf159b 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -9508,6 +9508,9 @@ document.addEventListener('wb-locale-changed', () => { clearBtn.addEventListener('click', async () => { const tabId = currentTabId; if (!window.confirm(t('sp.clear.confirm'))) return; + clearQueuedComposerMessagesForTab(tabId); + clearQueuedForTab(tabId); + await sendToBackground('clear_context_menu_prompt', { tabId }).catch(() => {}); if (isTabProcessing(tabId)) await abortRun(); await sendToBackground('clear_conversation', { tabId }); await renderClearedConversationForTab(tabId); diff --git a/test/run.js b/test/run.js index 773cf0c06..c7f91d1a9 100644 --- a/test/run.js +++ b/test/run.js @@ -12264,8 +12264,8 @@ test('sidepanel New conversation uses a message-plus icon and keeps its confirma const clearBody = panel.slice(clearStart, panel.indexOf('\n});', clearStart) + 4); assert.match( clearBody, - /if \(!window\.confirm\(t\('sp\.clear\.confirm'\)\)\) return;[\s\S]*?if \(isTabProcessing\(tabId\)\) await abortRun\(\);[\s\S]*?await sendToBackground\('clear_conversation', \{ tabId \}\);/, - `${label}: confirmed New conversation should stop an active run before clearing`, + /if \(!window\.confirm\(t\('sp\.clear\.confirm'\)\)\) return;[\s\S]*?clearQueuedComposerMessagesForTab\(tabId\);[\s\S]*?clearQueuedForTab\(tabId\);[\s\S]*?await sendToBackground\('clear_context_menu_prompt', \{ tabId \}\)\.catch\(\(\) => \{\}\);[\s\S]*?if \(isTabProcessing\(tabId\)\) await abortRun\(\);[\s\S]*?await sendToBackground\('clear_conversation', \{ tabId \}\);/, + `${label}: confirmed New conversation should discard queued prompts before stopping and clearing`, ); } }); From 9b8bf5f1cc0ff5a8b8349901d080458deebcb52b Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 23 Jul 2026 18:30:17 +0300 Subject: [PATCH 10/17] Scope New Chat aborts to their tab --- src/chrome/src/ui/sidepanel.js | 11 +++++------ src/firefox/src/ui/sidepanel.js | 11 +++++------ test/run.js | 3 ++- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index ab9472ab3..f40b69beb 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -9493,18 +9493,17 @@ modeDevBtn?.addEventListener('click', async () => { // --- Stop / Abort --- -async function abortRun() { - const tabId = currentTabId; +async function abortRun(tabId = currentTabId) { if (!isTabProcessing(tabId)) return; const requestId = String( localRunRequestIds.get(Number(tabId)) - || currentAssistantEl?.dataset?.runRequestId + || (sameTabId(currentTabId, tabId) ? currentAssistantEl?.dataset?.runRequestId : '') || '', ); const follower = localRunFollowers.get(Number(tabId)); if (requestId) cancelledRunRecoveryRequestIds.add(requestId); setTabAbortRequested(tabId, true); - showActivity(t('sp.activity.stopping')); + if (sameTabId(currentTabId, tabId)) showActivity(t('sp.activity.stopping')); let fallbackTimer = null; let fallbackCancelled = false; @@ -9570,7 +9569,7 @@ async function abortRun() { } } -stopBtn.addEventListener('click', abortRun); +stopBtn.addEventListener('click', () => abortRun()); // --- Voice input (mic dictation, issue #210) --- // Web Speech API: well-supported in Chrome, absent in stock Firefox (which @@ -10059,7 +10058,7 @@ clearBtn.addEventListener('click', async () => { clearQueuedComposerMessagesForTab(tabId); clearQueuedForTab(tabId); await sendToBackground('clear_context_menu_prompt', { tabId }).catch(() => {}); - if (isTabProcessing(tabId)) await abortRun(); + if (isTabProcessing(tabId)) await abortRun(tabId); await sendToBackground('clear_conversation', { tabId }); await renderClearedConversationForTab(tabId); }); diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index bdfcf159b..53cbd85aa 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -9051,18 +9051,17 @@ modeDevBtn?.addEventListener('click', async () => { // --- Stop / Abort --- -async function abortRun() { - const tabId = currentTabId; +async function abortRun(tabId = currentTabId) { if (!isTabProcessing(tabId)) return; const requestId = String( localRunRequestIds.get(Number(tabId)) - || currentAssistantEl?.dataset?.runRequestId + || (sameTabId(currentTabId, tabId) ? currentAssistantEl?.dataset?.runRequestId : '') || '', ); const follower = localRunFollowers.get(Number(tabId)); if (requestId) cancelledRunRecoveryRequestIds.add(requestId); setTabAbortRequested(tabId, true); - showActivity(t('sp.activity.stopping')); + if (sameTabId(currentTabId, tabId)) showActivity(t('sp.activity.stopping')); let fallbackTimer = null; let fallbackCancelled = false; @@ -9128,7 +9127,7 @@ async function abortRun() { } } -stopBtn.addEventListener('click', abortRun); +stopBtn.addEventListener('click', () => abortRun()); // --- Voice input (mic dictation, issue #210) --- // Web Speech API: well-supported in Chrome, absent in stock Firefox (which @@ -9511,7 +9510,7 @@ clearBtn.addEventListener('click', async () => { clearQueuedComposerMessagesForTab(tabId); clearQueuedForTab(tabId); await sendToBackground('clear_context_menu_prompt', { tabId }).catch(() => {}); - if (isTabProcessing(tabId)) await abortRun(); + if (isTabProcessing(tabId)) await abortRun(tabId); await sendToBackground('clear_conversation', { tabId }); await renderClearedConversationForTab(tabId); }); diff --git a/test/run.js b/test/run.js index c7f91d1a9..d7cff50a9 100644 --- a/test/run.js +++ b/test/run.js @@ -12264,9 +12264,10 @@ test('sidepanel New conversation uses a message-plus icon and keeps its confirma const clearBody = panel.slice(clearStart, panel.indexOf('\n});', clearStart) + 4); assert.match( clearBody, - /if \(!window\.confirm\(t\('sp\.clear\.confirm'\)\)\) return;[\s\S]*?clearQueuedComposerMessagesForTab\(tabId\);[\s\S]*?clearQueuedForTab\(tabId\);[\s\S]*?await sendToBackground\('clear_context_menu_prompt', \{ tabId \}\)\.catch\(\(\) => \{\}\);[\s\S]*?if \(isTabProcessing\(tabId\)\) await abortRun\(\);[\s\S]*?await sendToBackground\('clear_conversation', \{ tabId \}\);/, + /if \(!window\.confirm\(t\('sp\.clear\.confirm'\)\)\) return;[\s\S]*?clearQueuedComposerMessagesForTab\(tabId\);[\s\S]*?clearQueuedForTab\(tabId\);[\s\S]*?await sendToBackground\('clear_context_menu_prompt', \{ tabId \}\)\.catch\(\(\) => \{\}\);[\s\S]*?if \(isTabProcessing\(tabId\)\) await abortRun\(tabId\);[\s\S]*?await sendToBackground\('clear_conversation', \{ tabId \}\);/, `${label}: confirmed New conversation should discard queued prompts before stopping and clearing`, ); + assert.match(panel, /async function abortRun\(tabId = currentTabId\) \{[\s\S]*?sendToBackground\('abort', \{ tabId \}\)[\s\S]*?stopBtn\.addEventListener\('click', \(\) => abortRun\(\)\);/, `${label}: Stop should support a captured tab target without treating click events as tab ids`); } }); From 90e6ca5f35c308546ea373f510c6b0e86fab7276 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 23 Jul 2026 18:37:54 +0300 Subject: [PATCH 11/17] Cancel schedules after active runs settle --- src/chrome/src/background.js | 2 +- src/firefox/src/background.js | 2 +- test/run.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index d4bf360a8..9f9cef618 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -2361,8 +2361,8 @@ async function handleMessage(msg, sender) { const tabId = msg.tabId || sender.tab?.id; if (tabId) { const conversationId = await agent.getConversationId(tabId); - await scheduler.cancelForConversation(tabId, conversationId); await stopActiveRunBeforeConversationClear(tabId); + await scheduler.cancelForConversation(tabId, conversationId); agent.clearConversation(tabId); clearRunUiSnapshot(tabId); } diff --git a/src/firefox/src/background.js b/src/firefox/src/background.js index f9ae0f493..1ffd6c9f1 100644 --- a/src/firefox/src/background.js +++ b/src/firefox/src/background.js @@ -2071,8 +2071,8 @@ async function handleMessage(msg, sender) { const tabId = msg.tabId || sender.tab?.id; if (tabId) { const conversationId = await agent.getConversationId(tabId); - await scheduler.cancelForConversation(tabId, conversationId); await stopActiveRunBeforeConversationClear(tabId); + await scheduler.cancelForConversation(tabId, conversationId); agent.clearConversation(tabId); clearRunUiSnapshot(tabId); } diff --git a/test/run.js b/test/run.js index d7cff50a9..d8eab31e1 100644 --- a/test/run.js +++ b/test/run.js @@ -12284,7 +12284,7 @@ test('background waits for an active run to stop before clearing its conversatio const clearStart = background.indexOf("case 'clear_conversation':"); const clearBody = background.slice(clearStart, background.indexOf("case 'compact_conversation':", clearStart)); - assert.match(clearBody, /await scheduler\.cancelForConversation\(tabId, conversationId\);[\s\S]*?await stopActiveRunBeforeConversationClear\(tabId\);[\s\S]*?agent\.clearConversation\(tabId\);/, `${label}: conversation state should clear only after the active run settles`); + assert.match(clearBody, /const conversationId = await agent\.getConversationId\(tabId\);[\s\S]*?await stopActiveRunBeforeConversationClear\(tabId\);[\s\S]*?await scheduler\.cancelForConversation\(tabId, conversationId\);[\s\S]*?agent\.clearConversation\(tabId\);/, `${label}: active runs should settle before old-conversation jobs and state are cleared`); } }); From 2807dfd1d45e3c9d5b4b9bd400116876d2c951af Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 23 Jul 2026 18:49:04 +0300 Subject: [PATCH 12/17] Suppress updates from cleared runs --- src/chrome/src/ui/sidepanel.js | 22 ++++++++++++++++++++++ src/firefox/src/ui/sidepanel.js | 22 ++++++++++++++++++++++ test/run.js | 4 +++- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index f40b69beb..1024ad5b0 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -950,6 +950,7 @@ const localRunRequestIds = new Map(); const localRunFollowers = new Map(); const cancelledRunRecoveryRequestIds = new Set(); const adoptedRunRecoveryRequestIds = new Set(); +const clearedConversationRunRequestIds = new Set(); let recommendationsRequestId = 0; let providerSelectionRequestId = 0; let providerTestRequestId = 0; @@ -6235,6 +6236,7 @@ async function parseSlashCommands(text, tabId = currentTabId, options = {}) { } if (command.value === '/reset') { + suppressRunUpdatesForClearedConversation(tabId); await sendToBackground('clear_conversation', { tabId }); await renderClearedConversationForTab(tabId); return ''; @@ -7071,6 +7073,23 @@ function ensureCurrentRunAssistant(msg) { return assistantEl; } +function suppressRunUpdatesForClearedConversation(tabId) { + const requestId = String( + localRunRequestIds.get(Number(tabId)) + || (sameTabId(currentTabId, tabId) ? currentAssistantEl?.dataset?.runRequestId : '') + || '', + ); + if (!requestId) return; + // Runtime messages are delivered asynchronously. Keep recently cleared + // request IDs so a terminal update already queued by the background cannot + // recreate an assistant bubble after the empty conversation is rendered. + clearedConversationRunRequestIds.delete(requestId); + clearedConversationRunRequestIds.add(requestId); + while (clearedConversationRunRequestIds.size > 100) { + clearedConversationRunRequestIds.delete(clearedConversationRunRequestIds.values().next().value); + } +} + function invalidatePlanReviewCards({ tabId = currentTabId, planId = '', requestId = '', runId = '', remove = true } = {}) { for (const card of messagesEl.querySelectorAll('.plan-review-card')) { if (tabId != null && String(card.dataset.tabId || '') !== String(tabId)) continue; @@ -7094,6 +7113,8 @@ function handleAgentUpdateMessage(msg) { return; } + if (msg.requestId && clearedConversationRunRequestIds.has(String(msg.requestId))) return; + // Drop updates that belong to a different tab's run. agent_update is a // window-wide broadcast (chrome.runtime.sendMessage has no per-tab // targeting from the service worker), and the side panel mounts a @@ -10055,6 +10076,7 @@ document.addEventListener('wb-locale-changed', () => { clearBtn.addEventListener('click', async () => { const tabId = currentTabId; if (!window.confirm(t('sp.clear.confirm'))) return; + suppressRunUpdatesForClearedConversation(tabId); clearQueuedComposerMessagesForTab(tabId); clearQueuedForTab(tabId); await sendToBackground('clear_context_menu_prompt', { tabId }).catch(() => {}); diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 53cbd85aa..75b8e83c8 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -808,6 +808,7 @@ const localRunRequestIds = new Map(); const localRunFollowers = new Map(); const cancelledRunRecoveryRequestIds = new Set(); const adoptedRunRecoveryRequestIds = new Set(); +const clearedConversationRunRequestIds = new Set(); let recommendationsRequestId = 0; let providerSelectionRequestId = 0; let providerTestRequestId = 0; @@ -6060,6 +6061,7 @@ async function parseSlashCommands(text, tabId = currentTabId, options = {}) { } if (command.value === '/reset') { + suppressRunUpdatesForClearedConversation(tabId); await sendToBackground('clear_conversation', { tabId }); await renderClearedConversationForTab(tabId); return ''; @@ -6595,6 +6597,23 @@ function ensureCurrentRunAssistant(msg) { return assistantEl; } +function suppressRunUpdatesForClearedConversation(tabId) { + const requestId = String( + localRunRequestIds.get(Number(tabId)) + || (sameTabId(currentTabId, tabId) ? currentAssistantEl?.dataset?.runRequestId : '') + || '', + ); + if (!requestId) return; + // Runtime messages are delivered asynchronously. Keep recently cleared + // request IDs so a terminal update already queued by the background cannot + // recreate an assistant bubble after the empty conversation is rendered. + clearedConversationRunRequestIds.delete(requestId); + clearedConversationRunRequestIds.add(requestId); + while (clearedConversationRunRequestIds.size > 100) { + clearedConversationRunRequestIds.delete(clearedConversationRunRequestIds.values().next().value); + } +} + function invalidatePlanReviewCards({ tabId = currentTabId, planId = '', requestId = '', runId = '', remove = true } = {}) { for (const card of messagesEl.querySelectorAll('.plan-review-card')) { if (tabId != null && String(card.dataset.tabId || '') !== String(tabId)) continue; @@ -6616,6 +6635,8 @@ function handleAgentUpdateMessage(msg) { return; } + if (msg.requestId && clearedConversationRunRequestIds.has(String(msg.requestId))) return; + // Drop updates that belong to a different tab's run. agent_update is a // window-wide broadcast (browser.runtime.sendMessage has no per-tab // targeting from the background script), and the side panel can render @@ -9507,6 +9528,7 @@ document.addEventListener('wb-locale-changed', () => { clearBtn.addEventListener('click', async () => { const tabId = currentTabId; if (!window.confirm(t('sp.clear.confirm'))) return; + suppressRunUpdatesForClearedConversation(tabId); clearQueuedComposerMessagesForTab(tabId); clearQueuedForTab(tabId); await sendToBackground('clear_context_menu_prompt', { tabId }).catch(() => {}); diff --git a/test/run.js b/test/run.js index d8eab31e1..04b5b749d 100644 --- a/test/run.js +++ b/test/run.js @@ -12264,9 +12264,11 @@ test('sidepanel New conversation uses a message-plus icon and keeps its confirma const clearBody = panel.slice(clearStart, panel.indexOf('\n});', clearStart) + 4); assert.match( clearBody, - /if \(!window\.confirm\(t\('sp\.clear\.confirm'\)\)\) return;[\s\S]*?clearQueuedComposerMessagesForTab\(tabId\);[\s\S]*?clearQueuedForTab\(tabId\);[\s\S]*?await sendToBackground\('clear_context_menu_prompt', \{ tabId \}\)\.catch\(\(\) => \{\}\);[\s\S]*?if \(isTabProcessing\(tabId\)\) await abortRun\(tabId\);[\s\S]*?await sendToBackground\('clear_conversation', \{ tabId \}\);/, + /if \(!window\.confirm\(t\('sp\.clear\.confirm'\)\)\) return;[\s\S]*?suppressRunUpdatesForClearedConversation\(tabId\);[\s\S]*?clearQueuedComposerMessagesForTab\(tabId\);[\s\S]*?clearQueuedForTab\(tabId\);[\s\S]*?await sendToBackground\('clear_context_menu_prompt', \{ tabId \}\)\.catch\(\(\) => \{\}\);[\s\S]*?if \(isTabProcessing\(tabId\)\) await abortRun\(tabId\);[\s\S]*?await sendToBackground\('clear_conversation', \{ tabId \}\);/, `${label}: confirmed New conversation should discard queued prompts before stopping and clearing`, ); + assert.match(panel, /function suppressRunUpdatesForClearedConversation\(tabId\) \{[\s\S]*?localRunRequestIds\.get\(Number\(tabId\)\)[\s\S]*?clearedConversationRunRequestIds\.add\(requestId\)[\s\S]*?clearedConversationRunRequestIds\.size > 100/, `${label}: conversation clear should retain a bounded set of invalidated run requests`); + assert.match(panel, /function handleAgentUpdateMessage\(msg\) \{[\s\S]*?if \(msg\.requestId && clearedConversationRunRequestIds\.has\(String\(msg\.requestId\)\)\) return;[\s\S]*?const eventAssistantEl = ensureCurrentRunAssistant\(msg\);/, `${label}: cleared-run updates should be rejected before they can recreate an assistant bubble`); assert.match(panel, /async function abortRun\(tabId = currentTabId\) \{[\s\S]*?sendToBackground\('abort', \{ tabId \}\)[\s\S]*?stopBtn\.addEventListener\('click', \(\) => abortRun\(\)\);/, `${label}: Stop should support a captured tab target without treating click events as tab ids`); } }); From 71d093a164a4c3fcf9833e13d7394264c0390440 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 23 Jul 2026 18:58:41 +0300 Subject: [PATCH 13/17] Wait for stopped run follower before clearing --- src/chrome/src/ui/sidepanel.js | 21 ++++++++++++++++++--- src/firefox/src/ui/sidepanel.js | 21 ++++++++++++++++++--- test/run.js | 4 +++- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 1024ad5b0..aff309bb7 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -6722,7 +6722,11 @@ async function sendMessage(extraChatParams = {}) { const returnedErrorUpdate = Array.isArray(res?.updates) ? res.updates.find(u => u?.type === 'error') : null; - if (returnedErrorUpdate && renderToCurrentTab && currentTabId === tabId && !isTabAbortRequested(tabId)) { + if (returnedErrorUpdate + && renderToCurrentTab + && currentTabId === tabId + && !isTabAbortRequested(tabId) + && !clearedConversationRunRequestIds.has(requestId)) { renderAgentErrorUpdate(returnedErrorUpdate.data, tabId, requestId, { submittedTurnDurable: res.submittedTurnDurable, }); @@ -6796,7 +6800,10 @@ async function sendMessage(extraChatParams = {}) { } syncSendButtonState(); } - } else if (renderToCurrentTab && currentTabId === tabId && !isTabAbortRequested(tabId)) { + } else if (renderToCurrentTab + && currentTabId === tabId + && !isTabAbortRequested(tabId) + && !clearedConversationRunRequestIds.has(requestId)) { renderAgentErrorUpdate({ message: e.message }, tabId, requestId); } } finally { @@ -8667,7 +8674,10 @@ async function continueAgent(options = {}) { } } } catch (e) { - if (currentTabId === tabId && assistantEl && !isTabAbortRequested(tabId)) { + if (currentTabId === tabId + && assistantEl + && !isTabAbortRequested(tabId) + && !clearedConversationRunRequestIds.has(requestId)) { addMessage('error', t('sp.error_prefix', { msg: e.message })); } } finally { @@ -9585,6 +9595,11 @@ async function abortRun(tabId = currentTabId) { follower.promise.catch(() => {}), fallbackPromise, ]); + // A stopped background run can become inactive before this reconnect + // follower consumes its terminal journal. New conversation awaits + // abortRun(), so do not let it clear that journal until the follower has + // observed the terminal snapshot (or reached its bounded failure). + await follower.promise.catch(() => {}); fallbackCancelled = true; clearTimeout(fallbackTimer); } diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 75b8e83c8..11c6dc8e7 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -6457,7 +6457,11 @@ async function sendMessage(extraChatParams = {}) { const returnedErrorUpdate = Array.isArray(res?.updates) ? res.updates.find(u => u?.type === 'error') : null; - if (returnedErrorUpdate && renderToCurrentTab && currentTabId === tabId && !isTabAbortRequested(tabId)) { + if (returnedErrorUpdate + && renderToCurrentTab + && currentTabId === tabId + && !isTabAbortRequested(tabId) + && !clearedConversationRunRequestIds.has(requestId)) { renderAgentErrorUpdate(returnedErrorUpdate.data, tabId, requestId, { submittedTurnDurable: res.submittedTurnDurable, }); @@ -6531,7 +6535,10 @@ async function sendMessage(extraChatParams = {}) { } syncSendButtonState(); } - } else if (renderToCurrentTab && currentTabId === tabId && !isTabAbortRequested(tabId)) { + } else if (renderToCurrentTab + && currentTabId === tabId + && !isTabAbortRequested(tabId) + && !clearedConversationRunRequestIds.has(requestId)) { renderAgentErrorUpdate({ message: e.message }, tabId, requestId); } } finally { @@ -8315,7 +8322,10 @@ async function continueAgent(options = {}) { } } } catch (e) { - if (currentTabId === tabId && assistantEl && !isTabAbortRequested(tabId)) { + if (currentTabId === tabId + && assistantEl + && !isTabAbortRequested(tabId) + && !clearedConversationRunRequestIds.has(requestId)) { addMessage('error', t('sp.error_prefix', { msg: e.message })); } } finally { @@ -9143,6 +9153,11 @@ async function abortRun(tabId = currentTabId) { follower.promise.catch(() => {}), fallbackPromise, ]); + // A stopped background run can become inactive before this reconnect + // follower consumes its terminal journal. New conversation awaits + // abortRun(), so do not let it clear that journal until the follower has + // observed the terminal snapshot (or reached its bounded failure). + await follower.promise.catch(() => {}); fallbackCancelled = true; clearTimeout(fallbackTimer); } diff --git a/test/run.js b/test/run.js index 04b5b749d..cb64dfa8e 100644 --- a/test/run.js +++ b/test/run.js @@ -47036,7 +47036,9 @@ test('reconnect protocol is wired through both sidepanels and backgrounds', () = panel.indexOf('// --- Voice input', panel.indexOf('// --- Stop / Abort ---')), ); assert.match(stopSection, /localRunRequestIds\.get\(Number\(tabId\)\)[\s\S]*?cancelledRunRecoveryRequestIds\.add\(requestId\)[\s\S]*?setTabAbortRequested\(tabId, true\)/, `${label}: Stop should persist request-scoped cancellation before its UI timeout clears`); - assert.match(stopSection, /const follower = localRunFollowers\.get\(Number\(tabId\)\);[\s\S]*?fallbackTimer = setTimeout\(settleWhenInactive, 3000\);[\s\S]*?await sendToBackground\('abort', \{ tabId \}\);[\s\S]*?await Promise\.race\(\[[\s\S]*?follower\.promise\.catch\(\(\) => \{\}\),[\s\S]*?fallbackPromise,[\s\S]*?\]\);/, `${label}: Stop should start its fallback before settling the local follower for New conversation`); + assert.match(stopSection, /const follower = localRunFollowers\.get\(Number\(tabId\)\);[\s\S]*?fallbackTimer = setTimeout\(settleWhenInactive, 3000\);[\s\S]*?await sendToBackground\('abort', \{ tabId \}\);[\s\S]*?await Promise\.race\(\[[\s\S]*?follower\.promise\.catch\(\(\) => \{\}\),[\s\S]*?fallbackPromise,[\s\S]*?\]\);[\s\S]*?await follower\.promise\.catch\(\(\) => \{\}\);[\s\S]*?fallbackCancelled = true;/, `${label}: Stop should start its fallback but still settle the local follower before New conversation can clear its journal`); + assert.match(panel, /returnedErrorUpdate[\s\S]*?!isTabAbortRequested\(tabId\)[\s\S]*?!clearedConversationRunRequestIds\.has\(requestId\)[\s\S]*?renderAgentErrorUpdate/, `${label}: cleared chats should suppress returned run errors even after the fallback clears the abort flag`); + assert.match(panel, /catch \(e\) \{[\s\S]*?currentTabId === tabId[\s\S]*?assistantEl[\s\S]*?!isTabAbortRequested\(tabId\)[\s\S]*?!clearedConversationRunRequestIds\.has\(requestId\)/, `${label}: cleared chats should suppress reconnect failures from direct and continuation runs`); assert.match(stopSection, /sendToBackground\('agent_run_state', \{ tabId, requestId \}\)[\s\S]*?if \(!state \|\| state\.running \|\| state\.starting\) \{[\s\S]*?fallbackTimer = setTimeout\(settleWhenInactive, 1000\);[\s\S]*?setTabProcessing\(tabId, false\);/, `${label}: the Stop fallback must keep polling without unlocking while the background run guard is active`); assert.match(background, /case 'chat_start':[\s\S]*?launchDetachedRun\('chat'/, `${label}: background should acknowledge detached chat starts`); assert.match(background, /case 'continue_start':[\s\S]*?launchDetachedRun\('continue'/, `${label}: background should acknowledge detached continuation starts`); From 20ef9a0f7483f764b37c2864e68b9effb409ad5c Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 23 Jul 2026 19:06:20 +0300 Subject: [PATCH 14/17] Bound unavailable stop-state probes --- src/chrome/src/ui/sidepanel.js | 15 ++++++++++++++- src/firefox/src/ui/sidepanel.js | 15 ++++++++++++++- test/run.js | 2 +- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index aff309bb7..65341cc4a 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -9538,6 +9538,8 @@ async function abortRun(tabId = currentTabId) { let fallbackTimer = null; let fallbackCancelled = false; + let fallbackProbeFailures = 0; + const maxFallbackProbeFailures = 5; const fallbackPromise = new Promise(resolve => { const settleWhenInactive = async () => { if (fallbackCancelled) { @@ -9558,7 +9560,18 @@ async function abortRun(tabId = currentTabId) { resolve(); return; } - if (!state || state.running || state.starting) { + if (!state) { + fallbackProbeFailures += 1; + // A local follower has its own bounded reconnect policy and remains + // authoritative. Without one, eventually release a panel whose + // background disappeared instead of polling "Stopping…" forever. + if (follower?.requestId === requestId + || fallbackProbeFailures < maxFallbackProbeFailures) { + fallbackTimer = setTimeout(settleWhenInactive, 1000); + return; + } + } else if (state.running || state.starting) { + fallbackProbeFailures = 0; fallbackTimer = setTimeout(settleWhenInactive, 1000); return; } diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 11c6dc8e7..7315a0f59 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -9096,6 +9096,8 @@ async function abortRun(tabId = currentTabId) { let fallbackTimer = null; let fallbackCancelled = false; + let fallbackProbeFailures = 0; + const maxFallbackProbeFailures = 5; const fallbackPromise = new Promise(resolve => { const settleWhenInactive = async () => { if (fallbackCancelled) { @@ -9116,7 +9118,18 @@ async function abortRun(tabId = currentTabId) { resolve(); return; } - if (!state || state.running || state.starting) { + if (!state) { + fallbackProbeFailures += 1; + // A local follower has its own bounded reconnect policy and remains + // authoritative. Without one, eventually release a panel whose + // background disappeared instead of polling "Stopping…" forever. + if (follower?.requestId === requestId + || fallbackProbeFailures < maxFallbackProbeFailures) { + fallbackTimer = setTimeout(settleWhenInactive, 1000); + return; + } + } else if (state.running || state.starting) { + fallbackProbeFailures = 0; fallbackTimer = setTimeout(settleWhenInactive, 1000); return; } diff --git a/test/run.js b/test/run.js index cb64dfa8e..ce17dcd1a 100644 --- a/test/run.js +++ b/test/run.js @@ -47039,7 +47039,7 @@ test('reconnect protocol is wired through both sidepanels and backgrounds', () = assert.match(stopSection, /const follower = localRunFollowers\.get\(Number\(tabId\)\);[\s\S]*?fallbackTimer = setTimeout\(settleWhenInactive, 3000\);[\s\S]*?await sendToBackground\('abort', \{ tabId \}\);[\s\S]*?await Promise\.race\(\[[\s\S]*?follower\.promise\.catch\(\(\) => \{\}\),[\s\S]*?fallbackPromise,[\s\S]*?\]\);[\s\S]*?await follower\.promise\.catch\(\(\) => \{\}\);[\s\S]*?fallbackCancelled = true;/, `${label}: Stop should start its fallback but still settle the local follower before New conversation can clear its journal`); assert.match(panel, /returnedErrorUpdate[\s\S]*?!isTabAbortRequested\(tabId\)[\s\S]*?!clearedConversationRunRequestIds\.has\(requestId\)[\s\S]*?renderAgentErrorUpdate/, `${label}: cleared chats should suppress returned run errors even after the fallback clears the abort flag`); assert.match(panel, /catch \(e\) \{[\s\S]*?currentTabId === tabId[\s\S]*?assistantEl[\s\S]*?!isTabAbortRequested\(tabId\)[\s\S]*?!clearedConversationRunRequestIds\.has\(requestId\)/, `${label}: cleared chats should suppress reconnect failures from direct and continuation runs`); - assert.match(stopSection, /sendToBackground\('agent_run_state', \{ tabId, requestId \}\)[\s\S]*?if \(!state \|\| state\.running \|\| state\.starting\) \{[\s\S]*?fallbackTimer = setTimeout\(settleWhenInactive, 1000\);[\s\S]*?setTabProcessing\(tabId, false\);/, `${label}: the Stop fallback must keep polling without unlocking while the background run guard is active`); + assert.match(stopSection, /let fallbackProbeFailures = 0;[\s\S]*?const maxFallbackProbeFailures = 5;[\s\S]*?if \(!state\) \{[\s\S]*?fallbackProbeFailures \+= 1;[\s\S]*?follower\?\.requestId === requestId[\s\S]*?fallbackProbeFailures < maxFallbackProbeFailures[\s\S]*?setTimeout\(settleWhenInactive, 1000\)[\s\S]*?\} else if \(state\.running \|\| state\.starting\) \{[\s\S]*?fallbackProbeFailures = 0;[\s\S]*?setTimeout\(settleWhenInactive, 1000\)[\s\S]*?setTabProcessing\(tabId, false\);/, `${label}: Stop should bound unavailable probes without unlocking a confirmed active run or bypassing a local follower`); assert.match(background, /case 'chat_start':[\s\S]*?launchDetachedRun\('chat'/, `${label}: background should acknowledge detached chat starts`); assert.match(background, /case 'continue_start':[\s\S]*?launchDetachedRun\('continue'/, `${label}: background should acknowledge detached continuation starts`); assert.match(background, /case 'chat':[\s\S]*?await beginContinuationRunUiSnapshot\(tabId, msg\.requestId,/, `${label}: replayed fresh chats should preserve journal sequence numbers`); From 360cfeabd99287c7336aab9e999c801a29b9764f Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 23 Jul 2026 19:16:53 +0300 Subject: [PATCH 15/17] Keep composer locked while clearing --- src/chrome/src/ui/sidepanel.js | 53 ++++++++++++++++++++++++++------- src/firefox/src/ui/sidepanel.js | 53 ++++++++++++++++++++++++++------- test/run.js | 4 ++- 3 files changed, 87 insertions(+), 23 deletions(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 65341cc4a..fb2e0b26c 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -946,6 +946,7 @@ let abortRequested = false; const awaitingPlanReviewTabs = new Set(); const processingTabs = new Set(); const abortRequestedTabs = new Set(); +const clearingConversationTabs = new Set(); const localRunRequestIds = new Map(); const localRunFollowers = new Map(); const cancelledRunRecoveryRequestIds = new Set(); @@ -979,6 +980,19 @@ function isTabProcessing(tabId) { return Number.isFinite(numericTabId) && processingTabs.has(numericTabId); } +function setConversationClearInProgress(tabId, clearing) { + const numericTabId = Number(tabId); + if (!Number.isFinite(numericTabId)) return; + if (clearing) clearingConversationTabs.add(numericTabId); + else clearingConversationTabs.delete(numericTabId); + if (sameTabId(currentTabId, numericTabId)) syncSendButtonState(); +} + +function isConversationClearInProgress(tabId = currentTabId) { + const numericTabId = Number(tabId); + return Number.isFinite(numericTabId) && clearingConversationTabs.has(numericTabId); +} + function setTabAbortRequested(tabId, requested) { const numericTabId = Number(tabId); if (!Number.isFinite(numericTabId)) return; @@ -1008,7 +1022,7 @@ const { clearQueuedForTab, } = createContextMenuPromptHandler({ getCurrentTabId: () => currentTabId, - getIsProcessing: () => isProcessing, + getIsProcessing: () => isProcessing || isConversationClearInProgress(), getAgentMode: () => agentMode, setMode, getInputEl: () => inputEl, @@ -5879,6 +5893,10 @@ function syncSendButtonState() { sendBtn.disabled = true; return; } + if (isConversationClearInProgress()) { + sendBtn.disabled = true; + return; + } if (!isProcessing) { sendBtn.disabled = isAttachmentReadPendingForTab(); return; @@ -6236,9 +6254,15 @@ async function parseSlashCommands(text, tabId = currentTabId, options = {}) { } if (command.value === '/reset') { - suppressRunUpdatesForClearedConversation(tabId); - await sendToBackground('clear_conversation', { tabId }); - await renderClearedConversationForTab(tabId); + setConversationClearInProgress(tabId, true); + try { + suppressRunUpdatesForClearedConversation(tabId); + if (isTabProcessing(tabId)) await abortRun(tabId); + await sendToBackground('clear_conversation', { tabId }); + await renderClearedConversationForTab(tabId); + } finally { + setConversationClearInProgress(tabId, false); + } return ''; } @@ -6550,6 +6574,7 @@ async function sendMessage(extraChatParams = {}) { if (!text) return; const submittedText = text; const tabId = currentTabId; + if (isConversationClearInProgress(tabId)) return false; const permissionSkipContext = permissionSkipCommandContextForDraft(tabId, text); const requestId = createRunRequestId(tabId); text = normalizeScreenshotCommandText(text); @@ -10103,14 +10128,20 @@ document.addEventListener('wb-locale-changed', () => { clearBtn.addEventListener('click', async () => { const tabId = currentTabId; + if (isConversationClearInProgress(tabId)) return; if (!window.confirm(t('sp.clear.confirm'))) return; - suppressRunUpdatesForClearedConversation(tabId); - clearQueuedComposerMessagesForTab(tabId); - clearQueuedForTab(tabId); - await sendToBackground('clear_context_menu_prompt', { tabId }).catch(() => {}); - if (isTabProcessing(tabId)) await abortRun(tabId); - await sendToBackground('clear_conversation', { tabId }); - await renderClearedConversationForTab(tabId); + setConversationClearInProgress(tabId, true); + try { + suppressRunUpdatesForClearedConversation(tabId); + clearQueuedComposerMessagesForTab(tabId); + clearQueuedForTab(tabId); + await sendToBackground('clear_context_menu_prompt', { tabId }).catch(() => {}); + if (isTabProcessing(tabId)) await abortRun(tabId); + await sendToBackground('clear_conversation', { tabId }); + await renderClearedConversationForTab(tabId); + } finally { + setConversationClearInProgress(tabId, false); + } }); providerSelect.addEventListener('change', async () => { diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 7315a0f59..9a1acd09e 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -804,6 +804,7 @@ let abortRequested = false; const awaitingPlanReviewTabs = new Set(); const processingTabs = new Set(); const abortRequestedTabs = new Set(); +const clearingConversationTabs = new Set(); const localRunRequestIds = new Map(); const localRunFollowers = new Map(); const cancelledRunRecoveryRequestIds = new Set(); @@ -837,6 +838,19 @@ function isTabProcessing(tabId) { return Number.isFinite(numericTabId) && processingTabs.has(numericTabId); } +function setConversationClearInProgress(tabId, clearing) { + const numericTabId = Number(tabId); + if (!Number.isFinite(numericTabId)) return; + if (clearing) clearingConversationTabs.add(numericTabId); + else clearingConversationTabs.delete(numericTabId); + if (sameTabId(currentTabId, numericTabId)) syncSendButtonState(); +} + +function isConversationClearInProgress(tabId = currentTabId) { + const numericTabId = Number(tabId); + return Number.isFinite(numericTabId) && clearingConversationTabs.has(numericTabId); +} + function setTabAbortRequested(tabId, requested) { const numericTabId = Number(tabId); if (!Number.isFinite(numericTabId)) return; @@ -866,7 +880,7 @@ const { clearQueuedForTab, } = createContextMenuPromptHandler({ getCurrentTabId: () => currentTabId, - getIsProcessing: () => isProcessing, + getIsProcessing: () => isProcessing || isConversationClearInProgress(), getAgentMode: () => agentMode, setMode, getInputEl: () => inputEl, @@ -5709,6 +5723,10 @@ function syncSendButtonState() { sendBtn.disabled = true; return; } + if (isConversationClearInProgress()) { + sendBtn.disabled = true; + return; + } if (!isProcessing) { sendBtn.disabled = isAttachmentReadPendingForTab(); return; @@ -6061,9 +6079,15 @@ async function parseSlashCommands(text, tabId = currentTabId, options = {}) { } if (command.value === '/reset') { - suppressRunUpdatesForClearedConversation(tabId); - await sendToBackground('clear_conversation', { tabId }); - await renderClearedConversationForTab(tabId); + setConversationClearInProgress(tabId, true); + try { + suppressRunUpdatesForClearedConversation(tabId); + if (isTabProcessing(tabId)) await abortRun(tabId); + await sendToBackground('clear_conversation', { tabId }); + await renderClearedConversationForTab(tabId); + } finally { + setConversationClearInProgress(tabId, false); + } return ''; } @@ -6290,6 +6314,7 @@ async function sendMessage(extraChatParams = {}) { if (!text) return; const submittedText = text; const tabId = currentTabId; + if (isConversationClearInProgress(tabId)) return false; const permissionSkipContext = permissionSkipCommandContextForDraft(tabId, text); const requestId = createRunRequestId(tabId); text = normalizeScreenshotCommandText(text); @@ -9555,14 +9580,20 @@ document.addEventListener('wb-locale-changed', () => { clearBtn.addEventListener('click', async () => { const tabId = currentTabId; + if (isConversationClearInProgress(tabId)) return; if (!window.confirm(t('sp.clear.confirm'))) return; - suppressRunUpdatesForClearedConversation(tabId); - clearQueuedComposerMessagesForTab(tabId); - clearQueuedForTab(tabId); - await sendToBackground('clear_context_menu_prompt', { tabId }).catch(() => {}); - if (isTabProcessing(tabId)) await abortRun(tabId); - await sendToBackground('clear_conversation', { tabId }); - await renderClearedConversationForTab(tabId); + setConversationClearInProgress(tabId, true); + try { + suppressRunUpdatesForClearedConversation(tabId); + clearQueuedComposerMessagesForTab(tabId); + clearQueuedForTab(tabId); + await sendToBackground('clear_context_menu_prompt', { tabId }).catch(() => {}); + if (isTabProcessing(tabId)) await abortRun(tabId); + await sendToBackground('clear_conversation', { tabId }); + await renderClearedConversationForTab(tabId); + } finally { + setConversationClearInProgress(tabId, false); + } }); providerSelect.addEventListener('change', async () => { diff --git a/test/run.js b/test/run.js index ce17dcd1a..9079cdc7e 100644 --- a/test/run.js +++ b/test/run.js @@ -12264,9 +12264,11 @@ test('sidepanel New conversation uses a message-plus icon and keeps its confirma const clearBody = panel.slice(clearStart, panel.indexOf('\n});', clearStart) + 4); assert.match( clearBody, - /if \(!window\.confirm\(t\('sp\.clear\.confirm'\)\)\) return;[\s\S]*?suppressRunUpdatesForClearedConversation\(tabId\);[\s\S]*?clearQueuedComposerMessagesForTab\(tabId\);[\s\S]*?clearQueuedForTab\(tabId\);[\s\S]*?await sendToBackground\('clear_context_menu_prompt', \{ tabId \}\)\.catch\(\(\) => \{\}\);[\s\S]*?if \(isTabProcessing\(tabId\)\) await abortRun\(tabId\);[\s\S]*?await sendToBackground\('clear_conversation', \{ tabId \}\);/, + /if \(!window\.confirm\(t\('sp\.clear\.confirm'\)\)\) return;[\s\S]*?setConversationClearInProgress\(tabId, true\);[\s\S]*?suppressRunUpdatesForClearedConversation\(tabId\);[\s\S]*?clearQueuedComposerMessagesForTab\(tabId\);[\s\S]*?clearQueuedForTab\(tabId\);[\s\S]*?await sendToBackground\('clear_context_menu_prompt', \{ tabId \}\)\.catch\(\(\) => \{\}\);[\s\S]*?if \(isTabProcessing\(tabId\)\) await abortRun\(tabId\);[\s\S]*?await sendToBackground\('clear_conversation', \{ tabId \}\);[\s\S]*?finally \{[\s\S]*?setConversationClearInProgress\(tabId, false\);/, `${label}: confirmed New conversation should discard queued prompts before stopping and clearing`, ); + assert.match(panel, /function syncSendButtonState\(\) \{[\s\S]*?isConversationClearInProgress\(\)[\s\S]*?sendBtn\.disabled = true;/, `${label}: the composer should stay disabled for the full clear transaction`); + assert.match(panel, /async function sendMessage\(extraChatParams = \{\}\) \{[\s\S]*?const tabId = currentTabId;[\s\S]*?if \(isConversationClearInProgress\(tabId\)\) return false;/, `${label}: Enter and programmatic sends should not bypass the pending-clear interlock`); assert.match(panel, /function suppressRunUpdatesForClearedConversation\(tabId\) \{[\s\S]*?localRunRequestIds\.get\(Number\(tabId\)\)[\s\S]*?clearedConversationRunRequestIds\.add\(requestId\)[\s\S]*?clearedConversationRunRequestIds\.size > 100/, `${label}: conversation clear should retain a bounded set of invalidated run requests`); assert.match(panel, /function handleAgentUpdateMessage\(msg\) \{[\s\S]*?if \(msg\.requestId && clearedConversationRunRequestIds\.has\(String\(msg\.requestId\)\)\) return;[\s\S]*?const eventAssistantEl = ensureCurrentRunAssistant\(msg\);/, `${label}: cleared-run updates should be rejected before they can recreate an assistant bubble`); assert.match(panel, /async function abortRun\(tabId = currentTabId\) \{[\s\S]*?sendToBackground\('abort', \{ tabId \}\)[\s\S]*?stopBtn\.addEventListener\('click', \(\) => abortRun\(\)\);/, `${label}: Stop should support a captured tab target without treating click events as tab ids`); From c2e2e89951c9403e9c3f5b527ff1b0a782656cf0 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 23 Jul 2026 19:27:09 +0300 Subject: [PATCH 16/17] docs: add Discord support link to sidebar --- web/docs/index.html | 4 ++++ web/docs/providers/index.html | 2 +- web/docs/safety/index.html | 2 +- web/docs/settings/index.html | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/web/docs/index.html b/web/docs/index.html index d34875155..a44c00164 100644 --- a/web/docs/index.html +++ b/web/docs/index.html @@ -48,6 +48,10 @@
+
diff --git a/web/docs/providers/index.html b/web/docs/providers/index.html index 178b81339..9d142d192 100644 --- a/web/docs/providers/index.html +++ b/web/docs/providers/index.html @@ -16,7 +16,7 @@
- +
The model behind the panel

Choose the setup that matches your patience, privacy, and budget.

WebBrain includes 103 provider cards spanning its built-in cloud option, direct cloud APIs, model routers, and local OpenAI-compatible servers. You only need one active provider.

diff --git a/web/docs/safety/index.html b/web/docs/safety/index.html index c8b0397f3..e3ad0862e 100644 --- a/web/docs/safety/index.html +++ b/web/docs/safety/index.html @@ -16,7 +16,7 @@
- +
Human in the loop

WebBrain works inside your logged-in browser. Treat that access with care.

The agent can see and use the same websites you can. Modes, plan review, and per-site permissions reduce risk, but you remain responsible for the prompt, destination, and outcome.

diff --git a/web/docs/settings/index.html b/web/docs/settings/index.html index bb50c4be4..72fe8f069 100644 --- a/web/docs/settings/index.html +++ b/web/docs/settings/index.html @@ -19,7 +19,7 @@
- +
Settings, without guesswork

Know what to change—and what to leave alone.

Open Settings from the gear in the side panel or from your browser’s extension options. Most people only need General, Providers, and Permissions; the other tabs solve specific problems.

Screenshots captured from WebBrain 22.3.1 · example values contain no real credentials
From 17868d9134cce8c12d53aabc319747bf5521f979 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Thu, 23 Jul 2026 19:34:42 +0300 Subject: [PATCH 17/17] Fix duplicate normalized streamed answers --- src/chrome/src/ui/sidepanel.js | 10 ++--- src/firefox/src/ui/sidepanel.js | 10 ++--- test/run.js | 80 ++++++++++++++++++++++++++++++++- 3 files changed, 88 insertions(+), 12 deletions(-) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index a8682663a..e2344f347 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -8167,8 +8167,8 @@ function renderAssistantTextUpdate(assistantEl, content, options = {}) { } const streamedText = getStreamedAssistantText(textEl); - const restoredStreamNeedsReplacement = hasStreamedAssistantText(textEl) && !streamedText; - const isDuplicateStreamFinal = streamedText && streamedText === String(content); + const hasStreamedText = hasStreamedAssistantText(textEl); + const restoredStreamNeedsReplacement = hasStreamedText && !streamedText; if (options.replace === true || restoredStreamNeedsReplacement) { // A rejected streamed terminal must replace its already-rendered deltas @@ -8181,11 +8181,11 @@ function renderAssistantTextUpdate(assistantEl, content, options = {}) { textEl.textContent = ''; clearStreamedAssistantText(textEl); } - } else if (verboseMode && !isDuplicateStreamFinal) { + } else if (verboseMode && !hasStreamedText) { // Verbose mode: append each non-streamed turn as its own paragraph so // intermediate prose is preserved alongside the steps log. Streaming - // finals are already visible live, so format the existing stream instead - // of appending a duplicate paragraph at run completion. + // finals are already visible live, so format the authoritative terminal + // content in place even when cleanup changed it from the raw stream. const para = document.createElement('div'); para.className = 'reasoning-step'; para.innerHTML = formatMarkdown(content); diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 744146095..6af4b6a92 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -7816,8 +7816,8 @@ function renderAssistantTextUpdate(assistantEl, content, options = {}) { } const streamedText = getStreamedAssistantText(textEl); - const restoredStreamNeedsReplacement = hasStreamedAssistantText(textEl) && !streamedText; - const isDuplicateStreamFinal = streamedText && streamedText === String(content); + const hasStreamedText = hasStreamedAssistantText(textEl); + const restoredStreamNeedsReplacement = hasStreamedText && !streamedText; if (options.replace === true || restoredStreamNeedsReplacement) { // A rejected streamed terminal must replace its already-rendered deltas @@ -7830,11 +7830,11 @@ function renderAssistantTextUpdate(assistantEl, content, options = {}) { textEl.textContent = ''; clearStreamedAssistantText(textEl); } - } else if (verboseMode && !isDuplicateStreamFinal) { + } else if (verboseMode && !hasStreamedText) { // Verbose mode: append each non-streamed turn as its own paragraph so // intermediate prose is preserved alongside the steps log. Streaming - // finals are already visible live, so format the existing stream instead - // of appending a duplicate paragraph at run completion. + // finals are already visible live, so format the authoritative terminal + // content in place even when cleanup changed it from the raw stream. const para = document.createElement('div'); para.className = 'reasoning-step'; para.innerHTML = formatMarkdown(content); diff --git a/test/run.js b/test/run.js index f89709e66..98cfc8ce2 100644 --- a/test/run.js +++ b/test/run.js @@ -14175,8 +14175,8 @@ test('sidepanel suppresses streamed raw tool-call text before rendering tool ste 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]*?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`); + assert.match(panel, /function renderAssistantTextUpdate\(assistantEl, content, options = \{\}\) \{[\s\S]*?const hasStreamedText = hasStreamedAssistantText\(textEl\);[\s\S]*?const restoredStreamNeedsReplacement = hasStreamedText && !streamedText;[\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 && !hasStreamedText\)/, `${label}: explicit and restored-stream replacements should overwrite or clear verbose streamed text`); + assert.match(panel, /function renderAssistantTextUpdate\(assistantEl, content, options = \{\}\) \{[\s\S]*?const hasStreamedText = hasStreamedAssistantText\(textEl\);[\s\S]*?else if \(verboseMode && !hasStreamedText\)[\s\S]*?textEl\.innerHTML = formatMarkdown\(content\);/, `${label}: every streamed final should format in place even when terminal cleanup changed the raw text`); const start = panel.indexOf("case 'tool_call':"); const end = panel.indexOf("case 'tool_result':", start); assert.notEqual(start, -1, `${label}: tool_call handler missing`); @@ -14194,6 +14194,82 @@ test('sidepanel suppresses streamed raw tool-call text before rendering tool ste } }); +test('verbose terminal rendering does not append a normalized streamed answer twice', () => { + for (const [label, panelRel] of [ + ['chrome', 'src/chrome/src/ui/sidepanel.js'], + ['firefox', 'src/firefox/src/ui/sidepanel.js'], + ]) { + const panel = fs.readFileSync(path.join(ROOT, panelRel), 'utf8'); + const start = panel.indexOf('function renderAssistantTextUpdate(assistantEl, content, options = {}) {'); + const end = panel.indexOf('\nfunction isStoppedByUserStatus(', start); + assert.notEqual(start, -1, `${label}: assistant terminal renderer missing`); + assert.notEqual(end, -1, `${label}: assistant terminal renderer boundary missing`); + + const streamedTextByEl = new WeakMap(); + const appended = []; + const makeTextEl = (html = '') => ({ + dataset: {}, + classList: { contains: () => false }, + innerHTML: html, + textContent: html, + replaceChildren() { + this.innerHTML = ''; + this.textContent = ''; + }, + appendChild(node) { + appended.push(node); + this.innerHTML += node.innerHTML; + }, + }); + const context = { + verboseMode: true, + isStoppedByUserStatus: () => false, + parseCostAllowanceError: () => null, + renderSubscribeError: () => false, + getStreamedAssistantText: textEl => streamedTextByEl.get(textEl) || '', + hasStreamedAssistantText: textEl => streamedTextByEl.has(textEl) + || textEl?.dataset?.streamedAssistantActive === 'true', + clearStreamedAssistantText: textEl => { + streamedTextByEl.delete(textEl); + delete textEl.dataset.streamedAssistantActive; + }, + formatMarkdown: value => String(value), + addMessageCopyButton: () => {}, + document: { + createElement: () => ({ className: '', innerHTML: '' }), + }, + }; + const renderAssistantTextUpdate = vm.runInNewContext( + `(${panel.slice(start, end).trim()})`, + context, + ); + + const rawStream = '\n\n**Decision**\nOne answer.'; + const terminalContent = rawStream.replace(/^\s+/, ''); + assert.notEqual(rawStream, terminalContent, `${label}: fixture must exercise terminal normalization`); + const streamedTextEl = makeTextEl(rawStream); + streamedTextEl.dataset.streamedAssistantActive = 'true'; + streamedTextByEl.set(streamedTextEl, rawStream); + const streamedAssistantEl = { + querySelector: selector => selector === '.message-text' ? streamedTextEl : null, + }; + + renderAssistantTextUpdate(streamedAssistantEl, terminalContent); + + assert.equal(streamedTextEl.innerHTML, terminalContent, `${label}: normalized terminal content should replace the live stream`); + assert.equal(appended.length, 0, `${label}: normalized streamed final must not append a verbose reasoning paragraph`); + assert.equal(streamedTextByEl.has(streamedTextEl), false, `${label}: terminal render should clear raw stream state`); + + const nonStreamedTextEl = makeTextEl('Prior verbose turn.'); + const nonStreamedAssistantEl = { + querySelector: selector => selector === '.message-text' ? nonStreamedTextEl : null, + }; + renderAssistantTextUpdate(nonStreamedAssistantEl, 'New non-streamed turn.'); + assert.equal(appended.length, 1, `${label}: verbose mode should still append genuinely non-streamed turns`); + assert.equal(appended[0].className, 'reasoning-step', `${label}: non-streamed verbose turn should keep reasoning styling`); + } +}); + function runSettingsTabsScript(script, { saved = null, hash = '' } = {}) { function makeClassList() { return {