* fix(ui): prevent OOM on tab focus for long sessions Coordinate visibility-recovery and session-updated refresh paths so they never start concurrent full-transcript loads, window the O(N) pre-render scans to the virtual render window, and bound the render-window expansion. Closes #6999 * fix(ui): full turn context + collision-free cache hash (#7006 review rework) Addresses the maintainer's CHANGES_REQUESTED on PR #7006: - Turn-context maps (_assistantTurnFinalVisibleContentMap / _assistantTurnVisibleContentMap) receive the FULL visWithIdx again. The virtualized render window (head+tail around a gap) cut through assistant runs and merged distinct turns across an omitted user boundary, which could duplicate the final answer in Worklog/Thinking or feed later-turn prose into the reasoning echo-strip. Turn context is input, not output: it must always be complete. - _addBoundedHash now streams the FULL string form through the FNV-1a loop (no length+head+tail clip, no slice copies), and object fields (attachments, tc.args, compression-anchor keys) are walked key-by-key so no integral JSON.stringify() is allocated. Same-length middle-only edits of tool args, attachment metadata, tool snippets, or compression-anchor keys no longer collide in _sessionHtmlCache. Regression tests: - tests/test_issue6999_turn_context_virtual_gap.py: multi-assistant turn with a virtualized gap proves full-context echo-strip suppresses the exact final-answer echo, and documents the gapped head+tail failure. - tests/test_issue2613_render_cache_signature.py: Node harness executing the real _messageRenderCacheSignature() proves same-length middle-only mutations change the signature for every structured field while the old clip scheme collides on the same data. Closes #6999 * fix(ui): coalesce session-updated under refresh guard; insertion-order cache hash Re-gate round 2 for #6999: - session-updated frames arriving while the external-refresh guard is held are latched per SID (max announced count) and drained by the refresh owner's finally with ONE guarded follow-up, instead of being dropped (production does not guarantee a second event). - _hashObjectInto walks keys in insertion order (matching Object.entries render projection) with an explicit per-key index discriminator, so opposite-insertion-order argument objects no longer share a cache signature; recursion depth is now threaded (children increment, not restart). * test(jump-scroll): stub _loadingSessionId + _drainSessionUpdatedPendingCount in extraction harness #7006 adds a reference to the pre-existing module var _loadingSessionId and a new _drainSessionUpdatedPendingCount() helper inside refreshActiveSessionIfExternallyUpdated. The jump-to-answer scroll-settle tests extract that function into a node harness, which didn't declare them -> ReferenceError (19 parametrized failures). Add the missing stubs to the harness preambles (no-op drain, since these tests exercise scroll ownership, not the coalesce path -- real drain behavior is covered by test_issue6999_session_updated_coalesce.py). Test-harness-only; production static/ untouched. Co-authored-by: webtecnica <webtecnica@users.noreply.github.com> --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
218 lines
8.8 KiB
Python
218 lines
8.8 KiB
Python
"""#6999 -- virtual-window turn context must stay complete.
|
|
|
|
The first #6999 iteration fed the assistant-turn content maps
|
|
(_assistantTurnFinalVisibleContentMap / _assistantTurnVisibleContentMap) the
|
|
*render* window, ``renderVisWithIdx`` — which is
|
|
``renderHeadVisWithIdx.concat(renderTailVisWithIdx)`` around a virtualized gap
|
|
(static/ui.js:15404-15413). Those helpers derive the echo-strip context of a
|
|
rendered assistant row from ALL assistant siblings of its turn
|
|
(static/ui.js:10783-10830), so windowed-out siblings are input context even
|
|
though their own rows are not read:
|
|
|
|
* cutting through an assistant run loses the final/visible answer used to
|
|
strip reasoning echoes -> duplicate final-answer in Worklog/Thinking;
|
|
* concatenating head+tail across an omitted user boundary merges distinct
|
|
turns into one run -> unrelated later-turn prose becomes the echo-strip
|
|
input.
|
|
|
|
These regressions lock the correction: renderMessages() must feed the maps
|
|
the FULL ``visWithIdx`` (never the virtual window), and the maps + echo-strip
|
|
chain must behave correctly on that full input.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
|
NODE = shutil.which("node")
|
|
|
|
|
|
def _render_messages_body() -> str:
|
|
start = UI_JS.find("function renderMessages(")
|
|
assert start != -1, "renderMessages() not found"
|
|
return UI_JS[start : start + 80000]
|
|
|
|
|
|
def test_render_messages_feeds_full_vis_with_idx_to_turn_maps():
|
|
body = _render_messages_body()
|
|
assert "_assistantTurnFinalVisibleContentMap(visWithIdx)" in body, (
|
|
"Turn context maps must see the FULL visWithIdx so an assistant run is "
|
|
"never cut by the virtual window."
|
|
)
|
|
assert "_assistantTurnVisibleContentMap(visWithIdx)" in body, (
|
|
"Turn visible-content map must see the FULL visWithIdx."
|
|
)
|
|
assert "_assistantTurnFinalVisibleContentMap(renderVisWithIdx)" not in body, (
|
|
"The virtualized render window (head+tail with a gap) must never be "
|
|
"used as turn-context input — it cuts runs and merges distinct turns."
|
|
)
|
|
assert "_assistantTurnVisibleContentMap(renderVisWithIdx)" not in body
|
|
|
|
|
|
def _function_source(name: str) -> str:
|
|
"""Return the full ``function name(...) {...}`` source from static/ui.js."""
|
|
marker = f"function {name}"
|
|
start = UI_JS.find(marker)
|
|
assert start != -1, f"{name} not found in static/ui.js"
|
|
params = UI_JS.find("(", start)
|
|
assert params != -1, f"{name} has no parameter list"
|
|
depth = 0
|
|
close = -1
|
|
for idx in range(params, len(UI_JS)):
|
|
if UI_JS[idx] == "(":
|
|
depth += 1
|
|
elif UI_JS[idx] == ")":
|
|
depth -= 1
|
|
if depth == 0:
|
|
close = idx
|
|
break
|
|
assert close != -1, f"{name} parameter list did not close"
|
|
brace = UI_JS.find("{", close)
|
|
assert brace != -1, f"{name} has no body"
|
|
depth = 0
|
|
for idx in range(brace, len(UI_JS)):
|
|
if UI_JS[idx] == "{":
|
|
depth += 1
|
|
elif UI_JS[idx] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return UI_JS[start : idx + 1]
|
|
raise AssertionError(f"{name} body did not close")
|
|
|
|
|
|
_FN_DECL_RE = re.compile(r"\bfunction\s+([A-Za-z_$][\w$]*)\s*\(")
|
|
|
|
|
|
def _collect_functions(names: list[str]) -> str:
|
|
"""Concatenate the sources of ``names`` plus every ``function X(`` dep."""
|
|
out: dict[str, str] = {}
|
|
stack = list(names)
|
|
seen: set[str] = set()
|
|
while stack:
|
|
name = stack.pop()
|
|
if name in seen:
|
|
continue
|
|
seen.add(name)
|
|
src = _function_source(name)
|
|
out[name] = src
|
|
for dep in _FN_DECL_RE.findall(src):
|
|
if dep not in seen:
|
|
stack.append(dep)
|
|
return "\n".join(out.values())
|
|
|
|
|
|
_TURN_CONTEXT_NODE_BODY = r"""
|
|
// Two turns. Turn 1 is a run of THREE assistant siblings: a thinking-only
|
|
// message that exactly echoes the turn's final answer (rawIdx 1), process
|
|
// prose (rawIdx 2), and the final answer (rawIdx 3). Turn 2 follows after a
|
|
// user boundary (rawIdx 4).
|
|
const full = [
|
|
{m: {role: 'user', content: 'Question 1'}, rawIdx: 0},
|
|
{m: {role: 'assistant', content: '<think>The answer is 42.</think>'}, rawIdx: 1},
|
|
{m: {role: 'assistant', content: 'I will inspect the file first.'}, rawIdx: 2},
|
|
{m: {role: 'assistant', content: 'The answer is 42.'}, rawIdx: 3},
|
|
{m: {role: 'user', content: 'Question 2'}, rawIdx: 4},
|
|
{m: {role: 'assistant', content: '<think>Second thinking</think>'}, rawIdx: 5},
|
|
{m: {role: 'assistant', content: 'Second answer.'}, rawIdx: 6},
|
|
];
|
|
// Virtualized render window: head = rawIdx 0..2, tail = rawIdx 5..6. The gap
|
|
// (rawIdx 3..4) contains turn 1's FINAL ANSWER and the user boundary that
|
|
// separates the two turns.
|
|
const gapped = [full[0], full[1], full[2], full[5], full[6]];
|
|
|
|
const finalMapFull = _assistantTurnFinalVisibleContentMap(full);
|
|
const visibleMapFull = _assistantTurnVisibleContentMap(full);
|
|
const finalMapGapped = _assistantTurnFinalVisibleContentMap(gapped);
|
|
const visibleMapGapped = _assistantTurnVisibleContentMap(gapped);
|
|
|
|
const a1FullFinal = finalMapFull.get(1) || '';
|
|
const a2FullFinal = finalMapFull.get(2) || '';
|
|
const a3FullFinal = finalMapFull.get(3) || '';
|
|
const a1FullVisible = visibleMapFull.get(1) || [];
|
|
// Echo-strip for the rendered thinking-only row (rawIdx 1) using FULL turn
|
|
// context: the thinking exactly echoes the final answer -> stripped.
|
|
const strippedFull = _worklogReasoningTextFromMessage(full[1].m, 1, new Set(), '', a1FullFinal, a1FullVisible);
|
|
|
|
// Contrast with the GAPPED window: the run is cut (final answer windowed
|
|
// out) and head+tail merge across the omitted user boundary into turn 2's
|
|
// run — rawIdx 1 would see turn 2's final answer as its strip input.
|
|
const a1GappedFinal = finalMapGapped.get(1) || '';
|
|
const a1GappedVisible = visibleMapGapped.get(1) || [];
|
|
const strippedGapped = _worklogReasoningTextFromMessage(gapped[1].m, 1, new Set(), '', a1GappedFinal, a1GappedVisible);
|
|
|
|
console.log(JSON.stringify({
|
|
a1FullFinal,
|
|
a2FullFinal,
|
|
a3FullFinal,
|
|
a1FullVisible,
|
|
strippedFull,
|
|
a1GappedFinal,
|
|
a1GappedVisible,
|
|
strippedGapped,
|
|
}));
|
|
"""
|
|
|
|
|
|
def _run_turn_context_node() -> dict:
|
|
assert NODE, "node is required for the turn-context harness"
|
|
fns = _collect_functions(
|
|
[
|
|
"_assistantTurnFinalVisibleContentMap",
|
|
"_assistantTurnVisibleContentMap",
|
|
"_assistantVisibleContentForReasoningCompare",
|
|
"_worklogReasoningTextFromMessage",
|
|
"_assistantReasoningPayloadText",
|
|
"_stripVisibleAssistantEchoFromThinking",
|
|
"_sanitizeThinkingDisplayText",
|
|
"_normalizeThinkingEchoCompare",
|
|
"_stripLeadingAssistantThinkingMarkup",
|
|
"_stripXmlToolCallsDisplay",
|
|
"_assistantAnchorSceneFinalAnswerText",
|
|
"_isMarkerOnlyAssistantCompressionMessage",
|
|
"_isPreservedCompressionTaskListMarkerOnlyText",
|
|
"_isPreservedCompressionTaskListMarkerText",
|
|
"_isAssistantEmptyPlaceholderContent",
|
|
"_messageHasReasoningPayload",
|
|
"msgContent",
|
|
]
|
|
)
|
|
script = f"const window = {{}};\n{fns}\n{_TURN_CONTEXT_NODE_BODY}"
|
|
result = subprocess.run([NODE, "-e", script], text=True, capture_output=True, check=False)
|
|
assert result.returncode == 0, result.stderr
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_full_turn_context_propagates_final_answer_and_strips_echo():
|
|
out = _run_turn_context_node()
|
|
# Every assistant sibling of turn 1 — including the one whose row is
|
|
# windowed out (rawIdx 3) — must map to the run's final visible answer.
|
|
assert out["a1FullFinal"] == "The answer is 42."
|
|
assert out["a2FullFinal"] == "The answer is 42."
|
|
assert out["a3FullFinal"] == "The answer is 42."
|
|
# The rendered thinking-only sibling sees the full visible texts of its
|
|
# run: process prose AND the final answer.
|
|
assert out["a1FullVisible"] == ["I will inspect the file first.", "The answer is 42."]
|
|
# Exact echo of the final answer is stripped -> no duplicate final-answer
|
|
# in Worklog/Thinking.
|
|
assert out["strippedFull"] == "", (
|
|
"Thinking that exactly echoes the turn's final answer must be "
|
|
"suppressed when the full turn context is available."
|
|
)
|
|
|
|
|
|
def test_gapped_window_would_cut_run_and_merge_turns():
|
|
"""Document why renderMessages must NOT use the virtual window here."""
|
|
out = _run_turn_context_node()
|
|
# Head+tail across the omitted user boundary merges turn 1 into turn 2's
|
|
# run: rawIdx 1 would receive turn 2's final answer as its strip input.
|
|
assert out["a1GappedFinal"] == "Second answer."
|
|
# The final answer of turn 1 is not in the visible texts (windowed out),
|
|
# so the echo is NOT stripped — the duplicate would reappear.
|
|
assert out["strippedGapped"] != ""
|