diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js
index aa62ffa3c..a5430ded4 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(/((?:^|[^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);
+ 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
@@ -18405,6 +18446,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);
@@ -18516,13 +18565,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 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;
+ queueAskStreamingTraceWrite(
+ () => trace.recordStreaming(traceRunId, traceStep, payload),
+ );
+ };
+
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,
@@ -18532,9 +18603,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,
@@ -18542,13 +18628,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',
@@ -18631,7 +18735,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) {
@@ -18642,7 +18755,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)) {
@@ -18936,10 +19059,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/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/background.js b/src/chrome/src/background.js
index 16121ddb1..9f9cef618 100644
--- a/src/chrome/src/background.js
+++ b/src/chrome/src/background.js
@@ -1483,6 +1483,29 @@ 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(() => {});
+ }
+ // 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;
+}
+
function acquireRunKeepalive() {
let released = false;
const touch = () => {
@@ -2338,6 +2361,7 @@ async function handleMessage(msg, sender) {
const tabId = msg.tabId || sender.tab?.id;
if (tabId) {
const conversationId = await agent.getConversationId(tabId);
+ await stopActiveRunBeforeConversationClear(tabId);
await scheduler.cancelForConversation(tabId, conversationId);
agent.clearConversation(tabId);
clearRunUiSnapshot(tabId);
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/sidepanel.js b/src/chrome/src/ui/sidepanel.js
index b089449b5..e2344f347 100644
--- a/src/chrome/src/ui/sidepanel.js
+++ b/src/chrome/src/ui/sidepanel.js
@@ -946,9 +946,12 @@ 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();
const adoptedRunRecoveryRequestIds = new Set();
+const clearedConversationRunRequestIds = new Set();
let recommendationsRequestId = 0;
let providerSelectionRequestId = 0;
let providerTestRequestId = 0;
@@ -977,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;
@@ -1006,7 +1022,7 @@ const {
clearQueuedForTab,
} = createContextMenuPromptHandler({
getCurrentTabId: () => currentTabId,
- getIsProcessing: () => isProcessing,
+ getIsProcessing: () => isProcessing || isConversationClearInProgress(),
getAgentMode: () => agentMode,
setMode,
getInputEl: () => inputEl,
@@ -5877,6 +5893,10 @@ function syncSendButtonState() {
sendBtn.disabled = true;
return;
}
+ if (isConversationClearInProgress()) {
+ sendBtn.disabled = true;
+ return;
+ }
if (!isProcessing) {
sendBtn.disabled = isAttachmentReadPendingForTab();
return;
@@ -6234,8 +6254,15 @@ async function parseSlashCommands(text, tabId = currentTabId, options = {}) {
}
if (command.value === '/reset') {
- 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 '';
}
@@ -6547,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);
@@ -6719,7 +6747,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,
});
@@ -6793,7 +6825,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 {
@@ -7070,6 +7105,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;
@@ -7093,6 +7145,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
@@ -8113,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
@@ -8127,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);
@@ -8648,7 +8702,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 {
@@ -9309,7 +9366,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),
@@ -9333,6 +9390,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) {
@@ -9488,28 +9552,61 @@ 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'));
-
- try {
- await sendToBackground('abort', { tabId });
- } catch {
- // Best effort
- }
-
- // Force UI to settle even if background doesn't respond cleanly
- setTimeout(async () => {
- if (isTabAbortRequested(tabId)) {
- if (!sameTabId(currentTabId, tabId) || !sameTabId(renderedTabId, tabId)) return;
+ if (sameTabId(currentTabId, tabId)) showActivity(t('sp.activity.stopping'));
+
+ let fallbackTimer = null;
+ let fallbackCancelled = false;
+ let fallbackProbeFailures = 0;
+ const maxFallbackProbeFailures = 5;
+ 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) {
+ 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;
+ }
+ if (!sameTabId(currentTabId, tabId) || !sameTabId(renderedTabId, tabId)) {
+ resolve();
+ return;
+ }
finalizeSteps();
if (currentAssistantEl) {
const textEl = currentAssistantEl.querySelector('.message-text');
@@ -9524,11 +9621,32 @@ 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,
+ ]);
+ // 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);
+ }
}
-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
@@ -10013,9 +10131,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;
- 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/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 ? ` ` : `${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 454710594..485f5ec38 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(/((?:^|[^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);
+ 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
@@ -13688,6 +13729,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);
@@ -13790,13 +13839,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 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;
+ queueAskStreamingTraceWrite(
+ () => trace.recordStreaming(traceRunId, traceStep, payload),
+ );
+ };
+
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,
@@ -13806,9 +13877,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,
@@ -13816,13 +13902,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',
@@ -13901,7 +14005,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);
@@ -13909,7 +14024,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 });
@@ -14195,9 +14322,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/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/background.js b/src/firefox/src/background.js
index a95a91ee8..1ffd6c9f1 100644
--- a/src/firefox/src/background.js
+++ b/src/firefox/src/background.js
@@ -1582,6 +1582,29 @@ 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(() => {});
+ }
+ // 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;
+}
+
function acquireRunKeepalive() {
let released = false;
const touch = () => {
@@ -2048,6 +2071,7 @@ async function handleMessage(msg, sender) {
const tabId = msg.tabId || sender.tab?.id;
if (tabId) {
const conversationId = await agent.getConversationId(tabId);
+ await stopActiveRunBeforeConversationClear(tabId);
await scheduler.cancelForConversation(tabId, conversationId);
agent.clearConversation(tabId);
clearRunUiSnapshot(tabId);
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/sidepanel.js b/src/firefox/src/ui/sidepanel.js
index b35aa004b..6af4b6a92 100644
--- a/src/firefox/src/ui/sidepanel.js
+++ b/src/firefox/src/ui/sidepanel.js
@@ -804,9 +804,12 @@ 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();
const adoptedRunRecoveryRequestIds = new Set();
+const clearedConversationRunRequestIds = new Set();
let recommendationsRequestId = 0;
let providerSelectionRequestId = 0;
let providerTestRequestId = 0;
@@ -835,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;
@@ -864,7 +880,7 @@ const {
clearQueuedForTab,
} = createContextMenuPromptHandler({
getCurrentTabId: () => currentTabId,
- getIsProcessing: () => isProcessing,
+ getIsProcessing: () => isProcessing || isConversationClearInProgress(),
getAgentMode: () => agentMode,
setMode,
getInputEl: () => inputEl,
@@ -5707,6 +5723,10 @@ function syncSendButtonState() {
sendBtn.disabled = true;
return;
}
+ if (isConversationClearInProgress()) {
+ sendBtn.disabled = true;
+ return;
+ }
if (!isProcessing) {
sendBtn.disabled = isAttachmentReadPendingForTab();
return;
@@ -6059,8 +6079,15 @@ async function parseSlashCommands(text, tabId = currentTabId, options = {}) {
}
if (command.value === '/reset') {
- 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 '';
}
@@ -6287,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);
@@ -6454,7 +6482,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,
});
@@ -6528,7 +6560,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 {
@@ -6594,6 +6629,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;
@@ -6615,6 +6667,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
@@ -7762,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
@@ -7776,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);
@@ -8296,7 +8350,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 {
@@ -8941,7 +8998,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),
@@ -8965,6 +9022,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) {
@@ -9046,28 +9110,61 @@ modeDevBtn?.addEventListener('click', async () => {
// --- Stop / Abort ---
-stopBtn.addEventListener('click', async () => {
- 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'));
-
- try {
- await sendToBackground('abort', { tabId });
- } catch {
- // Best effort
- }
-
- // Force UI to settle even if background doesn't respond cleanly
- setTimeout(async () => {
- if (isTabAbortRequested(tabId)) {
- if (!sameTabId(currentTabId, tabId) || !sameTabId(renderedTabId, tabId)) return;
+ if (sameTabId(currentTabId, tabId)) showActivity(t('sp.activity.stopping'));
+
+ let fallbackTimer = null;
+ let fallbackCancelled = false;
+ let fallbackProbeFailures = 0;
+ const maxFallbackProbeFailures = 5;
+ 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) {
+ 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;
+ }
+ if (!sameTabId(currentTabId, tabId) || !sameTabId(renderedTabId, tabId)) {
+ resolve();
+ return;
+ }
finalizeSteps();
if (currentAssistantEl) {
const textEl = currentAssistantEl.querySelector('.message-text');
@@ -9082,9 +9179,32 @@ stopBtn.addEventListener('click', async () => {
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,
+ ]);
+ // 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);
+ }
+}
+
+stopBtn.addEventListener('click', () => abortRun());
// --- Voice input (mic dictation, issue #210) ---
// Web Speech API: well-supported in Chrome, absent in stock Firefox (which
@@ -9463,9 +9583,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;
- 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/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 ? `
` : `
${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 86a41b2df..98cfc8ce2 100644
--- a/test/run.js
+++ b/test/run.js
@@ -3145,6 +3145,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'],
@@ -12337,9 +12391,31 @@ 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]*?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`);
+ }
+});
+
+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 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));
+ 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`);
}
});
@@ -14099,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`);
@@ -14118,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 {
@@ -14858,9 +15010,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`);
@@ -17197,9 +17351,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`);
@@ -27500,6 +27656,44 @@ 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, {"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|json-secret|quoted-secret|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]*?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`);
}
});
@@ -47450,6 +47644,50 @@ test('detached run recovery honors a user cancellation instead of auto-resuming'
}
});
+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}-follow-stopped-run`;
+ let probes = 0;
+ const states = [
+ {
+ running: true,
+ starting: false,
+ runUi: { requestId, status: 'running', events: [] },
+ },
+ {
+ running: false,
+ starting: false,
+ runUi: {
+ requestId,
+ status: 'stopped',
+ finalContent: 'Stopped by user.',
+ events: [],
+ },
+ },
+ ];
+
+ 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, 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`);
+ }
+});
+
test('detached run recovery preserves background preflight errors', async () => {
for (const [label, runDetachedWithReconnect] of [
['chrome', runDetachedWithReconnectCh],
@@ -47568,11 +47806,17 @@ 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]*?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, /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`);
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 @@
Technical docs ↗
+
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 @@
Skip to content
-
+
Docs / Providers & models
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 @@
Skip to content
-
+
Docs / Modes, safety & privacy
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 @@
Skip to content
-
+
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