mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-12 08:28:40 -04:00
perf(chat): batch live thinking rendering and bound timer updates (#5931)
* 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
<think> to the first </think> pinned the live box to "The" for the rest of the
stream on the `<think>The</think>` + 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 <leograndcontact@gmail.com>
This commit is contained in:
@@ -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. |
|
||||
|
||||
+513
-230
File diff suppressed because it is too large
Load Diff
@@ -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)|<channel\|>/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)>|<invoke\b|<\s*\/?\s*[||]+\s*DSML\s*[||]+|(?:\[\s*)?\{\s*"function"\s*:|<\/?\|(?:assistant|assistan|user|system|tool|end)\|?>|(?:^|[\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;
|
||||
@@ -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 = '<mm:think>x</mm:';
|
||||
assert.equal(gate.shouldAnalyze(text, { isThinking: true }), true, 'fresh opening tag is analyzed');
|
||||
text += 'think>answer';
|
||||
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 = `<think data-provider="${'x'.repeat(400)}"`;
|
||||
assert.equal(attributedGate.shouldAnalyze(attributed), false);
|
||||
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('<think>first</think> middle <thinking mode="deep">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('<think>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, ['']);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
)
|
||||
Reference in New Issue
Block a user