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 = 'x { + 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}" + )