From 651bf714de4ced0cdeb28b9f1820d35037be9c2a Mon Sep 17 00:00:00 2001
From: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
Date: Mon, 10 Aug 2026 20:11:48 +0100
Subject: [PATCH] perf(chat): batch live thinking rendering and bound timer
updates (#5931)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* perf(chat): batch live thinking DOM updates
* test(chat): cover live thinking scheduler lifecycle
* fix(chat): guard background stop-state, restore live thinking text, drop source-text tests
- _closeOpenThinkingMarkup no longer overwrites currentAccumulated for
backgrounded streams. It now mirrors the guard the delta path already uses
(`if (!_isBg) currentAccumulated = accumulated`). Without it a backgrounded
stream's text is written into the foreground session's stop-state, which
abortCurrentRequest and detachCurrentStream then put in the wrong bubble.
- Split _extractLiveThinkingText into _liveThinkingText (strip every think tag)
and _closedThinkingText (via extractThinkingBlocks). Slicing from the first
to the first pinned the live box to "The" for the rest of the
stream on the `The` + untagged-thinking pattern that the
hasUnclosedThink detection deliberately keeps streaming through.
- The background transition now flushes with rich:true, so a stream that
backgrounds mid-thinking isn't left as pre-wrap plain text permanently.
- Move the throttle to static/js/liveThinkingThrottle.js and import it. The
.mjs suite imports the module instead of slicing it out of chat.js with
vm.runInNewContext and marker comments.
- Replace the source-text assertions in tests/test_live_thinking_scheduler_js.py
with behavioral coverage, per tests/TESTING_STANDARD.md. The .mjs suite grows
from 3 to 6 cases.
- Collapse the duplicated tool_start/agent_step finalizers into one
_endLiveThinkingSection().
* fix(chat): hoist thinking teardown out of the try block so catch can reach it
In an ES module a function declared inside `try { }` is scoped to that block,
and `catch` is a sibling scope rather than a nested one. _closeOpenThinkingMarkup
was declared inside the try and called from catch, so the call threw
ReferenceError and killed the rest of the error path: the stream never
finalized and the thinking block was never torn down.
Declare _closeOpenThinkingMarkup and a new _endThinkingOnTerminalPath next to
the existing _flushLiveThinking / _cancelLiveThinkingWork outer lets and assign
them inside the try, which is the pattern those two already use for exactly
this reason.
Verified against a live stream in a browser: before, clicking stop mid-thinking
logged "_closeOpenThinkingMarkup is not defined" and left no finalized thinking
section; after, the block collapses to "View thinking process" correctly.
* perf(chat): extract live thinking at commit cadence
* fix(chat): bound live thinking work
* test(chat): update stream invariant assertions
---------
Co-authored-by: Léo
---
static/js/MODULE_SUMMARY.md | 1 +
static/js/chat.js | 743 +++++++++++++------
static/js/liveThinkingThrottle.js | 206 +++++
tests/live_thinking_scheduler.test.mjs | 277 +++++++
tests/test_chat_stream_scope.py | 3 +-
tests/test_live_thinking_chat_integration.py | 145 ++++
tests/test_live_thinking_scheduler_js.py | 29 +
7 files changed, 1173 insertions(+), 231 deletions(-)
create mode 100644 static/js/liveThinkingThrottle.js
create mode 100644 tests/live_thinking_scheduler.test.mjs
create mode 100644 tests/test_live_thinking_chat_integration.py
create mode 100644 tests/test_live_thinking_scheduler_js.py
diff --git a/static/js/MODULE_SUMMARY.md b/static/js/MODULE_SUMMARY.md
index df5b0cb33..c0f88c824 100644
--- a/static/js/MODULE_SUMMARY.md
+++ b/static/js/MODULE_SUMMARY.md
@@ -61,6 +61,7 @@ The largest and most central subsystem. Chat submission → backend SSE → prog
| **`chatRenderer.js`** | Message DOM construction: `addMessage`, role labels, model route labels, color coding, footers, metrics, code blocks, sources boxes (`web`/`research`/`RAG`), findings box, images, report links, ask-user cards, welcome screen, and transcript utilities. |
| **`streamingRenderer.js`** | Incremental streaming renderer used by `chat.js`. Freezes finalized DOM blocks and only re-renders the growing tail to avoid flicker and O(N²) re-parsing. |
| **`streamingSegmenter.js`** | Splits a token stream into display units (text vs code fences) for `streamingRenderer.js`. |
+| **`liveThinkingThrottle.js`** | Trailing-edge coalescer for the live thinking block in `chat.js`: one DOM commit per 100 ms carrying the latest reasoning text, with `flush`/`cancel` for terminal and session-switch paths. |
| **`slashCommands.js`** | Slash-command registry (`/help`, `/setup`, etc.), parsing, and dispatch handlers. Exported functions are consumed by `chat.js` and `slashAutocomplete.js`. |
| **`slashAutocomplete.js`** | Composer autocomplete popup for `/` commands. |
| **`composerArrowUpRecall.js`** | Recall last user message with `↑` on an empty composer. |
diff --git a/static/js/chat.js b/static/js/chat.js
index 3c8bbe850..ccf3c83a1 100644
--- a/static/js/chat.js
+++ b/static/js/chat.js
@@ -23,6 +23,12 @@ import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handle
import createResearchSynapse from './researchSynapse.js';
import { createStreamRenderer } from './streamingRenderer.js';
import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArrowUpRecall.js?v=20260714promptrecall';
+import {
+ createIncrementalDisplayProjector,
+ createLiveThinkingThrottle,
+ createThinkingAnalysisGate,
+ stripLiveThinkingTags,
+} from './liveThinkingThrottle.js';
const RESEARCH_TIMEOUT_MS = 360000;
const DEFAULT_TIMEOUT_MS = 120000;
@@ -562,7 +568,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
// Background streaming support
const _backgroundStreams = new Map(); // sessionId -> { status, accumulated, sourcesHtml, abortCtrl, query, metrics }
- const _activeStreams = new Map(); // sessionId -> { abortCtrl, holder, query, startedAt }
+ const _activeStreams = new Map(); // sessionId -> { abortCtrl, holder, query, startedAt, cancelViewWork, finalizeView }
const _resumingStreams = new Set(); // sessionId -> a resumeStream() reader is live (re-attach lock)
let _streamSessionId = null; // Session ID for the currently active reader loop
let _lastReaderActivity = 0; // Timestamp of last reader.read() success — used to detect frozen streams
@@ -1070,19 +1076,23 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
}
// Render whatever was accumulated so far
if (currentHolder && currentAccumulated) {
- // Store accumulated in a closure variable before it gets cleared
- const stoppedContent = currentAccumulated;
-
- // Store raw content in dataset for consistency with other messages
- currentHolder.dataset.raw = stoppedContent;
-
- currentHolder.querySelector('.body').innerHTML = markdownModule.processWithThinking(
- markdownModule.squashOutsideCode(stoppedContent)
- );
+ const _activeStopStream = _getForegroundStreamState();
+ const _terminalView = _activeStopStream?.finalizeView?.() || null;
+ const _stoppedViewHolder = _terminalView?.holder || currentHolder;
+ const _viewPreparedByStream = !!_terminalView;
+ // The stream finalizer may close a synthetic reasoning tag. Capture the
+ // durable raw value only after that canonical terminal preparation.
+ const stoppedContent = _terminalView?.raw || currentAccumulated;
+ _stoppedViewHolder.dataset.raw = stoppedContent;
+ if (!_viewPreparedByStream) {
+ _stoppedViewHolder.querySelector('.body').innerHTML = markdownModule.processWithThinking(
+ markdownModule.squashOutsideCode(stoppedContent)
+ );
+ }
// Highlight code blocks
if (window.hljs) {
- currentHolder.querySelectorAll('pre code').forEach((block) => {
+ _stoppedViewHolder.querySelectorAll('pre code').forEach((block) => {
window.hljs.highlightElement(block);
});
}
@@ -1097,7 +1107,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
continueBtn.className = 'continue-btn';
continueBtn.title = 'Continue';
continueBtn.textContent = '\u25B8';
- const _stoppedHolder = currentHolder; // capture before it gets cleared
+ const _stoppedHolder = _stoppedViewHolder; // capture before globals are cleared
continueBtn.addEventListener('click', () => {
stoppedIndicator.remove();
_hideUserBubble = true;
@@ -1111,16 +1121,16 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
}
});
stoppedIndicator.appendChild(continueBtn);
- currentHolder.querySelector('.body').appendChild(stoppedIndicator);
+ _stoppedViewHolder.querySelector('.body').appendChild(stoppedIndicator);
// Tell server to mark this message as stopped
const _sid = sessionModule.getCurrentSessionId();
if (_sid) fetch(`${API_BASE}/api/session/${_sid}/mark-stopped`, { method: 'POST' }).catch(e => console.warn('mark-stopped failed:', e));
// Add footer with copy/regen if not already present
- if (!currentHolder.querySelector('.msg-footer')) {
- currentHolder.dataset.raw = stoppedContent;
- currentHolder.appendChild(createMsgFooter(currentHolder));
+ if (!_stoppedViewHolder.querySelector('.msg-footer')) {
+ _stoppedViewHolder.dataset.raw = stoppedContent;
+ _stoppedViewHolder.appendChild(createMsgFooter(_stoppedViewHolder));
}
uiModule.scrollHistory();
@@ -1368,8 +1378,19 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
let processingProbeTimer = null;
let processingProbeAbort = null;
let _renderStream = () => {};
+ let _finalizeRoundRender = () => {};
+ let _finalizeInterruptedView = () => null;
let _cancelThinkingTimer = () => {};
let _removeThinkingSpinner = () => {};
+ let _flushLiveThinking = () => '';
+ let _cancelLiveThinkingWork = () => {};
+ // Declared out here, not inside the try: in an ES module a function declared
+ // in the try block is scoped to that block, so `catch` (a sibling scope)
+ // cannot see it. Calling one from catch throws ReferenceError and kills the
+ // rest of the error path — the stream never finalizes and the partial
+ // message is lost. Assigned below, alongside the two helpers above.
+ let _closeOpenThinkingMarkup = () => {};
+ let _endThinkingOnTerminalPath = () => {};
let timeoutId = null;
let responseTimeoutCleared = false;
let clearResponseTimeout = () => {};
@@ -1763,6 +1784,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
query: streamQuery,
startedAt: Date.now(),
lastActivity: Date.now(),
+ // Resolve the mutable closure at call time: live-thinking helpers are
+ // installed after the stream entry is registered.
+ cancelViewWork: () => _cancelLiveThinkingWork(),
+ finalizeView: () => _finalizeInterruptedView(),
});
_syncForegroundStreamGlobals();
holder._researchQuery = msg; // Store query for notification text
@@ -1907,9 +1932,12 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
// Multi-bubble agent tracking
let roundHolder = holder; // Current AI text bubble (changes per round)
let roundText = ''; // Text accumulated for current round
+ let roundReplyText = null; // Reply-only text after a thinking transition
let currentToolBubble = null; // Current tool execution bubble
let lastToolThread = null; // Visible tool timeline for tool-only turns
let roundFinalized = false; // Whether current round's text is finalized
+ let roundFinalization = null; // Terminal owner/result for the current round
+ let lastContentRoundHolder = null; // Last non-empty round for an empty continuation Stop
let _sourcesHtml = ''; // Sources box HTML to prepend to body
let _sourcesExpanded = false; // Track if user expanded sources during stream
let _sourcesData = null; // Raw sources data for rebuilding
@@ -1970,7 +1998,19 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (lastToolThread && lastToolThread.isConnected) lastToolThread.classList.add('has-bottom');
roundHolder = newWrap;
roundText = '';
+ roundReplyText = null;
roundFinalized = false;
+ roundFinalization = null;
+ isThinking = false;
+ _thinkingMode = null;
+ _cancelThinkingGrace();
+ _thinkingAnalysisGate.reset();
+ _roundDisplayProjector.reset();
+ _replyDisplayProjector.reset();
+ _docFenceOpened = false;
+ _docFenceContentStart = -1;
+ _docFenceCandidateStart = -1;
+ _docFenceCandidateMarker = '';
}
const esc = uiModule.esc;
// Remove thinking spinner helper
@@ -2061,6 +2101,16 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
// Document streaming state (text-fence detection)
let _docFenceOpened = false;
let _docFenceContentStart = -1;
+ let _docFenceCandidateStart = -1;
+ let _docFenceCandidateMarker = '';
+ const _thinkingAnalysisGate = createThinkingAnalysisGate({
+ startsWithReasoningPrefix: markdownModule.startsWithReasoningPrefix,
+ });
+ const _roundDisplayProjector = createIncrementalDisplayProjector(_streamDisplayText);
+ const _replyDisplayProjector = createIncrementalDisplayProjector(_streamDisplayText);
+ let _thinkingMode = null;
+ let _thinkingRecheckAt = 0;
+ let _thinkingGraceTimer = null;
let _liveThinkSection = null;
let _liveThinkContent = null;
let _liveThinkInner = null;
@@ -2070,6 +2120,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
let _liveThinkTokenCount = 0;
let _liveThinkToggle = null;
let _liveThinkDomId = null;
+ let _liveThinkRenderThrottle = null;
+ let _liveThinkLatestText = '';
+ let _liveThinkTimerId = null;
+ let _liveThinkReducedMotion = false;
function _estimateThinkingTokens(text) {
const clean = (text || '').trim();
@@ -2083,6 +2137,259 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
return time && tokens ? time + ' · ' + tokens : (time || tokens);
}
+ function _stripThinkingWrappers(text) {
+ return text
+ .replace(/<\|channel>thought\s*\n?/gi, '')
+ .replace(/<\|channel>response\s*\n?/gi, '')
+ .replace(//gi, '')
+ .replace(/^\s*Thinking(?:\s+Process)?:\s*/i, '');
+ }
+
+ // While thinking is still open, every think tag in the round is noise, so
+ // strip them all. Do NOT slice from the first to the first :
+ // the false-close detection below deliberately keeps us in the thinking
+ // state for `The` followed by real thinking left untagged,
+ // and slicing would pin the live box to "The" for the rest of the stream.
+ function _liveThinkingText(text) {
+ const normalized = markdownModule.normalizeThinkingMarkup(_streamDisplayText(text || ''));
+ return _stripThinkingWrappers(stripLiveThinkingTags(normalized));
+ }
+
+ // Once thinking has closed, the reply that follows must not leak
+ // into the thinking box, so go through extractThinkingBlocks — it already
+ // collapses the false-close pattern and merges every block into one.
+ function _closedThinkingText(text) {
+ const normalized = markdownModule.normalizeThinkingMarkup(_streamDisplayText(text || ''));
+ const blocks = markdownModule.extractThinkingBlocks
+ ? markdownModule.extractThinkingBlocks(normalized)?.thinkingBlocks
+ : null;
+ if (blocks?.length) return _stripThinkingWrappers(blocks.join('\n\n'));
+ return _liveThinkingText(text);
+ }
+
+ function _commitLiveThinkingText(text) {
+ _liveThinkLatestText = String(text ?? '');
+ _liveThinkTokenCount = _estimateThinkingTokens(_liveThinkLatestText);
+ const target = _liveThinkInner;
+ if (!target || !target.isConnected) return;
+ const thinkBox = target.closest('.thinking-content');
+ const nearBottom = !thinkBox || thinkBox.scrollHeight - thinkBox.clientHeight - thinkBox.scrollTop < 80;
+ target.style.whiteSpace = 'pre-wrap';
+ target.textContent = _liveThinkLatestText;
+ if (thinkBox && nearBottom) thinkBox.scrollTop = thinkBox.scrollHeight;
+ if (nearBottom) uiModule.scrollHistory();
+ }
+
+ function _ensureLiveThinkingThrottle() {
+ if (!_liveThinkRenderThrottle) {
+ _liveThinkRenderThrottle = createLiveThinkingThrottle(_commitLiveThinkingText, {
+ prepare: ({ text, prepared }) => prepared ? String(text ?? '') : _liveThinkingText(text),
+ });
+ }
+ return _liveThinkRenderThrottle;
+ }
+
+ function _stopLiveThinkTimer() {
+ if (_liveThinkTimerId !== null) clearInterval(_liveThinkTimerId);
+ _liveThinkTimerId = null;
+ }
+
+ function _startLiveThinkTimer() {
+ if (_liveThinkTimerId !== null || !_liveThinkTimerEl) return;
+ _liveThinkReducedMotion = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
+ const cadence = _liveThinkReducedMotion ? 1000 : 250;
+ _liveThinkTimerId = setInterval(() => {
+ if (!_liveThinkTimerEl || !_liveThinkTimerEl.isConnected) {
+ _stopLiveThinkTimer();
+ return;
+ }
+ const elapsed = (Date.now() - thinkingStartTime) / 1000;
+ const seconds = elapsed.toFixed(_liveThinkReducedMotion ? 0 : 1);
+ _liveThinkTimerEl.textContent = _formatThinkStats(seconds, _liveThinkTokenCount);
+ }, cadence);
+ }
+
+ function _queueLiveThinking(text, prepared = false) {
+ _ensureLiveThinkingThrottle().update({ text, prepared });
+ _startLiveThinkTimer();
+ }
+
+ _flushLiveThinking = ({ text = null, rich = false } = {}) => {
+ if (text !== null) _queueLiveThinking(text, true);
+ if (_liveThinkRenderThrottle) _liveThinkRenderThrottle.flush();
+ if (rich && _liveThinkInner && _liveThinkInner.isConnected) {
+ _liveThinkInner.style.whiteSpace = '';
+ _liveThinkInner.innerHTML = markdownModule.mdToHtml(_liveThinkLatestText);
+ }
+ return _liveThinkLatestText;
+ };
+
+ _cancelLiveThinkingWork = () => {
+ if (_liveThinkRenderThrottle) _liveThinkRenderThrottle.cancel();
+ _liveThinkRenderThrottle = null;
+ _stopLiveThinkTimer();
+ _cancelThinkingGrace();
+ };
+
+ function _finalizeLiveThinking(text, rich = true) {
+ const finalText = _flushLiveThinking({ text, rich });
+ _cancelLiveThinkingWork();
+ return finalText;
+ }
+
+ // Close the synthetic we opened around vLLM reasoning deltas, so a
+ // stream that ends mid-thinking doesn't persist an unclosed tag.
+ // `currentAccumulated` is the FOREGROUND stop-state text — mirror the guard
+ // the delta path uses (`if (!_isBg) currentAccumulated = accumulated`), or a
+ // backgrounded stream overwrites the visible session's stop-state and
+ // abortCurrentRequest/detachCurrentStream write it into the wrong bubble.
+ _closeOpenThinkingMarkup = (isBackground) => {
+ if (!_thinkOpen) return;
+ accumulated += '';
+ roundText += '';
+ if (!isBackground) currentAccumulated = accumulated;
+ _thinkOpen = false;
+ };
+
+ // Terminal finalize used by the catch path, which cannot see the
+ // block-scoped helpers below.
+ _endThinkingOnTerminalPath = ({ rich = true } = {}) => {
+ if (isThinking) {
+ isThinking = false;
+ _thinkingMode = null;
+ _thinkingRecheckAt = 0;
+ _finalizeLiveThinking(_closedThinkingText(roundText), rich);
+ } else {
+ _cancelLiveThinkingWork();
+ }
+ };
+
+ // Shared teardown for the terminal paths that end thinking without the
+ // normal transition (tool_start, agent_step, [DONE], errors).
+ function _endLiveThinkingSection({ rich = true } = {}) {
+ isThinking = false;
+ _thinkingMode = null;
+ _thinkingRecheckAt = 0;
+ _finalizeLiveThinking(_closedThinkingText(roundText), rich);
+ const elapsed = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
+ if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process';
+ if (_liveThinkTimerEl) _liveThinkTimerEl.textContent = elapsed ? _formatThinkStats(elapsed, _liveThinkTokenCount) : '';
+ if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove();
+ }
+
+ function _cancelThinkingGrace() {
+ if (_thinkingGraceTimer !== null) clearTimeout(_thinkingGraceTimer);
+ _thinkingGraceTimer = null;
+ _thinkingRecheckAt = 0;
+ }
+
+ function _finishLiveThinkingTransition() {
+ if (!isThinking) return;
+ isThinking = false;
+ _thinkingMode = null;
+ _cancelThinkingGrace();
+ const closedText = _closedThinkingText(roundText);
+ const thinkTextLen = closedText.trim().length;
+ _finalizeLiveThinking(closedText, thinkTextLen >= 20);
+
+ // Models sometimes emit a trivial marker such as The.
+ if (thinkTextLen < 20 && _liveThinkSection) {
+ _liveThinkSection.remove();
+ _liveThinkSection = null;
+ _liveThinkContent = null;
+ _liveThinkInner = null;
+ _liveThinkHeader = null;
+ _liveThinkSpinnerSlot = null;
+ _liveThinkTimerEl = null;
+ _liveThinkTokenCount = 0;
+ _liveThinkToggle = null;
+ _liveThinkDomId = null;
+ if (spinner && spinner.element) spinner.destroy();
+ _renderStream({ knownNormal: true, displayText: _roundDisplayProjector.current() });
+ _scheduleThinkingSpinner();
+ return;
+ }
+
+ const elapsed = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
+ if (elapsed) {
+ accumulated = accumulated.replace(//i, '');
+ roundText = roundText.replace(//i, '');
+ }
+ if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process';
+ if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove();
+ if (_liveThinkTimerEl && elapsed) {
+ _liveThinkTimerEl.textContent = _formatThinkStats(elapsed, _liveThinkTokenCount);
+ _liveThinkTimerEl.style.marginLeft = 'auto';
+ _liveThinkTimerEl.style.marginRight = '5px';
+ const headerRow = _liveThinkTimerEl.closest('.thinking-header');
+ if (headerRow) {
+ if (_liveThinkToggle && _liveThinkToggle.parentElement === headerRow) headerRow.insertBefore(_liveThinkTimerEl, _liveThinkToggle);
+ else headerRow.appendChild(_liveThinkTimerEl);
+ }
+ }
+
+ const thinkingId = 'think-' + Date.now();
+ const liveHeader = _liveThinkSection && _liveThinkSection.querySelector('.thinking-header');
+ if (liveHeader) liveHeader.dataset.thinkingId = thinkingId;
+ if (_liveThinkContent) _liveThinkContent.id = thinkingId;
+ if (_liveThinkToggle) _liveThinkToggle.id = thinkingId + '-toggle';
+
+ const streamElement = _liveThinkSection ? _liveThinkSection.parentElement : roundHolder.querySelector('.stream-content');
+ const replyHost = streamElement || roundHolder.querySelector('.body');
+ if (replyHost && !replyHost.querySelector('.live-reply-content')) {
+ const replyElement = document.createElement('div');
+ replyElement.className = 'live-reply-content';
+ replyHost.appendChild(replyElement);
+ }
+ _renderStream();
+ }
+
+ function _scheduleThinkingGrace() {
+ if (_thinkingGraceTimer !== null || !_thinkingRecheckAt) return;
+ const delay = Math.max(0, _thinkingRecheckAt - Date.now());
+ _thinkingGraceTimer = setTimeout(() => {
+ _thinkingGraceTimer = null;
+ if (!isThinking || !roundHolder?.isConnected || abortCtrl?.signal?.aborted) return;
+ _finishLiveThinkingTransition();
+ }, delay);
+ }
+
+ // Terminal paths replace the whole round, so they should perform exactly
+ // one rich markdown render instead of richly finalizing thinking, then
+ // rendering the reply, then replacing both again.
+ _finalizeRoundRender = () => {
+ if (roundFinalized) return roundFinalization;
+ const terminalHolder = roundHolder || holder;
+ const dt = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText));
+ if (!dt.trim()) {
+ terminalHolder.style.display = 'none';
+ roundFinalized = true;
+ roundFinalization = { rendered: true, holder: terminalHolder, hasContent: false };
+ return roundFinalization;
+ }
+ const body = terminalHolder.querySelector('.body');
+ const content = _ensureStreamLayout(body);
+ content.style.minHeight = '';
+ content.innerHTML = markdownModule.processWithThinking(markdownModule.squashOutsideCode(dt));
+ if (window.hljs) terminalHolder.querySelectorAll('pre code').forEach((block) => window.hljs.highlightElement(block));
+ roundFinalized = true;
+ lastContentRoundHolder = terminalHolder;
+ roundFinalization = { rendered: true, holder: terminalHolder, hasContent: true };
+ return roundFinalization;
+ };
+ _finalizeInterruptedView = () => {
+ _closeOpenThinkingMarkup(false);
+ _endThinkingOnTerminalPath({ rich: false });
+ const finalization = _finalizeRoundRender();
+ return {
+ rendered: !!finalization?.rendered,
+ holder: finalization?.hasContent
+ ? finalization.holder
+ : (lastContentRoundHolder || finalization?.holder || roundHolder || holder),
+ raw: accumulated,
+ };
+ };
+
function _replyAfterClosedThinking(text) {
text = markdownModule.normalizeThinkingMarkup(text || '');
const closeRe = /<\/(?:think(?:ing)?|thought)>|/gi;
@@ -2094,8 +2401,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
}
// Direct render helper for streaming text
- _renderStream = () => {
- let dt = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText));
+ _renderStream = ({ knownNormal = false, displayText = null, replyText = null } = {}) => {
const bodyEl = roundHolder.querySelector('.body');
const contentEl = _ensureStreamLayout(bodyEl);
@@ -2103,14 +2409,16 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
let liveReply = contentEl.querySelector('.live-reply-content');
if (liveReply) {
// Extract reply text — handle native tags and non-tag patterns
- const closedThinkReply = _replyAfterClosedThinking(dt);
- const { thinkingBlocks, content: replyText } = closedThinkReply
- ? { thinkingBlocks: [''], content: closedThinkReply }
- : markdownModule.extractThinkingBlocks(dt);
- let replyTrimmed = '';
- if (thinkingBlocks.length) {
- replyTrimmed = (replyText || '').trim();
- } else {
+ let replyTrimmed = replyText === null ? '' : String(replyText);
+ if (replyText === null) {
+ const dt = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText));
+ const closedThinkReply = _replyAfterClosedThinking(dt);
+ const { thinkingBlocks, content: extractedReply } = closedThinkReply
+ ? { thinkingBlocks: [''], content: closedThinkReply }
+ : markdownModule.extractThinkingBlocks(dt);
+ if (thinkingBlocks.length) {
+ replyTrimmed = (extractedReply || '').trim();
+ } else {
// Non-tag: check for garbled (reasoning\nreply)
const _gm = dt.match(/^[\s\S]+?<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*([\s\S]*?)(?:<\/(?:think(?:ing)?|thought)>)?\s*$/i);
if (_gm && _gm[1].trim()) {
@@ -2119,7 +2427,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
// Pure non-tag: find reply boundary
const _rPrefixes = markdownModule.startsWithReasoningPrefix;
const _rpStarts = ['Hey', 'Hi ', 'Hi!', 'Hello', 'Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome', 'Good ', "I'm happy", "I'd be"];
- const _rt = (replyText || '').trimStart();
+ const _rt = (extractedReply || '').trimStart();
if (_rPrefixes(_rt)) {
const _rLines = _rt.split('\n');
for (let _ri = 1; _ri < _rLines.length; _ri++) {
@@ -2136,6 +2444,12 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
}
}
}
+ }
+ }
+ if (replyText === null) {
+ roundReplyText = replyTrimmed;
+ _replyDisplayProjector.reset();
+ replyTrimmed = _replyDisplayProjector.append(replyTrimmed, roundReplyText);
}
if (replyTrimmed) {
const r = liveReply._streamRenderer ||
@@ -2150,8 +2464,18 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
return;
}
+ // Thinking compatibility normalization and display stripping are
+ // intentionally omitted from the known-normal path. The incremental
+ // projector already handled the newly appended boundary, so repeating
+ // the full-round regex chains per delta would restore O(N^2) work.
+ let dt = displayText === null
+ ? (knownNormal
+ ? _roundDisplayProjector.current()
+ : markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText)))
+ : String(displayText);
+
// If thinking is still streaming (unclosed ), show indicator instead of raw text
- if (markdownModule.hasUnclosedThinkTag && markdownModule.hasUnclosedThinkTag(dt)) {
+ if (!knownNormal && markdownModule.hasUnclosedThinkTag && markdownModule.hasUnclosedThinkTag(dt)) {
const thinkStart = dt.search(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought/i);
const thinkContent = dt.substring(Math.max(thinkStart, 0))
.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought\s*\n?/i, '')
@@ -2224,6 +2548,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
// On first transition to background, store state in map
if (_isBg && !_backgroundStreams.has(streamSessionId)) {
+ // Leave the block in its finished shape (rich, no pre-wrap) rather
+ // than frozen as plain text — the user may navigate back to it.
+ _flushLiveThinking({ rich: true });
+ _cancelLiveThinkingWork();
_backgroundStreams.set(streamSessionId, {
status: 'running',
accumulated: accumulated,
@@ -2240,6 +2568,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (data === '[DONE]') {
_streamSawDone = true;
+ _closeOpenThinkingMarkup(_isBg);
// Always update background map if entry exists (even if user switched back)
var bgDone = _backgroundStreams.get(streamSessionId);
if (bgDone && !_isBg) {
@@ -2270,7 +2599,9 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
// Force-close thinking if still open (model never output boundary)
if (isThinking) {
isThinking = false;
- cancelAnimationFrame(_thinkTimerRAF);
+ // The final round render below is authoritative and will render
+ // the complete thinking + reply markup once.
+ _finalizeLiveThinking(_closedThinkingText(roundText), false);
var _elapsedDone = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
if (_elapsedDone) {
accumulated = accumulated.replace(//i, '');
@@ -2298,14 +2629,6 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (_liveHdrDone) _liveHdrDone.dataset.thinkingId = _thinkIdDone;
if (_liveThinkContent) _liveThinkContent.id = _thinkIdDone;
if (_liveThinkToggle) _liveThinkToggle.id = _thinkIdDone + '-toggle';
- // Create live-reply container so final render preserves thinking bar
- var _streamElDone = _liveThinkSection ? _liveThinkSection.parentElement : roundHolder.querySelector('.stream-content');
- if (!_streamElDone) _streamElDone = roundHolder.querySelector('.body');
- if (_streamElDone && !_streamElDone.querySelector('.live-reply-content')) {
- var _replyElDone = document.createElement('div');
- _replyElDone.className = 'live-reply-content';
- _streamElDone.appendChild(_replyElDone);
- }
}
// Normal foreground completion — metrics will be displayed in the final render block below
break;
@@ -2372,22 +2695,37 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
}
_ensureVisibleRoundForDelta();
roundText += _delta;
+ _roundDisplayProjector.append(_delta, roundText);
// --- Text-fence doc streaming (for models that don't use native tool calls) ---
- if (!_docFenceOpened && documentModule && (roundText.includes('```create_document\n') || roundText.includes('```document\n') || roundText.includes('```documen\n'))) {
- const fenceMarker = roundText.includes('```document\n') ? '```document\n' : (roundText.includes('```documen\n') ? '```documen\n' : '```create_document\n');
- const fenceIdx = roundText.indexOf(fenceMarker);
- const afterFence = roundText.slice(fenceIdx + fenceMarker.length);
- const fenceLines = afterFence.split('\n');
- if (fenceLines.length >= 1 && fenceLines[0].trim()) {
- _docFenceOpened = true;
- const title = fenceLines[0].trim();
- // Keep in sync with backend _KNOWN_LANGS in src/tool_implementations.py
- const knownLangs = ['python','py','javascript','js','typescript','ts','html','css','json','yaml','bash','sql','rust','go','java','c','cpp','markdown','text','plain','ruby','swift','kotlin','php','email','csv','xml','toml','ini'];
- const isLang = fenceLines.length >= 2 && knownLangs.includes(fenceLines[1].trim().toLowerCase());
- const lang = isLang ? fenceLines[1].trim() : '';
- _docFenceContentStart = fenceIdx + fenceMarker.length + title.length + 1 + (isLang ? fenceLines[1].length + 1 : 0);
- documentModule.streamDocOpen(title, lang);
+ if (!_docFenceOpened && documentModule) {
+ // Only inspect the newly appended boundary. Re-scanning the
+ // full round for every reasoning delta is quadratic even
+ // before thinking normalization runs.
+ const fenceMarkers = ['```document\n', '```documen\n', '```create_document\n'];
+ const fenceScanStart = Math.max(0, roundText.length - _delta.length - 24);
+ if (_docFenceCandidateStart < 0) {
+ for (const candidate of fenceMarkers) {
+ const candidateIdx = roundText.indexOf(candidate, fenceScanStart);
+ if (candidateIdx >= 0 && (_docFenceCandidateStart < 0 || candidateIdx < _docFenceCandidateStart)) {
+ _docFenceCandidateMarker = candidate;
+ _docFenceCandidateStart = candidateIdx;
+ }
+ }
+ }
+ if (_docFenceCandidateStart >= 0) {
+ const afterFence = roundText.slice(_docFenceCandidateStart + _docFenceCandidateMarker.length);
+ const fenceLines = afterFence.split('\n');
+ if (fenceLines.length >= 1 && fenceLines[0].trim()) {
+ _docFenceOpened = true;
+ const title = fenceLines[0].trim();
+ // Keep in sync with backend _KNOWN_LANGS in src/tool_implementations.py
+ const knownLangs = ['python','py','javascript','js','typescript','ts','html','css','json','yaml','bash','sql','rust','go','java','c','cpp','markdown','text','plain','ruby','swift','kotlin','php','email','csv','xml','toml','ini'];
+ const isLang = fenceLines.length >= 2 && knownLangs.includes(fenceLines[1].trim().toLowerCase());
+ const lang = isLang ? fenceLines[1].trim() : '';
+ _docFenceContentStart = _docFenceCandidateStart + _docFenceCandidateMarker.length + title.length + 1 + (isLang ? fenceLines[1].length + 1 : 0);
+ documentModule.streamDocOpen(title, lang);
+ }
}
}
if (_docFenceOpened && _docFenceContentStart > 0 && documentModule) {
@@ -2401,6 +2739,30 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
// 1. Normal: ...no closing tag yet
// 2. Malformed: \n...text but no second yet
// 3. Qwen3.5: "Thinking Process:" without tags
+ // Most deltas cannot change thinking state. Analyze cumulative
+ // text only for a fresh tag/channel/reply boundary, an initial
+ // reasoning prefix, or an expired false-close grace period.
+ if (!_thinkingAnalysisGate.shouldAnalyze(roundText, {
+ isThinking,
+ nonTagThinking: _thinkingMode === 'prefix',
+ recheckAt: _thinkingRecheckAt,
+ })) {
+ if (isThinking) {
+ _queueLiveThinking(roundText);
+ } else {
+ if (spinner && spinner.element) spinner.destroy();
+ if (roundReplyText !== null) {
+ roundReplyText += _delta;
+ const replyDisplayText = _replyDisplayProjector.append(_delta, roundReplyText);
+ _renderStream({ replyText: replyDisplayText });
+ } else {
+ _renderStream({ knownNormal: true, displayText: _roundDisplayProjector.current() });
+ }
+ _scheduleThinkingSpinner();
+ if (streamingTTS) window.aiTTSManager.streamingUpdate(roundText);
+ }
+ continue;
+ }
const normalizedRoundText = markdownModule.normalizeThinkingMarkup(roundText);
let hasUnclosedThink = markdownModule.hasUnclosedThinkTag(normalizedRoundText);
// Detect non-tag thinking patterns: "Thinking:", "Thinking Process:", Gemma-style reasoning
@@ -2432,34 +2794,39 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
}
}
}
- if (!hasUnclosedThink && /^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i.test(normalizedRoundText)) {
- // Empty — the model likely put thinking outside the tags
- const afterEmpty = normalizedRoundText.replace(/^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i, '').trim();
- const closeTags = (afterEmpty.match(/<\/(?:think(?:ing)?|thought)>/gi) || []).length;
- if (closeTags === 0 && afterEmpty.length > 0) {
- hasUnclosedThink = true; // still waiting for real closing tag
- }
- }
// Detect false close: short where real thinking follows untagged
- // Only applies when there's a second later (model leaked thinking outside tags)
- // Do NOT trigger if the text after contains tool calls (that's real content)
- if (!hasUnclosedThink && isThinking) {
+ // Do NOT require a prior unclosed delta: providers can emit the
+ // short open+close and leaked reasoning in one chunk.
+ let _falseCloseDeadline = 0;
+ if (!hasUnclosedThink) {
const _thinkMatch = normalizedRoundText.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i);
const _thinkLen = _thinkMatch ? _thinkMatch[1].trim().length : 0;
- if (_thinkLen < 20) {
+ if (_thinkMatch && _thinkLen < 20) {
const _afterClose = normalizedRoundText.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i, '').trim();
// Only keep waiting if there's trailing text that looks like thinking (not tool calls)
const _hasToolCall = /```(?:bash|python|web_search|read_file|write_file|create_document|edit_document|manage_|generate_image)/i.test(_afterClose);
const _hasOrphanClose = /<\/(?:think(?:ing)?|thought)>/i.test(_afterClose);
- if (!_hasToolCall && (_hasOrphanClose || (Date.now() - thinkingStartTime) < 500)) {
- hasUnclosedThink = true; // keep waiting for real
+ const _falseCloseStart = thinkingStartTime || Date.now();
+ if (_afterClose && !_hasToolCall && !_hasOrphanClose && (Date.now() - _falseCloseStart) < 500) {
+ hasUnclosedThink = true;
+ _falseCloseDeadline = _falseCloseStart + 500;
+ if (isThinking) {
+ _thinkingRecheckAt = _falseCloseDeadline;
+ _scheduleThinkingGrace();
+ }
+ } else if (isThinking) {
+ _cancelThinkingGrace();
}
}
}
if (hasUnclosedThink && !isThinking) {
isThinking = true;
+ _thinkingMode = /<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought/i.test(normalizedRoundText)
+ ? 'tag'
+ : 'prefix';
thinkingStartTime = Date.now();
+ _thinkingRecheckAt = _falseCloseDeadline || 0;
if (spinner && spinner.element) spinner.destroy();
// Create a live thinking box — starts expanded so content streams visibly
@@ -2486,16 +2853,9 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
_liveThinkSpinnerSlot = thinkContent.querySelector('.live-think-spinner-slot');
_liveThinkTimerEl = thinkContent.querySelector('.live-think-timer');
_liveThinkToggle = thinkContent.querySelector('.live-think-toggle');
- // Live timer
- var _thinkTimerStart = Date.now();
- var _thinkTimerRAF = 0;
- function _tickThinkTimer() {
- if (!_liveThinkTimerEl || !_liveThinkTimerEl.isConnected) return;
- var s = ((Date.now() - _thinkTimerStart) / 1000).toFixed(1);
- _liveThinkTimerEl.textContent = _formatThinkStats(s, _liveThinkTokenCount);
- _thinkTimerRAF = requestAnimationFrame(_tickThinkTimer);
- }
- _thinkTimerRAF = requestAnimationFrame(_tickThinkTimer);
+ _liveThinkLatestText = '';
+ _cancelLiveThinkingWork();
+ _queueLiveThinking(roundText);
// Whirlpool spinner
if (_liveThinkSpinnerSlot) {
var _wp = spinnerModule.createWhirlpool(12);
@@ -2505,104 +2865,22 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
_wp.element.style.transform = 'translateY(-1px)'; // align the whirlpool with the header text
_liveThinkSpinnerSlot.appendChild(_wp.element);
}
+ if (_thinkingRecheckAt) _scheduleThinkingGrace();
} else if (hasUnclosedThink && isThinking) {
- if (_liveThinkInner) {
- // Extract raw thinking text (strip known thinking wrappers and prefixes)
- var thinkText = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText))
- .replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '')
- .replace(/<\|channel>thought\s*\n?/gi, '')
- .replace(/<\|channel>response\s*\n?/gi, '')
- .replace(//gi, '');
- thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, '');
- _liveThinkTokenCount = _estimateThinkingTokens(thinkText);
- _liveThinkInner.innerHTML = markdownModule.mdToHtml(thinkText);
- if (_liveThinkTimerEl) {
- var _elapsedLive = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : '';
- _liveThinkTimerEl.textContent = _formatThinkStats(_elapsedLive, _liveThinkTokenCount);
- }
- // Keep thinking box scrolled to bottom, but let user scroll up
- var _followThinking = true;
- var thinkBox = _liveThinkInner.closest('.thinking-content');
- if (thinkBox) {
- var nearBottom = thinkBox.scrollHeight - thinkBox.clientHeight - thinkBox.scrollTop < 80;
- if (nearBottom) thinkBox.scrollTop = thinkBox.scrollHeight;
- _followThinking = nearBottom;
- }
- }
- if (_followThinking) uiModule.scrollHistory();
+ _queueLiveThinking(roundText);
continue;
} else if (!hasUnclosedThink && isThinking) {
- isThinking = false;
- var _thinkTextLen = _liveThinkInner ? _liveThinkInner.textContent.trim().length : 0;
-
- // If thinking was trivially short (< 20 chars), remove the section entirely
- // Models sometimes emit The or similar noise
- if (_thinkTextLen < 20 && _liveThinkSection) {
- _liveThinkSection.remove();
- _liveThinkSection = null;
- _liveThinkContent = null;
- _liveThinkInner = null;
- _liveThinkHeader = null;
- _liveThinkSpinnerSlot = null;
- _liveThinkTimerEl = null;
- _liveThinkTokenCount = 0;
- _liveThinkToggle = null;
- _liveThinkDomId = null;
- // Fall through to normal streaming
- if (spinner && spinner.element) spinner.destroy();
- _renderStream();
- _scheduleThinkingSpinner();
- continue;
- }
-
- // Thinking ended — smooth transition: update header, pause, then collapse
- // Stop live timer and spinner
- cancelAnimationFrame(_thinkTimerRAF);
- var elapsed = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
- // Embed thinking time in the tag for persistence on reload
- if (elapsed) {
- accumulated = accumulated.replace(//i, '');
- roundText = roundText.replace(//i, '');
- }
- if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process';
- if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove();
- // Move timer to right side of header
- if (_liveThinkTimerEl && elapsed) {
- _liveThinkTimerEl.textContent = _formatThinkStats(elapsed, _liveThinkTokenCount);
- _liveThinkTimerEl.style.marginLeft = 'auto';
- _liveThinkTimerEl.style.marginRight = '5px';
- var _hdrRow = _liveThinkTimerEl.closest('.thinking-header');
- // Chevron furthest right, timer to its left — insert before
- // the toggle (appending would put the timer after it).
- if (_hdrRow) {
- if (_liveThinkToggle && _liveThinkToggle.parentElement === _hdrRow)
- _hdrRow.insertBefore(_liveThinkTimerEl, _liveThinkToggle);
- else _hdrRow.appendChild(_liveThinkTimerEl);
- }
- }
-
- // Assign stable IDs (for click-toggle handler in markdown.js)
- var _thinkId = 'think-' + Date.now();
- var _liveHdr = _liveThinkSection && _liveThinkSection.querySelector('.thinking-header');
- if (_liveHdr) _liveHdr.dataset.thinkingId = _thinkId;
- if (_liveThinkContent) _liveThinkContent.id = _thinkId;
- if (_liveThinkToggle) _liveThinkToggle.id = _thinkId + '-toggle';
-
- // Append a container for the reply text that follows thinking
- var _streamEl = _liveThinkSection ? _liveThinkSection.parentElement : roundHolder.querySelector('.stream-content');
- if (!_streamEl) _streamEl = roundHolder.querySelector('.body');
- if (_streamEl) {
- var _replyEl = document.createElement('div');
- _replyEl.className = 'live-reply-content';
- _streamEl.appendChild(_replyEl);
- }
-
- // Render any reply text that arrived with the closing token
- _renderStream();
+ _finishLiveThinkingTransition();
} else {
// Normal streaming
if (spinner && spinner.element) spinner.destroy();
- _renderStream();
+ if (roundReplyText !== null) {
+ roundReplyText += _delta;
+ const replyDisplayText = _replyDisplayProjector.append(_delta, roundReplyText);
+ _renderStream({ replyText: replyDisplayText });
+ } else {
+ _renderStream({ knownNormal: true, displayText: _roundDisplayProjector.current() });
+ }
_scheduleThinkingSpinner();
// Feed streaming TTS with accumulated text
if (streamingTTS) window.aiTTSManager.streamingUpdate(roundText);
@@ -2973,40 +3251,17 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (holder && json.id) holder.dataset.dbId = json.id;
} else if (json.type === 'tool_start') {
+ _closeOpenThinkingMarkup(_isBg);
if (_isBg) continue;
_cancelThinkingTimer();
_removeThinkingSpinner();
// Force-close thinking if still open — tools are real content, not thinking
if (isThinking) {
- isThinking = false;
- cancelAnimationFrame(_thinkTimerRAF);
- var _elapsed2 = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
- if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process';
- if (_liveThinkTimerEl) _liveThinkTimerEl.textContent = _elapsed2 ? _formatThinkStats(_elapsed2, _liveThinkTokenCount) : '';
- if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove();
- // Assign stable IDs
- var _thinkId2 = 'think-' + Date.now();
- var _liveHdr2 = _liveThinkSection && _liveThinkSection.querySelector('.thinking-header');
- if (_liveHdr2) _liveHdr2.dataset.thinkingId = _thinkId2;
- if (_liveThinkContent) _liveThinkContent.id = _thinkId2;
- if (_liveThinkToggle) _liveThinkToggle.id = _thinkId2 + '-toggle';
+ _endLiveThinkingSection({ rich: false });
}
- _renderStream();
// --- Finalize current text bubble (only once per round) ---
- if (!roundFinalized) {
- roundFinalized = true;
- if (spinner && spinner.element) spinner.destroy();
- const dt = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText));
- if (dt.trim()) {
- var _body3 = roundHolder.querySelector('.body');
- var _contentEl3 = _ensureStreamLayout(_body3);
- _contentEl3.style.minHeight = ''; // clear streaming inflate
- _contentEl3.innerHTML = markdownModule.processWithThinking(markdownModule.squashOutsideCode(dt));
- if (window.hljs) roundHolder.querySelectorAll('pre code').forEach((b) => window.hljs.highlightElement(b));
- } else {
- roundHolder.style.display = 'none';
- }
- }
+ if (spinner && spinner.element) spinner.destroy();
+ _finalizeRoundRender();
// Track tool name for contextual spinner labels
_lastToolName = json.tool || '';
@@ -3324,10 +3579,16 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (_pu) _setStoredPlan(_pu);
} else if (json.type === 'agent_step') {
+ _closeOpenThinkingMarkup(_isBg);
if (_isBg) continue;
_cancelThinkingTimer();
_removeThinkingSpinner();
- _renderStream();
+ if (isThinking) {
+ _endLiveThinkingSection({ rich: false });
+ } else {
+ _cancelLiveThinkingWork();
+ }
+ _finalizeRoundRender();
// Mark thread as connected to bubble below
const _activeThread = document.querySelector('.agent-thread.streaming');
if (_activeThread) {
@@ -3336,9 +3597,18 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
// --- New round: create fresh AI bubble with spinner ---
currentToolBubble = null;
roundFinalized = false;
+ roundFinalization = null;
isThinking = false;
+ roundReplyText = null;
+ _thinkingMode = null;
+ _thinkingRecheckAt = 0;
+ _thinkingAnalysisGate.reset();
+ _roundDisplayProjector.reset();
+ _replyDisplayProjector.reset();
_docFenceOpened = false;
_docFenceContentStart = -1;
+ _docFenceCandidateStart = -1;
+ _docFenceCandidateMarker = '';
const box = document.getElementById('chat-history');
const newWrap = document.createElement('div');
newWrap.className = 'msg msg-ai msg-continuation streaming';
@@ -3412,6 +3682,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
roundHolder = null;
roundText = '';
roundFinalized = false;
+ roundFinalization = null;
currentToolBubble = null;
uiModule.scrollHistory();
@@ -3458,7 +3729,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
throw new Error('Stream closed before completion');
}
- _renderStream();
+ // The final foreground render below is authoritative. Cancel any delayed
+ // live-view work instead of parsing and rendering the full round once
+ // here and then immediately replacing it.
+ _cancelLiveThinkingWork();
if (spinner && spinner.element) { try { spinner.destroy(); } catch (_) {} spinner = null; }
_cancelThinkingTimer();
_removeThinkingSpinner();
@@ -3739,14 +4013,27 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
} // end if (!_isBgFinal)
} catch (err) {
- _renderStream();
+ // Check if this stream was running in background — needed before any
+ // stop-state write, so an errored background stream can't clobber the
+ // foreground session's text.
+ const _isBgCatch = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
+ let _catchTerminalView = null;
+ _closeOpenThinkingMarkup(_isBgCatch);
+ if (_isBgCatch) {
+ _cancelLiveThinkingWork();
+ } else if (accumulated) {
+ _catchTerminalView = _finalizeInterruptedView();
+ } else {
+ // Empty terminal views are owned by _renderCancelledBubble; do not run
+ // the rich round renderer first because it hides an empty holder.
+ _endThinkingOnTerminalPath({ rich: false });
+ }
+ const _catchViewHolder = _catchTerminalView?.holder || holder;
// Clean up any active spinner (e.g. "Generating response" during tool calls)
if (spinner && spinner.element) spinner.destroy();
_cancelThinkingTimer();
_removeThinkingSpinner();
document.querySelectorAll('.agent-thread.streaming').forEach(t => t.classList.remove('streaming'));
- // Check if this stream was running in background
- const _isBgCatch = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
if (_isBgCatch) {
// Error happened while backgrounded — update map, don't touch DOM
@@ -3779,12 +4066,12 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (holder && !accumulated) {
holder.querySelector('.body').innerHTML =
`[${timeoutMsg}]
`;
- } else if (holder && accumulated) {
+ } else if (_catchViewHolder && accumulated) {
const timeoutNote = document.createElement('div');
timeoutNote.className = 'stopped-indicator';
timeoutNote.innerHTML =
`[${timeoutMsg}]`;
- holder.querySelector('.body').appendChild(timeoutNote);
+ _catchViewHolder.querySelector('.body').appendChild(timeoutNote);
}
if (currentAbort === abortCtrl) currentAbort = null;
return;
@@ -3795,12 +4082,12 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (holder && !accumulated) {
holder.querySelector('.body').innerHTML =
`[${offlineMsg}]
`;
- } else if (holder && accumulated) {
+ } else if (_catchViewHolder && accumulated) {
const offlineNote = document.createElement('div');
offlineNote.className = 'stopped-indicator';
offlineNote.innerHTML =
`[${offlineMsg}]`;
- holder.querySelector('.body').appendChild(offlineNote);
+ _catchViewHolder.querySelector('.body').appendChild(offlineNote);
}
if (currentAbort === abortCtrl) currentAbort = null;
return;
@@ -3811,12 +4098,12 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (holder && !accumulated) {
holder.querySelector('.body').innerHTML =
`[${recoveryMsg}]
`;
- } else if (holder && accumulated) {
+ } else if (_catchViewHolder && accumulated) {
const recoveryNote = document.createElement('div');
recoveryNote.className = 'stopped-indicator';
recoveryNote.innerHTML =
`[${recoveryMsg}]`;
- holder.querySelector('.body').appendChild(recoveryNote);
+ _catchViewHolder.querySelector('.body').appendChild(recoveryNote);
}
if (currentAbort === abortCtrl) currentAbort = null;
return;
@@ -3827,11 +4114,11 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
if (holder && !accumulated) {
holder.querySelector('.body').innerHTML =
`[${staleMsg}]
`;
- } else if (holder && accumulated) {
+ } else if (_catchViewHolder && accumulated) {
const staleNote = document.createElement('div');
staleNote.className = 'stopped-indicator';
staleNote.innerHTML = `[${staleMsg}]`;
- holder.querySelector('.body').appendChild(staleNote);
+ _catchViewHolder.querySelector('.body').appendChild(staleNote);
}
if (currentAbort === abortCtrl) currentAbort = null;
return;
@@ -3844,19 +4131,11 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
_renderCancelledBubble(holder);
}
- // But just in case the stop button didn't render it, render it here
- if (holder && accumulated && !currentHolder) {
- holder.dataset.raw = accumulated;
- holder.querySelector('.body').innerHTML = markdownModule.processWithThinking(
- markdownModule.squashOutsideCode(accumulated)
- );
-
- if (window.hljs) {
- holder.querySelectorAll('pre code').forEach((block) => {
- window.hljs.highlightElement(block);
- });
- }
-
+ // Navigation and non-button aborts do not pass through the synchronous
+ // Stop renderer. The catch render above owns markdown; add only the
+ // interruption controls here so each terminal path renders once.
+ if (_catchViewHolder && accumulated && currentHolder) {
+ _catchViewHolder.dataset.raw = accumulated;
const stoppedIndicator = document.createElement('div');
stoppedIndicator.className = 'stopped-indicator';
const stoppedLabel = document.createElement('span');
@@ -3869,7 +4148,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
continueBtn.addEventListener('click', () => {
stoppedIndicator.remove();
_hideUserBubble = true;
- _pendingContinue = holder;
+ _pendingContinue = _catchViewHolder;
const cutoff = accumulated;
const msgInput = uiModule.el('message');
if (msgInput) {
@@ -3879,14 +4158,14 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
}
});
stoppedIndicator.appendChild(continueBtn);
- holder.querySelector('.body').appendChild(stoppedIndicator);
+ _catchViewHolder.querySelector('.body').appendChild(stoppedIndicator);
// Tell server to mark this message as stopped
const _sid2 = sessionModule.getCurrentSessionId();
if (_sid2) fetch(`${API_BASE}/api/session/${_sid2}/mark-stopped`, { method: 'POST' }).catch(e => console.warn('mark-stopped failed:', e));
- if (!holder.querySelector('.msg-footer')) {
- holder.appendChild(createMsgFooter(holder));
+ if (!_catchViewHolder.querySelector('.msg-footer')) {
+ _catchViewHolder.appendChild(createMsgFooter(_catchViewHolder));
}
uiModule.scrollHistory();
@@ -3912,8 +4191,8 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
// cap. Only auto-recover from connection-class failures; deterministic
// errors (unsupported tools, 4xx/5xx, parse failures) surface right away
// instead of burning the nudge budget on a guaranteed-to-fail retry.
- if (!(_isRecoverableStreamErr(err) && _tryAutoRecover(holder, accumulated, streamSessionId))) {
- const errorHolder = document.querySelector('.msg-ai:last-of-type .body');
+ if (!(_isRecoverableStreamErr(err) && _tryAutoRecover(_catchViewHolder, accumulated, streamSessionId))) {
+ const errorHolder = _catchViewHolder?.querySelector('.body') || document.querySelector('.msg-ai:last-of-type .body');
if (errorHolder) {
let errMsg = `Error: ${err.message}`;
// Add hint for tool-call errors
@@ -3926,6 +4205,7 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
}
}
} finally {
+ _cancelLiveThinkingWork();
clearResponseTimeout();
clearProcessingProbe();
clearFirstTokenWaitTimers();
@@ -4080,10 +4360,6 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
_autoNudges++;
if (holder && accumulated) {
holder.dataset.raw = accumulated;
- try {
- holder.querySelector('.body').innerHTML =
- markdownModule.processWithThinking(markdownModule.squashOutsideCode(accumulated));
- } catch (_) {}
}
_pendingContinue = holder || null; // merge the continuation into the same bubble
_hideUserBubble = true; // no user bubble for the handshake
@@ -4206,7 +4482,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
* Called from both abort paths when no tokens had streamed yet. */
function _renderCancelledBubble(holder) {
if (!holder) return;
+ if (holder.dataset.cancelledRendered === '1') return;
+ holder.dataset.cancelledRendered = '1';
holder.dataset.raw = '';
+ holder.style.display = '';
const body = holder.querySelector('.body');
if (body) {
body.innerHTML = '';
@@ -4262,6 +4541,10 @@ import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArr
abortCurrentRequest();
return;
}
+ // Detachment deliberately keeps the network stream alive, but the outgoing
+ // view must stop all delayed rendering immediately. The reader loop may not
+ // receive another SSE line for an arbitrary amount of time.
+ if (active.cancelViewWork) active.cancelViewWork();
// Store background stream state
_backgroundStreams.set(sessionId, {
status: 'running',
diff --git a/static/js/liveThinkingThrottle.js b/static/js/liveThinkingThrottle.js
new file mode 100644
index 000000000..ca73abc10
--- /dev/null
+++ b/static/js/liveThinkingThrottle.js
@@ -0,0 +1,206 @@
+// liveThinkingThrottle.js
+//
+// Pure trailing-edge coalescer for the live "thinking" block in chat.js.
+//
+// A reasoning stream delivers deltas far faster than a human can read them, and
+// the only thing that matters on screen is the LATEST cumulative text. Committing
+// every delta to the DOM makes the work grow with the length of the stream. This
+// throttle collapses a burst of updates into one commit per `delay` ms, always
+// carrying the most recent value.
+//
+// Timers are injected so the behaviour is testable without a browser or a clock:
+//
+// const throttle = createLiveThinkingThrottle(commit, { prepare, schedule, cancel });
+//
+// Lifecycle contract, which the terminal paths in chat.js depend on:
+//
+// update(value) queue `value`; schedule a commit if one is not already pending
+// flush() commit any pending value NOW and drop the timer; returns whether
+// a commit happened, so a clean flush cannot duplicate a commit
+// cancel() drop the timer AND the pending value — nothing lands later
+//
+// `cancel()` is what stops a finished (or backgrounded) stream from mutating a
+// view the user has since navigated away to.
+
+export function stripLiveThinkingTags(text) {
+ return String(text ?? '').replace(
+ /<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi,
+ '',
+ );
+}
+
+const THINKING_BOUNDARY_RE = /<\/?(?:(?:mm:)?think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>(?:thought|response)|/gi;
+const REPLY_PREFIX_SOURCE = "(?:Hey|Hi |Hi!|Hello|Sure|Yes|No |No,|Yo|OK|Here|Absolutely|Of course|Great|Alright|Thanks|Welcome|Good |I'm happy|I'd be)";
+const REPLY_LINE_RE = new RegExp('(?:^|\\n)\\s*' + REPLY_PREFIX_SOURCE, 'gi');
+const REPLY_INLINE_RE = new RegExp('[.!?]\\s*' + REPLY_PREFIX_SOURCE, 'gi');
+const REASONING_PREFIX_CANDIDATES = [
+ 'thinking:', 'thinking process:', 'the user ', 'user wants', 'we need ',
+ 'i need ', 'i should ', 'i will ', "i'll ", 'i am going ', 'let me think',
+ 'let me look', 'let me see', 'let me check', 'let me read', 'let me review',
+ 'let me analyze', 'let me parse', 'let me figure', 'let me draft', 'let me write',
+ 'they are ', 'the question ', 'i can ',
+];
+
+const DISPLAY_FILTER_BOUNDARY_RE = /\[\/?TOOL_CALL\]|```(?:create_document|documen(?:t)?)(?:\s|$)|```[\w-]+[ \t]*[\[{]|<(?:[\w]+:)?(?:tool_call|function_call)>||(?:^|[\r\n])\s*(?:stdout|stderr|exit_code):/i;
+
+function hasFreshMatch(text, regex, cursor, minStart = 0) {
+ regex.lastIndex = 0;
+ for (const match of text.matchAll(regex)) {
+ const end = match.index + match[0].length;
+ if (end > cursor && match.index >= minStart) return true;
+ }
+ return false;
+}
+
+// Incrementally decides when chat.js needs its compatibility-heavy cumulative
+// thinking analysis. The gate inspects only a short overlap plus the new text;
+// ordinary answer/reasoning deltas therefore stay O(delta) while split tags,
+// namespaced tags, non-tag reply boundaries, and false-close grace deadlines
+// still request the canonical full analysis.
+export function createThinkingAnalysisGate({
+ startsWithReasoningPrefix = () => false,
+ now = () => Date.now(),
+ overlap = 512,
+} = {}) {
+ let cursor = 0;
+ let prefixSettled = false;
+ let prefixProbe = '';
+
+ return {
+ shouldAnalyze(text, {
+ isThinking = false,
+ nonTagThinking = false,
+ recheckAt = 0,
+ } = {}) {
+ const fullText = String(text ?? '');
+ if (fullText.length < cursor) {
+ cursor = 0;
+ prefixSettled = false;
+ prefixProbe = '';
+ }
+ const previousCursor = cursor;
+ if (!prefixSettled && prefixProbe.length < overlap) {
+ // Build the initial probe from deltas so arbitrary leading whitespace
+ // cannot strand the gate in its undecided state. The retained state is
+ // bounded even if a provider emits a very large whitespace prefix.
+ prefixProbe = (prefixProbe + fullText.slice(previousCursor))
+ .trimStart()
+ .slice(0, overlap);
+ }
+ const scanStart = Math.max(0, previousCursor - overlap);
+ const freshText = fullText.slice(scanStart);
+ const relativeCursor = previousCursor - scanStart;
+ const hasBoundary = hasFreshMatch(freshText, THINKING_BOUNDARY_RE, relativeCursor);
+ const hasReplyBoundary = nonTagThinking && (
+ hasFreshMatch(freshText, REPLY_LINE_RE, relativeCursor)
+ || hasFreshMatch(freshText, REPLY_INLINE_RE, relativeCursor, Math.max(0, 20 - scanStart))
+ );
+ cursor = fullText.length;
+
+ if (hasBoundary || hasReplyBoundary) return true;
+ if (isThinking) return recheckAt > 0 && now() >= recheckAt;
+ if (prefixSettled) return false;
+
+ if (!prefixProbe) return false;
+ if (startsWithReasoningPrefix(prefixProbe)) {
+ prefixSettled = true;
+ return true;
+ }
+ const lowerProbe = prefixProbe.toLowerCase();
+ if (REASONING_PREFIX_CANDIDATES.some((candidate) => candidate.startsWith(lowerProbe))) {
+ return false;
+ }
+ prefixSettled = true;
+ return false;
+ },
+ reset() {
+ cursor = 0;
+ prefixSettled = false;
+ prefixProbe = '';
+ },
+ };
+}
+
+// Keep the common prose path append-only. At the first structured/tool
+// boundary, filter only the preceding visible prefix and hide the structured
+// tail until the authoritative terminal render.
+export function createIncrementalDisplayProjector(filter, { overlap = 512 } = {}) {
+ let projected = '';
+ let boundaryTail = '';
+ let rawLength = 0;
+ let structuredTailHidden = false;
+
+ return {
+ append(delta, fullText) {
+ const chunk = String(delta ?? '');
+ const raw = String(fullText ?? '');
+ if (raw.length < rawLength) this.reset();
+ const boundaryProbe = boundaryTail + chunk;
+ const boundaryMatch = !structuredTailHidden
+ ? DISPLAY_FILTER_BOUNDARY_RE.exec(boundaryProbe)
+ : null;
+ if (boundaryMatch) {
+ // Filter the visible prefix, not the incomplete marker itself: several
+ // compatibility regexes intentionally match only completed blocks.
+ const boundaryStart = Math.max(0, raw.length - boundaryProbe.length + boundaryMatch.index);
+ structuredTailHidden = true;
+ projected = String(filter(raw.slice(0, boundaryStart)) ?? '');
+ } else if (!structuredTailHidden) {
+ projected += chunk;
+ }
+ boundaryTail = (boundaryTail + chunk).slice(-overlap);
+ rawLength = raw.length;
+ return projected;
+ },
+ current() {
+ return projected;
+ },
+ reset() {
+ projected = '';
+ boundaryTail = '';
+ rawLength = 0;
+ structuredTailHidden = false;
+ },
+ };
+}
+
+export function createLiveThinkingThrottle(commit, {
+ delay = 100,
+ prepare = (value) => String(value ?? ''),
+ schedule = (callback, ms) => setTimeout(callback, ms),
+ cancel = (timer) => clearTimeout(timer),
+} = {}) {
+ let timer = null;
+ let latest = null;
+ let dirty = false;
+
+ const commitLatest = () => {
+ timer = null;
+ if (!dirty) return false;
+ dirty = false;
+ commit(prepare(latest));
+ return true;
+ };
+
+ return {
+ update(value) {
+ latest = value;
+ dirty = true;
+ if (timer === null) timer = schedule(commitLatest, delay);
+ },
+ flush() {
+ if (timer !== null) {
+ cancel(timer);
+ timer = null;
+ }
+ return commitLatest();
+ },
+ cancel() {
+ if (timer !== null) cancel(timer);
+ timer = null;
+ dirty = false;
+ },
+ };
+}
+
+export default createLiveThinkingThrottle;
diff --git a/tests/live_thinking_scheduler.test.mjs b/tests/live_thinking_scheduler.test.mjs
new file mode 100644
index 000000000..e0da83945
--- /dev/null
+++ b/tests/live_thinking_scheduler.test.mjs
@@ -0,0 +1,277 @@
+// Tests for the live-thinking throttle that bounds DOM work during long
+// reasoning streams (see static/js/liveThinkingThrottle.js).
+//
+// The throttle's contract is what the terminal paths in chat.js lean on:
+// a burst of deltas becomes ONE commit carrying the latest text; flush()
+// lands trailing text synchronously and cannot double-commit; cancel()
+// guarantees nothing lands after a stream is finished or backgrounded.
+//
+// Timers are injected, so this runs with no DOM and no real clock.
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ createIncrementalDisplayProjector,
+ createLiveThinkingThrottle,
+ createThinkingAnalysisGate,
+ stripLiveThinkingTags,
+} from '../static/js/liveThinkingThrottle.js';
+
+function fakeTimers() {
+ let nextId = 1;
+ const callbacks = new Map();
+ const delays = [];
+ return {
+ schedule(callback, delay) {
+ const id = nextId++;
+ callbacks.set(id, callback);
+ delays.push(delay);
+ return id;
+ },
+ cancel(id) {
+ callbacks.delete(id);
+ },
+ run(id) {
+ const callback = callbacks.get(id);
+ assert.ok(callback, `missing timer ${id}`);
+ callbacks.delete(id);
+ callback();
+ },
+ pendingIds() {
+ return [...callbacks.keys()];
+ },
+ delays,
+ };
+}
+
+test('coalesces a burst and commits only the latest text after 100 ms', () => {
+ const timers = fakeTimers();
+ const commits = [];
+ const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
+
+ throttle.update('a');
+ throttle.update('ab');
+ throttle.update('abc');
+
+ assert.deepEqual(commits, []);
+ assert.deepEqual(timers.delays, [100], 'a burst must schedule exactly one commit');
+ const [timer] = timers.pendingIds();
+ timers.run(timer);
+ assert.deepEqual(commits, ['abc']);
+});
+
+test('commit count stays flat as the stream grows', () => {
+ const timers = fakeTimers();
+ const commits = [];
+ const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
+
+ // 500 deltas arriving inside one window is the regression this guards:
+ // the old code committed once per delta, so work grew with stream length.
+ let text = '';
+ for (let i = 0; i < 500; i++) {
+ text += 'token ';
+ throttle.update(text);
+ }
+ assert.deepEqual(commits, []);
+ assert.equal(timers.pendingIds().length, 1);
+ timers.run(timers.pendingIds()[0]);
+ assert.equal(commits.length, 1);
+ assert.equal(commits[0], text);
+});
+
+test('prepares a 200K cumulative stream only at scheduled commit cadence', () => {
+ const timers = fakeTimers();
+ const commits = [];
+ let prepareCalls = 0;
+ let scannedCharacters = 0;
+ const throttle = createLiveThinkingThrottle((value) => commits.push(value), {
+ ...timers,
+ prepare(value) {
+ prepareCalls += 1;
+ scannedCharacters += value.length;
+ return stripLiveThinkingTags(value);
+ },
+ });
+
+ const delta = 'reasoning '.repeat(10); // 100 characters
+ let cumulative = '';
+ for (let i = 0; i < 2000; i++) {
+ cumulative += delta;
+ throttle.update(cumulative);
+ }
+
+ assert.equal(cumulative.length, 200_000);
+ assert.equal(prepareCalls, 0, 'cumulative extraction must not run per delta');
+ assert.equal(timers.pendingIds().length, 1);
+ timers.run(timers.pendingIds()[0]);
+ assert.equal(prepareCalls, 1);
+ assert.equal(scannedCharacters, 200_000);
+ assert.deepEqual(commits, [cumulative]);
+});
+
+test('ordinary answers and reasoning deltas do not request cumulative analysis', () => {
+ const startsReasoning = (text) => /^\s*thinking(?:\s+process)?\s*:/i.test(text);
+ const ordinaryGate = createThinkingAnalysisGate({ startsWithReasoningPrefix: startsReasoning });
+ let ordinary = '';
+ let ordinaryAnalyses = 0;
+ for (let i = 0; i < 2000; i++) {
+ ordinary += i === 0 ? 'Here is the answer. ' : 'answer '.repeat(10);
+ if (ordinaryGate.shouldAnalyze(ordinary)) ordinaryAnalyses += 1;
+ }
+ assert.equal(ordinaryAnalyses, 0);
+
+ const thinkingGate = createThinkingAnalysisGate({ startsWithReasoningPrefix: startsReasoning });
+ let thinking = 'Thin';
+ assert.equal(thinkingGate.shouldAnalyze(thinking), false);
+ thinking += 'king: inspect the problem';
+ assert.equal(thinkingGate.shouldAnalyze(thinking), true);
+ for (let i = 0; i < 2000; i++) {
+ thinking += ' reasoning'.repeat(10);
+ assert.equal(thinkingGate.shouldAnalyze(thinking, { isThinking: true, nonTagThinking: true }), false);
+ }
+ thinking += '\n\nHere is the answer';
+ assert.equal(thinkingGate.shouldAnalyze(thinking, { isThinking: true, nonTagThinking: true }), true);
+
+ const whitespaceGate = createThinkingAnalysisGate({ startsWithReasoningPrefix: startsReasoning });
+ let whitespaceThinking = ' '.repeat(250);
+ assert.equal(whitespaceGate.shouldAnalyze(whitespaceThinking), false);
+ whitespaceThinking += 'Thinking: bounded probe';
+ assert.equal(whitespaceGate.shouldAnalyze(whitespaceThinking), true);
+});
+
+test('split namespaced closes and false-close deadlines request analysis', () => {
+ let clock = 100;
+ const gate = createThinkingAnalysisGate({ now: () => clock });
+ let text = 'xanswer';
+ assert.equal(gate.shouldAnalyze(text, { isThinking: true }), true, 'split namespaced close is analyzed');
+
+ text += ' still waiting';
+ assert.equal(gate.shouldAnalyze(text, { isThinking: true, recheckAt: 500 }), false);
+ clock = 500;
+ text += ' next delta';
+ assert.equal(gate.shouldAnalyze(text, { isThinking: true, recheckAt: 500 }), true);
+
+ const attributedGate = createThinkingAnalysisGate();
+ let attributed = `reasoning';
+ assert.equal(attributedGate.shouldAnalyze(attributed), true, 'bounded carry preserves split tag attributes');
+});
+
+test('display projection is append-only and filters a structured tail once', () => {
+ let filterCalls = 0;
+ let filteredCharacters = 0;
+ const projector = createIncrementalDisplayProjector((text) => {
+ filterCalls += 1;
+ filteredCharacters += text.length;
+ return text.replace(/\[TOOL_CALL\][\s\S]*$/i, '');
+ });
+
+ let text = '';
+ for (let i = 0; i < 2000; i++) {
+ const delta = i === 0 ? 'Here is the answer. ' : 'ordinary text ';
+ text += delta;
+ assert.equal(projector.append(delta, text), text);
+ }
+ assert.equal(filterCalls, 0, 'ordinary deltas never run the cumulative filter');
+
+ text += '[TOOL_';
+ projector.append('[TOOL_', text);
+ text += 'CALL]{"name":"read"}';
+ const beforeToolPayload = projector.append('CALL]{"name":"read"}', text);
+ for (let i = 0; i < 2000; i++) {
+ const delta = 'payload ';
+ text += delta;
+ assert.equal(projector.append(delta, text), beforeToolPayload);
+ }
+ assert.equal(filterCalls, 1, 'structured payload filtering happens only at its boundary');
+ assert.ok(filteredCharacters < text.length, 'filter work is bounded by the first structured boundary');
+});
+
+test('literal escaped tags survive and malformed live tags retain trailing text', () => {
+ assert.equal(
+ stripLiveThinkingTags('<think>literal</think>'),
+ '<think>literal</think>',
+ );
+ assert.equal(
+ stripLiveThinkingTags('first middle trailing'),
+ 'first middle trailing',
+ );
+ assert.equal(stripLiveThinkingTags('answer with 2 < 3 and 5 > 4'), 'answer with 2 < 3 and 5 > 4');
+});
+
+test('terminal flush prepares and commits the complete trailing cumulative text', () => {
+ const timers = fakeTimers();
+ const commits = [];
+ const throttle = createLiveThinkingThrottle((value) => commits.push(value), {
+ ...timers,
+ prepare: stripLiveThinkingTags,
+ });
+
+ throttle.update('reasoning without a closing tag');
+ assert.equal(throttle.flush(), true);
+ assert.deepEqual(commits, ['reasoning without a closing tag']);
+ assert.deepEqual(timers.pendingIds(), []);
+});
+
+test('independent throttles cannot commit cancelled text into another session', () => {
+ const timers = fakeTimers();
+ const commits = [];
+ const first = createLiveThinkingThrottle((value) => commits.push(['first', value]), timers);
+ const second = createLiveThinkingThrottle((value) => commits.push(['second', value]), timers);
+
+ first.update('stale first-session text');
+ second.update('current second-session text');
+ first.cancel();
+ assert.equal(second.flush(), true);
+
+ assert.deepEqual(timers.pendingIds(), []);
+ assert.deepEqual(commits, [['second', 'current second-session text']]);
+});
+
+test('flush synchronously preserves trailing text and cancels the pending callback', () => {
+ const timers = fakeTimers();
+ const commits = [];
+ const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
+
+ throttle.update('trailing text');
+ assert.equal(throttle.flush(), true);
+ assert.deepEqual(commits, ['trailing text']);
+ assert.deepEqual(timers.pendingIds(), []);
+ assert.equal(throttle.flush(), false, 'clean flush must not duplicate the commit');
+});
+
+test('cancel discards pending work without a late DOM commit', () => {
+ const timers = fakeTimers();
+ const commits = [];
+ const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
+
+ throttle.update('stale session text');
+ throttle.cancel();
+ assert.deepEqual(timers.pendingIds(), []);
+ assert.deepEqual(commits, []);
+});
+
+test('a cancelled throttle accepts new work again', () => {
+ const timers = fakeTimers();
+ const commits = [];
+ const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
+
+ throttle.update('discarded');
+ throttle.cancel();
+ throttle.update('fresh');
+ assert.equal(throttle.flush(), true);
+ assert.deepEqual(commits, ['fresh']);
+});
+
+test('coerces nullish updates instead of committing undefined', () => {
+ const timers = fakeTimers();
+ const commits = [];
+ const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
+
+ throttle.update(null);
+ throttle.flush();
+ assert.deepEqual(commits, ['']);
+});
diff --git a/tests/test_chat_stream_scope.py b/tests/test_chat_stream_scope.py
index a726c776d..efd482d2f 100644
--- a/tests/test_chat_stream_scope.py
+++ b/tests/test_chat_stream_scope.py
@@ -1,3 +1,4 @@
+import re
from pathlib import Path
@@ -13,7 +14,7 @@ def test_stream_render_helpers_are_visible_to_catch_block():
assert "let _cancelThinkingTimer = () => {};" in outer_scope
assert "let _removeThinkingSpinner = () => {};" in outer_scope
- assert "_renderStream = () => {" in try_body
+ assert re.search(r"(?m)^\s*_renderStream\s*=", try_body)
assert "_cancelThinkingTimer = () => {" in try_body
assert "_removeThinkingSpinner = () => {" in try_body
assert "function _renderStream()" not in try_body
diff --git a/tests/test_live_thinking_chat_integration.py b/tests/test_live_thinking_chat_integration.py
new file mode 100644
index 000000000..13cc0d230
--- /dev/null
+++ b/tests/test_live_thinking_chat_integration.py
@@ -0,0 +1,145 @@
+"""Source-level wiring guards for live-thinking stream lifecycle.
+
+The pure scheduler suite covers timing behavior. These assertions pin the
+browser-only integration seams that are impractical to import without the full
+application DOM.
+"""
+
+from pathlib import Path
+
+
+_CHAT = (Path(__file__).resolve().parent.parent / "static" / "js" / "chat.js").read_text(
+ encoding="utf-8"
+)
+
+
+def _between(start: str, end: str) -> str:
+ return _CHAT.split(start, 1)[1].split(end, 1)[0]
+
+
+def test_in_thinking_delta_short_circuits_before_cumulative_normalization():
+ delta_handler = _between(
+ "let _delta = json.delta;",
+ "} else if (json.type === 'research_progress')",
+ )
+ delta_path = _between(
+ "// Detect thinking-in-progress:",
+ "} else if (json.type === 'research_progress')",
+ )
+ guard = "if (!_thinkingAnalysisGate.shouldAnalyze(roundText, {"
+ normalize = "markdownModule.normalizeThinkingMarkup(roundText)"
+ assert guard in delta_path
+ assert delta_path.index(guard) < delta_path.index(normalize)
+ assert "_queueLiveThinking(roundText);" in delta_path
+ assert "createThinkingAnalysisGate" in _CHAT
+ projector_append = "_roundDisplayProjector.append(_delta, roundText);"
+ assert projector_append in delta_handler
+ assert delta_handler.index(projector_append) < delta_handler.index(guard)
+ assert "_renderStream({ knownNormal: true, displayText: _roundDisplayProjector.current() });" in delta_path
+ assert "_replyDisplayProjector.append(_delta, roundReplyText)" in delta_path
+
+
+def test_short_close_grace_expires_without_another_delta():
+ assert "function _scheduleThinkingGrace()" in _CHAT
+ grace = _between(
+ "function _scheduleThinkingGrace()",
+ "function _replyAfterClosedThinking",
+ )
+ assert "setTimeout(() =>" in grace
+ assert "_finishLiveThinkingTransition();" in grace
+ cancel = _between("_cancelLiveThinkingWork = () =>", "function _finalizeLiveThinking")
+ assert "_cancelThinkingGrace();" in cancel
+ delta_path = _between(
+ "// Detect thinking-in-progress:",
+ "} else if (json.type === 'research_progress')",
+ )
+ false_close = _between(
+ "// Detect false close:",
+ "if (hasUnclosedThink && !isThinking)",
+ )
+ assert "Do NOT require a prior unclosed delta" in false_close
+ assert "_afterClose &&" in false_close
+ assert "&& isThinking" not in false_close.split("let _falseCloseDeadline", 1)[1].split("if (isThinking)", 1)[0]
+ assert "_thinkingRecheckAt = _falseCloseDeadline || 0;" in delta_path
+
+
+def test_terminal_paths_use_one_authoritative_rich_round_render():
+ tool_path = _between(
+ "} else if (json.type === 'tool_start') {",
+ "} else if (json.type === 'tool_output') {",
+ )
+ assert "_endLiveThinkingSection({ rich: false });" in tool_path
+ assert tool_path.count("_finalizeRoundRender();") == 1
+ assert "_renderStream();" not in tool_path
+
+ agent_path = _between(
+ "} else if (json.type === 'agent_step') {",
+ "} else if (json.type === 'budget_exceeded') {",
+ )
+ assert "_endLiveThinkingSection({ rich: false });" in agent_path
+ assert agent_path.count("_finalizeRoundRender();") == 1
+ assert "if (!roundFinalized)" not in agent_path
+
+ catch_path = _between(
+ "// foreground session's text.\n const _isBgCatch",
+ "} finally {",
+ )
+ assert "if (_isBgCatch)" in catch_path
+ assert "_cancelLiveThinkingWork();" in catch_path
+ assert "_catchTerminalView = _finalizeInterruptedView();" in catch_path
+ assert "_finalizeRoundRender();" not in catch_path
+ assert "_endThinkingOnTerminalPath({ rich: false });" in catch_path
+ assert "const _catchViewHolder = _catchTerminalView?.holder || holder;" in catch_path
+
+ round_finalizer = _between(
+ "_finalizeRoundRender = () => {",
+ "_finalizeInterruptedView = () => {",
+ )
+ assert "if (roundFinalized) return roundFinalization;" in round_finalizer
+ assert round_finalizer.index("processWithThinking") < round_finalizer.rindex("roundFinalized = true;")
+ assert "lastContentRoundHolder = terminalHolder;" in round_finalizer
+
+ interrupted_finalizer = _between(
+ "_finalizeInterruptedView = () => {",
+ "function _replyAfterClosedThinking",
+ )
+ assert "finalization?.hasContent" in interrupted_finalizer
+ assert "lastContentRoundHolder || finalization?.holder" in interrupted_finalizer
+
+ stop_path = _between(
+ "// Render whatever was accumulated so far",
+ "// Reset button state",
+ )
+ assert "const _stoppedViewHolder = _terminalView?.holder || currentHolder;" in stop_path
+ assert "_stoppedViewHolder.querySelector('.body').appendChild(stoppedIndicator);" in stop_path
+
+ done_path = _between(
+ "if (data === '[DONE]') {",
+ "try {\n const json = JSON.parse(data);",
+ )
+ assert "_finalizeLiveThinking(_closedThinkingText(roundText), false);" in done_path
+ assert "_renderStream();" not in done_path
+
+ post_loop = _between(
+ "if (!_streamSawDone) {",
+ "// --- Final render (skip if stream was ever backgrounded or currently in background) ---",
+ )
+ assert "_cancelLiveThinkingWork();" in post_loop
+ assert "_renderStream();" not in post_loop
+
+ recovery_path = _between(
+ "function _tryAutoRecover(holder, accumulated, sessionId)",
+ "function _removeStallBanner()",
+ )
+ assert "processWithThinking" not in recovery_path
+
+
+def test_detach_synchronously_cancels_delayed_view_work():
+ registration = _between("_activeStreams.set(streamSessionId", "_syncForegroundStreamGlobals();")
+ assert "cancelViewWork: () => _cancelLiveThinkingWork()" in registration
+
+ detach = _between("export function detachCurrentStream", "// _notifyStreamComplete")
+ cancel = "if (active.cancelViewWork) active.cancelViewWork();"
+ background = "_backgroundStreams.set(sessionId"
+ assert cancel in detach
+ assert detach.index(cancel) < detach.index(background)
diff --git a/tests/test_live_thinking_scheduler_js.py b/tests/test_live_thinking_scheduler_js.py
new file mode 100644
index 000000000..aa1f5cecb
--- /dev/null
+++ b/tests/test_live_thinking_scheduler_js.py
@@ -0,0 +1,29 @@
+"""Runs the live-thinking throttle's behavioral suite under pytest.
+
+Behavior lives in tests/live_thinking_scheduler.test.mjs (node:test, no DOM).
+This wrapper only exists so the JS suite runs in the normal pytest job.
+"""
+
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+_REPO = Path(__file__).resolve().parent.parent
+_HAS_NODE = shutil.which("node") is not None
+
+
+@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+def test_live_thinking_scheduler_behavior():
+ result = subprocess.run(
+ ["node", "--test", "tests/live_thinking_scheduler.test.mjs"],
+ cwd=_REPO,
+ capture_output=True,
+ timeout=30,
+ text=True,
+ )
+ if result.returncode != 0:
+ raise AssertionError(
+ f"node --test failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
+ )