fix(ui): prevent OOM on tab focus for long sessions (#7006)

* 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>
This commit is contained in:
authored and GitHub committed 2026-08-14 19:42:15 -07:00
1 parent 95f05c5147
commit 37c59baf68
7 files changed
+914 -7

No files matched your search

+16 -1
View File
@@ -8022,6 +8022,20 @@ function startSessionStream(sid) {
// the hidden-tab return (loadSession force + keepStaleUntilLoaded): the new
// transcript replaces the old in a single render frame — NO clear+refetch,
// so the #5177/#5189 blank-gap "jump" is not reintroduced.
// #6999: focusing a backgrounded tab also fires the visibility-recovery
// probe in sessions.js (refreshActiveSessionIfExternallyUpdated), which
// holds the shared _activeSessionExternalRefreshInFlight guard while it
// probes + force-reloads this same session. This handler honors that guard
// so the two paths never start two concurrent loadSession(force) calls
// (double full-transcript fetch + double renderMessages pass = the OOM
// pattern on long sessions). The probe side carries its own
// _loadingSessionId guard; loadSession() keeps its legitimate
// newest-wins supersede semantics untouched.
// #6999 re-gate: while the probe owns the guard, frames are COALESCED via
// _coalesceSessionUpdatedWhileRefreshHeld — the max announced count is
// latched per SID and the owner's finally runs ONE guarded follow-up when
// local state is still behind (a bare return dropped the update; production
// does not guarantee a second event).
es.addEventListener('session-updated', e => {
try {
const d = JSON.parse(e.data || '{}');
@@ -8034,12 +8048,13 @@ function startSessionStream(sid) {
: (S.session && S.session.session_id === sid);
if (!isCurrent) return;
if (S.activeStreamId) return;
const serverCount = Number(d.message_count);
if (typeof _coalesceSessionUpdatedWhileRefreshHeld === 'function' && _coalesceSessionUpdatedWhileRefreshHeld(sid, serverCount)) return;
// Re-check against our CURRENT known count — a concurrent load may have
// already caught us up between the server's emit and now.
const localCount = (S.session && S.session.session_id === sid && Number.isFinite(Number(S.session.message_count)))
? Number(S.session.message_count)
: (Array.isArray(S.messages) ? S.messages.length : 0);
const serverCount = Number(d.message_count);
if (!Number.isFinite(serverCount) || serverCount <= localCount) return;
if (typeof loadSession === 'function') {
void loadSession(sid, {force: true, externalRefreshReason: 'session-updated', keepStaleUntilLoaded: true});