From e183e1da8ec52e35200fa9e9581ce39b9fb873dd Mon Sep 17 00:00:00 2001 From: Hermes Staging Bot Date: Mon, 20 Jul 2026 03:54:10 +0400 Subject: [PATCH 001/115] feat(webui): allow per-instance settings file --- api/config.py | 11 ++++++++++- tests/test_configurable_pinned_sessions_limit.py | 11 +++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/api/config.py b/api/config.py index 1a45f0b7..5a632279 100644 --- a/api/config.py +++ b/api/config.py @@ -83,10 +83,19 @@ STATE_DIR = ( .resolve() ) + +def _resolve_settings_file(state_dir: Path) -> Path: + """Resolve an optional per-instance settings file without splitting session state.""" + configured = os.getenv("HERMES_WEBUI_SETTINGS_FILE", "").strip() + if configured: + return Path(configured).expanduser().resolve() + return state_dir / "settings.json" + + SESSION_DIR = STATE_DIR / "sessions" WORKSPACES_FILE = STATE_DIR / "workspaces.json" SESSION_INDEX_FILE = SESSION_DIR / "_index.json" -SETTINGS_FILE = STATE_DIR / "settings.json" +SETTINGS_FILE = _resolve_settings_file(STATE_DIR) LAST_WORKSPACE_FILE = STATE_DIR / "last_workspace.txt" PROJECTS_FILE = STATE_DIR / "projects.json" diff --git a/tests/test_configurable_pinned_sessions_limit.py b/tests/test_configurable_pinned_sessions_limit.py index 3b98ed05..7c7914e1 100644 --- a/tests/test_configurable_pinned_sessions_limit.py +++ b/tests/test_configurable_pinned_sessions_limit.py @@ -5,6 +5,7 @@ import pathlib import urllib.error import urllib.request +from api import config as webui_config from tests._pytest_port import BASE ROOT = pathlib.Path(__file__).resolve().parent.parent @@ -42,6 +43,16 @@ def make_session(created, title): return sid +def test_settings_file_path_can_be_scoped_per_instance(tmp_path, monkeypatch): + default_state_dir = tmp_path / "default-state" + monkeypatch.delenv("HERMES_WEBUI_SETTINGS_FILE", raising=False) + assert webui_config._resolve_settings_file(default_state_dir) == default_state_dir / "settings.json" + + staging_settings = tmp_path / "staging" / "settings.json" + monkeypatch.setenv("HERMES_WEBUI_SETTINGS_FILE", str(staging_settings)) + assert webui_config._resolve_settings_file(default_state_dir) == staging_settings.resolve() + + def test_pin_limit_setting_is_exposed_and_wired_through_ui(): assert '"pinned_sessions_limit": 3' in CONFIG_PY assert '"pinned_sessions_limit": (1, 99)' in CONFIG_PY From 4df32ea33243ed4bfe7e9c037e8351d8151ac97f Mon Sep 17 00:00:00 2001 From: webtecnica Date: Sun, 2 Aug 2026 14:59:15 -0300 Subject: [PATCH 002/115] fix(perf): don't reset virtual measurement retry budget on window-key oscillation (#6654) _scheduleMessageVirtualMeasurementRefresh() reset _messageVirtualMeasurementRetryCount every time _messageVirtualMeasurementCycleKey changed. When WebKit alternates between two measured window-metric sets (A -> B -> A -> B) after getBoundingClientRect(), each key transition renewed the two-render budget and the requestAnimationFrame/geometry-measurement loop never terminated (WebContent ~30% CPU for minutes after session recovery). The MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS budget now caps the whole convergence burst globally; the cycle-key branch only records the new key. The budget still resets at the correct lifecycle boundaries: _markMessageVirtualMeasurementsSettled() and _clearMessageVirtualHeightCache(). Adds a deterministic node-based regression test driving the real ui.js function with alternating keys ['A','B','A','B'] (before: 4 scheduled re-renders; after: 2), plus a stable-key guard and a structural test. --- static/ui.js | 1 - ...st_issue500_message_list_virtualization.py | 83 +++++++++++++++++++ tests/test_issue734_message_windowing.py | 18 ++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/static/ui.js b/static/ui.js index 56af87da..13583741 100644 --- a/static/ui.js +++ b/static/ui.js @@ -727,7 +727,6 @@ function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){ const cycleKey=_messageVirtualMeasurementCycleKeyFor(windowMetrics); if(_messageVirtualMeasurementCycleKey!==cycleKey){ _messageVirtualMeasurementCycleKey=cycleKey; - _messageVirtualMeasurementRetryCount=0; } if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return; _messageVirtualMeasurementRetryCount++; diff --git a/tests/test_issue500_message_list_virtualization.py b/tests/test_issue500_message_list_virtualization.py index 70a8e851..6bd963ca 100644 --- a/tests/test_issue500_message_list_virtualization.py +++ b/tests/test_issue500_message_list_virtualization.py @@ -1141,3 +1141,86 @@ console.log(JSON.stringify({ assert metrics["timerCleared"] is True, ( "_clearMessageVirtualHeightCache must call clearTimeout on the pending settle timer" ) + + +def test_measurement_retry_budget_is_global_across_cycle_key_oscillation(): + """#6654: _scheduleMessageVirtualMeasurementRefresh must NOT reset + _messageVirtualMeasurementRetryCount when _messageVirtualMeasurementCycleKey + changes. If WebKit alternates between two measured window-metric sets + (A -> B -> A -> B), each key transition used to reset the two-render budget, + so the rAF/geometry-measurement loop never terminated (~30% WebContent CPU). + The MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS budget must cap the whole + convergence burst globally; it resets only when measurement settles + (_markMessageVirtualMeasurementsSettled) or the cache/session resets + (_clearMessageVirtualHeightCache).""" + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + """ +let scheduled = 0; +let _messageVirtualMeasurementCycleKey = ''; +let _messageVirtualMeasurementRetryCount = 0; +let _messageVirtualScrollActive = false; +let _messageVirtualDeferredMeasurement = null; +const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS = 2; +function _messageVirtualMeasurementCycleKeyFor(w) { return w.key; } +function _scheduleMessageVirtualizedRender(force) { scheduled++; } +function requestAnimationFrame(cb) { cb(); } +eval(extractFunc('_scheduleMessageVirtualMeasurementRefresh')); +// WebKit alternates between two adjacent window-metric keys across +// getBoundingClientRect passes. Before the fix every key transition reset the +// budget, so all four passes scheduled a re-render. +for (const key of ['A','B','A','B']) { + _scheduleMessageVirtualMeasurementRefresh({ key: key }); +} +console.log(JSON.stringify({ + scheduled: scheduled, + retries: _messageVirtualMeasurementRetryCount, +})); +""" + metrics = json.loads(_run_node(source)) + assert metrics["scheduled"] == 2, ( + "cycle-key oscillation must not reset the retry budget: " + f"scheduled {metrics['scheduled']} re-renders (expected 2, the " + "MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS cap for the whole burst)" + ) + assert metrics["retries"] == 2, ( + "retry count must keep accumulating across key changes: " + f"ended at {metrics['retries']} (expected 2)" + ) + + +def test_measurement_retry_budget_still_caps_stable_key_burst(): + """#6654 guard: with a stable cycle key the budget must still cap the burst + at MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS (the removed per-key reset + must not have weakened the stable-key path).""" + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + """ +let scheduled = 0; +let _messageVirtualMeasurementCycleKey = ''; +let _messageVirtualMeasurementRetryCount = 0; +let _messageVirtualScrollActive = false; +let _messageVirtualDeferredMeasurement = null; +const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS = 2; +function _messageVirtualMeasurementCycleKeyFor(w) { return w.key; } +function _scheduleMessageVirtualizedRender(force) { scheduled++; } +function requestAnimationFrame(cb) { cb(); } +eval(extractFunc('_scheduleMessageVirtualMeasurementRefresh')); +for (let i = 0; i < 6; i++) { + _scheduleMessageVirtualMeasurementRefresh({ key: 'A' }); +} +// The budget is exhausted after two renders; a settled measurement resets it. +_messageVirtualMeasurementRetryCount = 0; +_scheduleMessageVirtualMeasurementRefresh({ key: 'A' }); +console.log(JSON.stringify({ + scheduled: scheduled, + retries: _messageVirtualMeasurementRetryCount, +})); +""" + metrics = json.loads(_run_node(source)) + assert metrics["scheduled"] == 3, ( + "stable-key burst must stop at MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS " + f"and resume only after a budget reset: scheduled {metrics['scheduled']} " + "(expected 3: 2 capped + 1 after explicit reset)" + ) + assert metrics["retries"] == 1, ( + f"retry count after explicit reset must be 1, got {metrics['retries']}" + ) diff --git a/tests/test_issue734_message_windowing.py b/tests/test_issue734_message_windowing.py index adf7df9e..a9f5257c 100644 --- a/tests/test_issue734_message_windowing.py +++ b/tests/test_issue734_message_windowing.py @@ -59,3 +59,21 @@ def test_measurement_rerenders_are_bounded_per_virtual_window_cycle(): assert "if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return;" in UI_JS assert "_scheduleMessageVirtualMeasurementRefresh(virtualWindow);" in UI_JS assert "_markMessageVirtualMeasurementsSettled(virtualWindow);" in UI_JS + + +def test_measurement_retry_budget_not_reset_by_cycle_key_change(): + """#6654: the cycle-key branch of _scheduleMessageVirtualMeasurementRefresh + must record the new key but never reset _messageVirtualMeasurementRetryCount. + Resetting on key change lets WebKit's A->B->A->B metric oscillation renew + the two-render budget forever, keeping the rAF/measure loop alive.""" + idx = UI_JS.index("function _scheduleMessageVirtualMeasurementRefresh(windowMetrics)") + end = UI_JS.index("function _markMessageVirtualMeasurementsSettled", idx) + body = UI_JS[idx:end] + # The key-change branch must only assign the key. + assert "if(_messageVirtualMeasurementCycleKey!==cycleKey){" in body + assert "_messageVirtualMeasurementRetryCount=0;" not in body, ( + "cycle-key change must not reset the retry budget (issue #6654)" + ) + # The budget guard and increment must still be present. + assert "if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return;" in body + assert "_messageVirtualMeasurementRetryCount++;" in body From f88cc782864fa76075f81959389a2c887b98a904 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Tue, 4 Aug 2026 10:59:35 -0300 Subject: [PATCH 003/115] fix(perf): per-burst retry budget lifecycle, reset only on external render (#6717) --- static/ui.js | 23 ++- ...st_issue500_message_list_virtualization.py | 152 +++++++++++------- tests/test_issue734_message_windowing.py | 24 ++- 3 files changed, 135 insertions(+), 64 deletions(-) diff --git a/static/ui.js b/static/ui.js index 13583741..ee42cfbc 100644 --- a/static/ui.js +++ b/static/ui.js @@ -548,6 +548,7 @@ let _messageVirtualScrollRaf=0; let _messageVirtualWindowKey=''; let _messageVirtualMeasurementCycleKey=''; let _messageVirtualMeasurementRetryCount=0; +let _messageVirtualMeasurementBurstActive=false; let _messageVirtualScrollActive=false; let _messageVirtualScrollSettleTimer=0; let _messageVirtualDeferredMeasurement=null; @@ -595,6 +596,7 @@ function _clearMessageVirtualHeightCache(){ _messageVirtualWindowKey=''; _messageVirtualMeasurementCycleKey=''; _messageVirtualMeasurementRetryCount=0; + _messageVirtualMeasurementBurstActive=false; _messageVirtualScrollActive=false; clearTimeout(_messageVirtualScrollSettleTimer); _messageVirtualScrollSettleTimer=0; @@ -728,13 +730,32 @@ function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){ if(_messageVirtualMeasurementCycleKey!==cycleKey){ _messageVirtualMeasurementCycleKey=cycleKey; } - if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return; + // Per-burst retry budget (#6717): the counter is preserved across ONLY the + // internally measurement-scheduled chain (requestAnimationFrame -> + // _scheduleMessageVirtualizedRender -> renderMessages -> re-measure -> here), + // so WebKit's A/B window-metric oscillation can never renew the budget and + // keep the rAF/measure loop alive (#6654). It is reset exactly once when a + // later EXTERNALLY initiated render begins a genuinely new measurement cycle + // (session load, message append, real content change) — never on cycle-key + // changes. A cycle-key change alone just records the new key above. + if(!_messageVirtualMeasurementBurstActive){ + _messageVirtualMeasurementRetryCount=0; + _messageVirtualMeasurementBurstActive=true; + } + if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS){ + // Budget exhausted: this burst is over (no further internal re-render is + // scheduled), so the next externally initiated cycle starts fresh instead + // of being starved of its own retries. + _messageVirtualMeasurementBurstActive=false; + return; + } _messageVirtualMeasurementRetryCount++; requestAnimationFrame(()=>{ _scheduleMessageVirtualizedRender(true); }); } function _markMessageVirtualMeasurementsSettled(windowMetrics){ _messageVirtualMeasurementCycleKey=_messageVirtualMeasurementCycleKeyFor(windowMetrics); _messageVirtualMeasurementRetryCount=0; + _messageVirtualMeasurementBurstActive=false; } function _messageVirtualHeightEntryMatches(previousEntry, nextEntry){ return !!( diff --git a/tests/test_issue500_message_list_virtualization.py b/tests/test_issue500_message_list_virtualization.py index 6bd963ca..994d2951 100644 --- a/tests/test_issue500_message_list_virtualization.py +++ b/tests/test_issue500_message_list_virtualization.py @@ -1112,6 +1112,7 @@ let _messageVirtualEstimatedRowHeight = 200; let _messageVirtualWindowKey = 'old-key'; let _messageVirtualMeasurementCycleKey = 'old-cycle'; let _messageVirtualMeasurementRetryCount = 3; +let _messageVirtualMeasurementBurstActive = false; const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140; // #4367 introduced per-role default heights; the combined _clearMessageVirtualHeightCache // resets the estimate via _messageVirtualDefaultHeightForRole('default'), so the harness @@ -1146,81 +1147,116 @@ console.log(JSON.stringify({ def test_measurement_retry_budget_is_global_across_cycle_key_oscillation(): """#6654: _scheduleMessageVirtualMeasurementRefresh must NOT reset _messageVirtualMeasurementRetryCount when _messageVirtualMeasurementCycleKey - changes. If WebKit alternates between two measured window-metric sets - (A -> B -> A -> B), each key transition used to reset the two-render budget, - so the rAF/geometry-measurement loop never terminated (~30% WebContent CPU). - The MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS budget must cap the whole - convergence burst globally; it resets only when measurement settles - (_markMessageVirtualMeasurementsSettled) or the cache/session resets - (_clearMessageVirtualHeightCache).""" + changes INSIDE one measurement burst. If WebKit alternates between two + measured window-metric sets (A -> B -> A -> B), each key transition used to + reset the two-render budget, so the rAF/geometry-measurement loop never + terminated (~30% WebContent CPU). The MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS + budget must cap the whole convergence burst; the burst ends at the cap (or + when measurement settles), so the NEXT externally initiated cycle gets a + fresh budget. The harness drives the thrash causally through the internal + chain (each scheduled render re-measures the next oscillating key).""" js = UI_JS_PATH.read_text(encoding="utf-8") source = _extract_func_script(js) + """ let scheduled = 0; let _messageVirtualMeasurementCycleKey = ''; let _messageVirtualMeasurementRetryCount = 0; +let _messageVirtualMeasurementBurstActive = false; let _messageVirtualScrollActive = false; let _messageVirtualDeferredMeasurement = null; const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS = 2; function _messageVirtualMeasurementCycleKeyFor(w) { return w.key; } -function _scheduleMessageVirtualizedRender(force) { scheduled++; } -function requestAnimationFrame(cb) { cb(); } -eval(extractFunc('_scheduleMessageVirtualMeasurementRefresh')); -// WebKit alternates between two adjacent window-metric keys across -// getBoundingClientRect passes. Before the fix every key transition reset the -// budget, so all four passes scheduled a re-render. -for (const key of ['A','B','A','B']) { - _scheduleMessageVirtualMeasurementRefresh({ key: key }); +// Drive the burst causally: each internally scheduled render re-measures and +// re-enters the scheduler with the next WebKit-oscillated key. +let keys = ['B','A','B','A']; +let idx = 0; +function _scheduleMessageVirtualizedRender(force){ + scheduled++; + if(idxB->A->B. _scheduleMessageVirtualMeasurementRefresh({ key: 'A' }); console.log(JSON.stringify({ scheduled: scheduled, retries: _messageVirtualMeasurementRetryCount, + burstActive: _messageVirtualMeasurementBurstActive, })); """ metrics = json.loads(_run_node(source)) - assert metrics["scheduled"] == 3, ( - "stable-key burst must stop at MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS " - f"and resume only after a budget reset: scheduled {metrics['scheduled']} " - "(expected 3: 2 capped + 1 after explicit reset)" + assert metrics["scheduled"] == 2, ( + "cycle-key oscillation must not reset the retry budget within a burst: " + f"scheduled {metrics['scheduled']} re-renders (expected 2, the " + "MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS cap for the whole burst)" ) - assert metrics["retries"] == 1, ( - f"retry count after explicit reset must be 1, got {metrics['retries']}" + assert metrics["retries"] == 2, ( + "retry count must keep accumulating across key changes within the burst: " + f"ended at {metrics['retries']} (expected 2)" + ) + assert metrics["burstActive"] is False, ( + "the burst must end once the budget is exhausted so the next externally " + f"initiated cycle can reset it: burstActive {metrics['burstActive']}" + ) + + +def test_external_measurement_after_thrash_gets_fresh_retry_budget(): + """#6717 re-gate: exhausting the budget with the WebKit A/B thrash must NOT + starve a genuinely new externally initiated measurement cycle. Drive the + A/B oscillation causally until the per-burst budget is exhausted, then start + an independent changed-C measurement WITHOUT touching private state: + (a) C must still receive its own retries, and (b) C's own internal + oscillation must remain capped at MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS.""" + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + """ +let scheduled = 0; +let _messageVirtualMeasurementCycleKey = ''; +let _messageVirtualMeasurementRetryCount = 0; +let _messageVirtualMeasurementBurstActive = false; +let _messageVirtualScrollActive = false; +let _messageVirtualDeferredMeasurement = null; +const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS = 2; +function _messageVirtualMeasurementCycleKeyFor(w) { return w.key; } +let keys = []; +let idx = 0; +function _scheduleMessageVirtualizedRender(force){ + scheduled++; + if(idx=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return;" in UI_JS + assert "if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS){" in UI_JS assert "_scheduleMessageVirtualMeasurementRefresh(virtualWindow);" in UI_JS assert "_markMessageVirtualMeasurementsSettled(virtualWindow);" in UI_JS def test_measurement_retry_budget_not_reset_by_cycle_key_change(): - """#6654: the cycle-key branch of _scheduleMessageVirtualMeasurementRefresh + """#6654/#6717: the cycle-key branch of _scheduleMessageVirtualMeasurementRefresh must record the new key but never reset _messageVirtualMeasurementRetryCount. Resetting on key change lets WebKit's A->B->A->B metric oscillation renew - the two-render budget forever, keeping the rAF/measure loop alive.""" + the two-render budget forever, keeping the rAF/measure loop alive. The ONLY + reset allowed inside the scheduler is the per-burst one: when a NEW + externally initiated cycle begins (no burst active), guarded by + if(!_messageVirtualMeasurementBurstActive).""" idx = UI_JS.index("function _scheduleMessageVirtualMeasurementRefresh(windowMetrics)") end = UI_JS.index("function _markMessageVirtualMeasurementsSettled", idx) body = UI_JS[idx:end] # The key-change branch must only assign the key. assert "if(_messageVirtualMeasurementCycleKey!==cycleKey){" in body - assert "_messageVirtualMeasurementRetryCount=0;" not in body, ( + key_branch_start = body.index("if(_messageVirtualMeasurementCycleKey!==cycleKey){") + key_branch_end = body.index("}", key_branch_start) + key_branch = body[key_branch_start:key_branch_end] + assert "_messageVirtualMeasurementRetryCount=0;" not in key_branch, ( "cycle-key change must not reset the retry budget (issue #6654)" ) + # The per-burst reset is allowed only at the start of a new external cycle. + assert "if(!_messageVirtualMeasurementBurstActive){" in body + guard_pos = body.index("if(!_messageVirtualMeasurementBurstActive){") + reset_pos = body.index("_messageVirtualMeasurementRetryCount=0;") + assert guard_pos < reset_pos, ( + "the budget reset must be guarded by the burst-active check " + "(per-burst lifecycle, issue #6717)" + ) # The budget guard and increment must still be present. - assert "if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS) return;" in body + assert "if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS){" in body assert "_messageVirtualMeasurementRetryCount++;" in body From e5742d5dda3632d45d7be103135268b73e844509 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Sat, 8 Aug 2026 09:49:26 -0300 Subject: [PATCH 004/115] fix(perf): cycle-aware measurement burst tracking (#6654) Replace the flat 2-render burst cap with cycle-aware tracking: unseen monotonic window keys proceed (genuine convergence A->B->C->settled is never capped), while repeated oscillating keys (A->B->A->B) terminate the burst so the rAF/measure loop stays bounded. Thread an internal-measurement provenance marker through both rAF layers into renderMessages() and reset burst state on every unmarked (externally initiated) render trigger, so a pending internal callback can never starve a legitimate new cycle. --- static/ui.js | 86 +++-- ...st_issue500_message_list_virtualization.py | 329 ++++++++++++------ tests/test_issue734_message_windowing.py | 44 ++- tests/test_process_wakeup_rendering.py | 2 +- 4 files changed, 319 insertions(+), 142 deletions(-) diff --git a/static/ui.js b/static/ui.js index ee42cfbc..3e7a6b70 100644 --- a/static/ui.js +++ b/static/ui.js @@ -536,7 +536,14 @@ function _messageVirtualDefaultHeightForRole(role){ role&&Object.prototype.hasOwnProperty.call(MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS,role)?role:'default' ]; } -const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS=2; +// Cycle-aware measurement burst tracking (#6654/#6717): instead of a flat +// render cap, the burst remembers every window cycle key it has already seen. +// An UNSEEN key means the window is still converging forward (A->B->C->settled +// — content reflow, late fonts/images, dynamic height) and may proceed; a key +// that REPEATS means the browser is oscillating (A->B->A->B) and the burst +// terminates. Keys repeat only when geometry flaps, so legitimate convergence +// is never capped while oscillation is always bounded. +let _messageVirtualMeasurementSeenKeys=[]; let _messageRenderWindowSid=null; let _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT; let _messageVirtualHeightCache=[]; @@ -547,8 +554,13 @@ let _messageVirtualEstimatedRowHeight=_messageVirtualDefaultHeightForRole('defau let _messageVirtualScrollRaf=0; let _messageVirtualWindowKey=''; let _messageVirtualMeasurementCycleKey=''; -let _messageVirtualMeasurementRetryCount=0; let _messageVirtualMeasurementBurstActive=false; +// Provenance marker: true while an internally measurement-scheduled render is +// queued in the rAF layers. renderMessages() resets the burst on every +// UNMARKED (externally initiated) render trigger, so a message append / +// scroll-settle / other overlapping external render always starts a fresh +// cycle even when an internal measurement callback is still pending. +let _messageVirtualMeasurementRenderPending=false; let _messageVirtualScrollActive=false; let _messageVirtualScrollSettleTimer=0; let _messageVirtualDeferredMeasurement=null; @@ -595,8 +607,7 @@ function _clearMessageVirtualHeightCache(){ _messageVirtualEstimatedRowHeight=_messageVirtualDefaultHeightForRole('default'); _messageVirtualWindowKey=''; _messageVirtualMeasurementCycleKey=''; - _messageVirtualMeasurementRetryCount=0; - _messageVirtualMeasurementBurstActive=false; + _resetMessageVirtualMeasurementBurst(); _messageVirtualScrollActive=false; clearTimeout(_messageVirtualScrollSettleTimer); _messageVirtualScrollSettleTimer=0; @@ -721,6 +732,11 @@ function _messageVirtualMeasurementCycleKeyFor(windowMetrics){ windowMetrics.tailStart||0, ].join(':'); } +function _resetMessageVirtualMeasurementBurst(){ + _messageVirtualMeasurementSeenKeys=[]; + _messageVirtualMeasurementBurstActive=false; + _messageVirtualMeasurementRenderPending=false; +} function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){ if(_messageVirtualScrollActive){ _messageVirtualDeferredMeasurement=windowMetrics; @@ -730,32 +746,39 @@ function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){ if(_messageVirtualMeasurementCycleKey!==cycleKey){ _messageVirtualMeasurementCycleKey=cycleKey; } - // Per-burst retry budget (#6717): the counter is preserved across ONLY the + // Cycle-aware burst tracking (#6717 re-gate): the burst follows ONLY the // internally measurement-scheduled chain (requestAnimationFrame -> - // _scheduleMessageVirtualizedRender -> renderMessages -> re-measure -> here), - // so WebKit's A/B window-metric oscillation can never renew the budget and - // keep the rAF/measure loop alive (#6654). It is reset exactly once when a - // later EXTERNALLY initiated render begins a genuinely new measurement cycle - // (session load, message append, real content change) — never on cycle-key - // changes. A cycle-key change alone just records the new key above. + // _scheduleMessageVirtualizedRender -> renderMessages -> re-measure -> here). + // Within one burst we remember every cycle key already seen. An UNSEEN key + // (genuine forward convergence A->B->C->settled — content reflow, late + // fonts/images, dynamic height) may proceed, so convergence is never capped + // at a flat render count; a key that REPEATS (WebKit's A->B->A->B window + // oscillation) ends the burst, so the rAF/measure loop can never run forever + // (#6654). The burst starts fresh on every EXTERNALLY initiated render + // (session load, message append, real content change — see renderMessages) + // and on measurement settlement — never on a cycle-key change alone. if(!_messageVirtualMeasurementBurstActive){ - _messageVirtualMeasurementRetryCount=0; + _messageVirtualMeasurementSeenKeys=[]; _messageVirtualMeasurementBurstActive=true; } - if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS){ - // Budget exhausted: this burst is over (no further internal re-render is - // scheduled), so the next externally initiated cycle starts fresh instead - // of being starved of its own retries. - _messageVirtualMeasurementBurstActive=false; + if(_messageVirtualMeasurementSeenKeys.includes(cycleKey)){ + // Repeated key: the window is oscillating, not converging. End the burst + // (no further internal re-render is scheduled), so the next externally + // initiated cycle starts fresh instead of being starved of retries. + _resetMessageVirtualMeasurementBurst(); return; } - _messageVirtualMeasurementRetryCount++; + _messageVirtualMeasurementSeenKeys.push(cycleKey); + // Provenance marker for the internal measurement chain: the render we are + // scheduling must reach renderMessages() marked as internal (threaded + // through BOTH rAF layers via _scheduleMessageVirtualizedRender) so it + // preserves the burst instead of resetting it. + _messageVirtualMeasurementRenderPending=true; requestAnimationFrame(()=>{ _scheduleMessageVirtualizedRender(true); }); } function _markMessageVirtualMeasurementsSettled(windowMetrics){ _messageVirtualMeasurementCycleKey=_messageVirtualMeasurementCycleKeyFor(windowMetrics); - _messageVirtualMeasurementRetryCount=0; - _messageVirtualMeasurementBurstActive=false; + _resetMessageVirtualMeasurementBurst(); } function _messageVirtualHeightEntryMatches(previousEntry, nextEntry){ return !!( @@ -1486,6 +1509,13 @@ function _scheduleMessageVirtualizedRender(force){ if(_messageVirtualScrollRaf) return; _messageVirtualScrollRaf=requestAnimationFrame(()=>{ _messageVirtualScrollRaf=0; + // Consume the internal-measurement provenance marker (set by + // _scheduleMessageVirtualMeasurementRefresh before the first rAF layer and + // threaded through it into this second rAF layer). If an EXTERNAL render + // reset the burst meanwhile, this callback degrades to an unmarked render + // and starts a fresh measurement cycle instead of continuing a stale one. + const internalMeasurement=_messageVirtualMeasurementRenderPending; + _messageVirtualMeasurementRenderPending=false; const liveVisWithIdx=_getVisibleMessagesWithIdx(); const liveWindow=_currentMessageVirtualWindow(liveVisWithIdx,_messageVirtualKeepTailCount()); const liveKey=_messageVirtualWindowKeyFor(liveWindow); @@ -1493,14 +1523,14 @@ function _scheduleMessageVirtualizedRender(force){ if(_scrollbarDragActive){ _programmaticScroll=true; _programmaticScrollSetAt=performance.now(); - _compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true }); }); + _compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true, _internalMeasurement: internalMeasurement }); }); _deferClearProgrammaticScroll(); _messageVirtualWindowKey=liveKey; return; } _msgNodeRecycleEnabled=true; try{ - _compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true }); }); + _compensateScrollForMeasurementDelta(()=>{ renderMessages({ preserveScroll:true, _internalMeasurement: internalMeasurement }); }); } finally{ _msgNodeRecycleEnabled=false; } }); @@ -16565,6 +16595,18 @@ function _processWakeupCardHtml(info, rawText, extras){ function renderMessages(options){ _lastMessageRenderAt=performance.now(); + // Measurement-burst lifecycle (#6654/#6717): every UNMARKED (externally + // initiated) render — session load, message append, scroll-settle, genuine + // content change — starts a fresh measurement burst HERE, before any rAF + // coalescing or early return, so a pending internal measurement callback can + // never carry a stale burst into the new cycle (external provenance + // overrides a pending internal callback). Only the internally + // measurement-scheduled chain (options._internalMeasurement, threaded through + // both rAF layers by _scheduleMessageVirtualMeasurementRefresh and + // _scheduleMessageVirtualizedRender) preserves the burst across renders. + if(!(options&&options._internalMeasurement)){ + _resetMessageVirtualMeasurementBurst(); + } const preserveScroll=!!(options&&options.preserveScroll); const virtualFallback=!!(options&&options._virtualFallback); // Capture the pre-wipe scroll position when preserving OR when the reader has diff --git a/tests/test_issue500_message_list_virtualization.py b/tests/test_issue500_message_list_virtualization.py index 994d2951..06c26b80 100644 --- a/tests/test_issue500_message_list_virtualization.py +++ b/tests/test_issue500_message_list_virtualization.py @@ -1111,8 +1111,9 @@ let _messageVirtualHeightCacheSrc = {}; let _messageVirtualEstimatedRowHeight = 200; let _messageVirtualWindowKey = 'old-key'; let _messageVirtualMeasurementCycleKey = 'old-cycle'; -let _messageVirtualMeasurementRetryCount = 3; -let _messageVirtualMeasurementBurstActive = false; +let _messageVirtualMeasurementSeenKeys = ['stale-key']; +let _messageVirtualMeasurementBurstActive = true; +let _messageVirtualMeasurementRenderPending = true; const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140; // #4367 introduced per-role default heights; the combined _clearMessageVirtualHeightCache // resets the estimate via _messageVirtualDefaultHeightForRole('default'), so the harness @@ -1120,6 +1121,7 @@ const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140; const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS = {user:120, assistant:160, tool_call:400, default:140}; function clearTimeout(id){ timerCleared = (id === 99); } eval(extractFunc('_messageVirtualDefaultHeightForRole')); +eval(extractFunc('_resetMessageVirtualMeasurementBurst')); eval(extractFunc('_clearMessageVirtualHeightCache')); _clearMessageVirtualHeightCache(); console.log(JSON.stringify({ @@ -1127,6 +1129,9 @@ console.log(JSON.stringify({ settleTimer: _messageVirtualScrollSettleTimer, deferred: _messageVirtualDeferredMeasurement, timerCleared: timerCleared, + seenKeys: _messageVirtualMeasurementSeenKeys, + burstActive: _messageVirtualMeasurementBurstActive, + renderPending: _messageVirtualMeasurementRenderPending, })); """ metrics = json.loads(_run_node(source)) @@ -1142,121 +1147,245 @@ console.log(JSON.stringify({ assert metrics["timerCleared"] is True, ( "_clearMessageVirtualHeightCache must call clearTimeout on the pending settle timer" ) - - -def test_measurement_retry_budget_is_global_across_cycle_key_oscillation(): - """#6654: _scheduleMessageVirtualMeasurementRefresh must NOT reset - _messageVirtualMeasurementRetryCount when _messageVirtualMeasurementCycleKey - changes INSIDE one measurement burst. If WebKit alternates between two - measured window-metric sets (A -> B -> A -> B), each key transition used to - reset the two-render budget, so the rAF/geometry-measurement loop never - terminated (~30% WebContent CPU). The MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS - budget must cap the whole convergence burst; the burst ends at the cap (or - when measurement settles), so the NEXT externally initiated cycle gets a - fresh budget. The harness drives the thrash causally through the internal - chain (each scheduled render re-measures the next oscillating key).""" - js = UI_JS_PATH.read_text(encoding="utf-8") - source = _extract_func_script(js) + """ -let scheduled = 0; -let _messageVirtualMeasurementCycleKey = ''; -let _messageVirtualMeasurementRetryCount = 0; -let _messageVirtualMeasurementBurstActive = false; -let _messageVirtualScrollActive = false; -let _messageVirtualDeferredMeasurement = null; -const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS = 2; -function _messageVirtualMeasurementCycleKeyFor(w) { return w.key; } -// Drive the burst causally: each internally scheduled render re-measures and -// re-enters the scheduler with the next WebKit-oscillated key. -let keys = ['B','A','B','A']; -let idx = 0; -function _scheduleMessageVirtualizedRender(force){ - scheduled++; - if(idxB->A->B. -_scheduleMessageVirtualMeasurementRefresh({ key: 'A' }); -console.log(JSON.stringify({ - scheduled: scheduled, - retries: _messageVirtualMeasurementRetryCount, - burstActive: _messageVirtualMeasurementBurstActive, -})); -""" - metrics = json.loads(_run_node(source)) - assert metrics["scheduled"] == 2, ( - "cycle-key oscillation must not reset the retry budget within a burst: " - f"scheduled {metrics['scheduled']} re-renders (expected 2, the " - "MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS cap for the whole burst)" - ) - assert metrics["retries"] == 2, ( - "retry count must keep accumulating across key changes within the burst: " - f"ended at {metrics['retries']} (expected 2)" + assert metrics["seenKeys"] == [], ( + "_clearMessageVirtualHeightCache must clear the measurement-burst seen-key " + f"memory: seenKeys {metrics['seenKeys']}" ) assert metrics["burstActive"] is False, ( - "the burst must end once the budget is exhausted so the next externally " - f"initiated cycle can reset it: burstActive {metrics['burstActive']}" + "_clearMessageVirtualHeightCache must end any active measurement burst: " + f"burstActive {metrics['burstActive']}" + ) + assert metrics["renderPending"] is False, ( + "_clearMessageVirtualHeightCache must clear the internal-measurement " + f"provenance marker: renderPending {metrics['renderPending']}" ) -def test_external_measurement_after_thrash_gets_fresh_retry_budget(): - """#6717 re-gate: exhausting the budget with the WebKit A/B thrash must NOT - starve a genuinely new externally initiated measurement cycle. Drive the - A/B oscillation causally until the per-burst budget is exhausted, then start - an independent changed-C measurement WITHOUT touching private state: - (a) C must still receive its own retries, and (b) C's own internal - oscillation must remain capped at MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS.""" +def _measurement_burst_harness(scenario_js: str) -> str: + """Shared queued-rAF harness for the measurement-burst chain (#6654/#6717). + + Drives the REAL production chain: the cycle-aware scheduler queues a first + rAF, _scheduleMessageVirtualizedRender queues a second rAF (the + internal-measurement provenance marker is threaded through BOTH layers), + and the renderMessages stub applies the production burst-reset contract for + every unmarked (external) render, then simulates the measure pass from the + caller-provided plan (measurePlan). Unlike the earlier synchronous-rAF + harnesses, external renders can land BETWEEN queued rAF callbacks — exactly + where the overlap/convergence regressions lived. + """ js = UI_JS_PATH.read_text(encoding="utf-8") - source = _extract_func_script(js) + """ -let scheduled = 0; + return _extract_func_script(js) + """ +// ── queued-rAF harness ──────────────────────────────────────────────────────── +let renderLog = []; // every renderMessages call: {internal, preserveScroll} +let rafQueue = []; +function requestAnimationFrame(cb){ rafQueue.push(cb); return rafQueue.length; } +function flushRaf(){ const q = rafQueue; rafQueue = []; for (const cb of q) cb(); } +let _messageVirtualScrollRaf = 0; +let _messageVirtualWindowKey = ''; let _messageVirtualMeasurementCycleKey = ''; -let _messageVirtualMeasurementRetryCount = 0; +let _messageVirtualMeasurementSeenKeys = []; let _messageVirtualMeasurementBurstActive = false; +let _messageVirtualMeasurementRenderPending = false; let _messageVirtualScrollActive = false; let _messageVirtualDeferredMeasurement = null; -const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS = 2; -function _messageVirtualMeasurementCycleKeyFor(w) { return w.key; } -let keys = []; -let idx = 0; -function _scheduleMessageVirtualizedRender(force){ - scheduled++; - if(idxB->A->B) + must terminate the measurement burst even though every key was unseen on + first sight — the burst tracks SEEN keys, so geometry that flaps between + two states can never renew the chain and keep the rAF/measure loop alive + (~30% WebContent CPU, issue #6654). The queued-rAF harness drives the + oscillation through the real two-rAF-layer chain.""" + source = _measurement_burst_harness(""" +measurePlan = ['B','A','B','A','B','A']; +renderMessages({}); // external trigger: measures B -> burst starts +flushRaf(); flushRaf(); // layer-1 then layer-2 rAF -> internal render (A) +flushRaf(); flushRaf(); // next cycle -> internal render (B repeats) -> burst ends +flushRaf(); flushRaf(); // nothing left queued console.log(JSON.stringify({ - afterThrash: afterThrash, - scheduled: scheduled, - retries: _messageVirtualMeasurementRetryCount, + renders: renderLog.length, + internal: renderLog.filter(r => r.internal).length, burstActive: _messageVirtualMeasurementBurstActive, + seenKeys: _messageVirtualMeasurementSeenKeys, + renderPending: _messageVirtualMeasurementRenderPending, })); -""" +""") metrics = json.loads(_run_node(source)) - assert metrics["afterThrash"] == 2, ( - "the A/B thrash burst must stop at MESSAGE_VIRTUAL_MEASUREMENT_" - f"MAX_RERENDERS: scheduled {metrics['afterThrash']} (expected 2)" + assert metrics["internal"] == 2, ( + "A->B->A->B oscillation must stay bounded: a repeated key ends the burst. " + f"scheduled {metrics['internal']} internal re-renders (expected 2, then termination)" ) - assert metrics["scheduled"] == 4, ( - "a genuinely new external measurement cycle C must still receive its " - "own retries after the thrash exhausted the budget: " - f"scheduled {metrics['scheduled']} (expected 4 = 2 thrash + 2 for C)" + assert metrics["renders"] == 3, ( + "oscillation must not keep scheduling renders: " + f"{metrics['renders']} renders (expected 3 = 1 external + 2 internal)" ) - assert metrics["retries"] == 2, ( - "C's own internal oscillation must remain capped at " - f"MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS: retries " - f"{metrics['retries']} (expected 2)" + assert metrics["burstActive"] is False, ( + "the burst must end once the repeated key is detected so the next " + f"externally initiated cycle starts fresh: burstActive {metrics['burstActive']}" + ) + assert metrics["seenKeys"] == [], ( + "termination must clear the seen-key memory: " + f"seenKeys {metrics['seenKeys']}" + ) + assert metrics["renderPending"] is False, ( + "no internal render may remain pending after termination: " + f"renderPending {metrics['renderPending']}" + ) + + +def test_genuine_convergence_exceeding_two_renders_completes(): + """#6717 re-gate (b): a legitimate forward convergence A->B->C->settled + (content reflow, fonts/images loading late, dynamic height) must be allowed + MORE than two measurement passes. The old flat two-render burst cap left + the geometry mis-measured (wrong row heights / clipped content); cycle-aware + tracking lets every UNSEEN key proceed until the measurements settle.""" + source = _measurement_burst_harness(""" +measurePlan = ['B','C','D']; // three distinct forward keys, then settled +renderMessages({}); // external trigger: measures B +flushRaf(); flushRaf(); // internal render: measures C +flushRaf(); flushRaf(); // internal render: measures D +flushRaf(); flushRaf(); // internal render: measurements settle +console.log(JSON.stringify({ + renders: renderLog.length, + internal: renderLog.filter(r => r.internal).length, + lastInternal: renderLog[renderLog.length - 1].internal, + burstActive: _messageVirtualMeasurementBurstActive, + seenKeys: _messageVirtualMeasurementSeenKeys, + renderPending: _messageVirtualMeasurementRenderPending, +})); +""") + metrics = json.loads(_run_node(source)) + assert metrics["internal"] == 3, ( + "genuine A->B->C->settled convergence must complete with MORE than the " + "old two-render cap: " + f"{metrics['internal']} internal re-renders (expected 3)" + ) + assert metrics["renders"] == 4, ( + "the convergence chain must run to completion: " + f"{metrics['renders']} renders (expected 4 = 1 external + 3 internal)" + ) + assert metrics["lastInternal"] is True, ( + "the final render of the convergence chain must still be part of the " + "internal measurement chain (settled via _markMessageVirtualMeasurementsSettled)" + ) + assert metrics["burstActive"] is False, ( + "settlement must end the burst: " + f"burstActive {metrics['burstActive']}" + ) + assert metrics["seenKeys"] == [] and metrics["renderPending"] is False, ( + "settlement must clear the seen-key memory and any pending marker: " + f"seenKeys {metrics['seenKeys']}, renderPending {metrics['renderPending']}" + ) + + +def test_overlapping_external_append_receives_fresh_budget(): + """#6717 re-gate (a): an externally initiated render (message append, + scroll-settle, genuine content change) that lands while an internal + measurement callback is still PENDING in the rAF queue must reset the burst + — external provenance overrides a pending internal callback — so the new + cycle receives a fresh budget instead of being starved by the stale burst's + seen-key memory (0 retries). + + Phase 1: the append lands between the two rAF layers and measures key B + (already seen in the stale burst). Only the external reset lets B proceed; + without it the repeated key would terminate the append's cycle immediately. + Phase 2: an external render that measures SETTLED lands while a layer-2 + callback is pending; the stale callback must then degrade to an UNMARKED + render (the external reset consumed its provenance), and the fresh cycle it + starts must complete with its own internal render.""" + source = _measurement_burst_harness(""" +measurePlan = ['B','B','C', undefined, 'E', undefined, 'F', undefined]; +renderMessages({}); // R0 external: burst starts, seen=[B], L1 queued +flushRaf(); // L1 fires -> L2 (internal callback) now pending +renderMessages({}); // R1 EXTERNAL APPEND lands before L2 fires: + // resets the burst -> fresh cycle, seen=[B] again +flushRaf(); flushRaf(); // stale L2 joins the fresh cycle (R2), then R3 settles +renderMessages({}); // R4 external: new cycle, L1 queued +flushRaf(); // L1 fires -> L2 (internal callback) now pending +renderMessages({}); // R5 external scroll-settle: resets the burst, + // measures SETTLED (no new schedule) +flushRaf(); // stale L2 fires -> marker consumed as FALSE -> + // R6 UNMARKED render (external provenance won) +flushRaf(); flushRaf(); // R6's fresh cycle completes (R7 internal) -> settles +console.log(JSON.stringify({ + renders: renderLog.length, + internal: renderLog.filter(r => r.internal).length, + appendExternal: renderLog[1].internal, + appendCycleInternal: renderLog[2].internal && renderLog[3].internal, + staleCallbackUnmarked: renderLog[6].internal, + postResetCycleInternal: renderLog[7].internal, + burstActive: _messageVirtualMeasurementBurstActive, + seenKeys: _messageVirtualMeasurementSeenKeys, + renderPending: _messageVirtualMeasurementRenderPending, +})); +""") + metrics = json.loads(_run_node(source)) + assert metrics["renders"] == 8, ( + "the overlapping append must NOT starve the new cycle: the chain must " + "continue past the append to completion " + f"({metrics['renders']} renders; expected 8)" + ) + assert metrics["appendExternal"] is False, ( + "the append is an unmarked external render" + ) + assert metrics["appendCycleInternal"] is True, ( + "the append's cycle must proceed with its own internal renders " + "(fresh budget — the repeated key B was not treated as oscillation " + "because the external render reset the burst)" + ) + assert metrics["staleCallbackUnmarked"] is False, ( + "external provenance must override the pending internal callback: the " + "stale internal rAF callback must degrade to an unmarked render after " + "the external render reset the burst" + ) + assert metrics["postResetCycleInternal"] is True, ( + "the cycle started by the degraded stale callback must complete with " + "its own internal render (retries were not starved)" + ) + assert metrics["burstActive"] is False, ( + "the fresh cycles must end settled: " + f"burstActive {metrics['burstActive']}" + ) + assert metrics["seenKeys"] == [] and metrics["renderPending"] is False, ( + "settlement must clear the seen-key memory and any pending marker: " + f"seenKeys {metrics['seenKeys']}, renderPending {metrics['renderPending']}" ) diff --git a/tests/test_issue734_message_windowing.py b/tests/test_issue734_message_windowing.py index 53f6d6d9..88218cde 100644 --- a/tests/test_issue734_message_windowing.py +++ b/tests/test_issue734_message_windowing.py @@ -52,23 +52,25 @@ def test_virtualization_affordances_have_styling_hooks(): assert "border-radius:999px" in CSS -def test_measurement_rerenders_are_bounded_per_virtual_window_cycle(): - assert "const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS=2;" in UI_JS +def test_measurement_rerenders_are_cycle_aware_per_virtual_window_burst(): assert "function _messageVirtualMeasurementCycleKeyFor(windowMetrics)" in UI_JS assert "function _scheduleMessageVirtualMeasurementRefresh(windowMetrics)" in UI_JS - assert "if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS){" in UI_JS assert "_scheduleMessageVirtualMeasurementRefresh(virtualWindow);" in UI_JS assert "_markMessageVirtualMeasurementsSettled(virtualWindow);" in UI_JS + # Cycle-aware burst tracking: unseen keys proceed, repeated keys terminate, + # and the seen-key memory lives in _messageVirtualMeasurementSeenKeys. + assert "_messageVirtualMeasurementSeenKeys.includes(cycleKey)" in UI_JS + assert "_messageVirtualMeasurementSeenKeys.push(cycleKey)" in UI_JS + assert "function _resetMessageVirtualMeasurementBurst()" in UI_JS -def test_measurement_retry_budget_not_reset_by_cycle_key_change(): +def test_measurement_burst_not_reset_by_cycle_key_change(): """#6654/#6717: the cycle-key branch of _scheduleMessageVirtualMeasurementRefresh - must record the new key but never reset _messageVirtualMeasurementRetryCount. - Resetting on key change lets WebKit's A->B->A->B metric oscillation renew - the two-render budget forever, keeping the rAF/measure loop alive. The ONLY - reset allowed inside the scheduler is the per-burst one: when a NEW - externally initiated cycle begins (no burst active), guarded by - if(!_messageVirtualMeasurementBurstActive).""" + must record the new key but never reset the burst state. Resetting on key + change lets WebKit's A->B->A->B metric oscillation renew the chain forever, + keeping the rAF/measure loop alive. The ONLY resets allowed inside the + scheduler are: the repeated-key termination (oscillation) and the fresh-burst + start (when no burst is active), guarded by if(!_messageVirtualMeasurementBurstActive).""" idx = UI_JS.index("function _scheduleMessageVirtualMeasurementRefresh(windowMetrics)") end = UI_JS.index("function _markMessageVirtualMeasurementsSettled", idx) body = UI_JS[idx:end] @@ -77,17 +79,21 @@ def test_measurement_retry_budget_not_reset_by_cycle_key_change(): key_branch_start = body.index("if(_messageVirtualMeasurementCycleKey!==cycleKey){") key_branch_end = body.index("}", key_branch_start) key_branch = body[key_branch_start:key_branch_end] - assert "_messageVirtualMeasurementRetryCount=0;" not in key_branch, ( - "cycle-key change must not reset the retry budget (issue #6654)" + assert "_messageVirtualMeasurementSeenKeys" not in key_branch, ( + "cycle-key change must not reset the burst's seen-key memory (issue #6654)" ) - # The per-burst reset is allowed only at the start of a new external cycle. + assert "_messageVirtualMeasurementBurstActive=false" not in key_branch, ( + "cycle-key change must not end the burst (issue #6654)" + ) + # The fresh-burst reset is allowed only at the start of a new external cycle. assert "if(!_messageVirtualMeasurementBurstActive){" in body guard_pos = body.index("if(!_messageVirtualMeasurementBurstActive){") - reset_pos = body.index("_messageVirtualMeasurementRetryCount=0;") - assert guard_pos < reset_pos, ( - "the budget reset must be guarded by the burst-active check " + seen_reset_pos = body.index("_messageVirtualMeasurementSeenKeys=[];") + assert guard_pos < seen_reset_pos, ( + "the seen-key reset must be guarded by the burst-active check " "(per-burst lifecycle, issue #6717)" ) - # The budget guard and increment must still be present. - assert "if(_messageVirtualMeasurementRetryCount>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS){" in body - assert "_messageVirtualMeasurementRetryCount++;" in body + # Repeated keys terminate the burst; unseen keys proceed. + assert "if(_messageVirtualMeasurementSeenKeys.includes(cycleKey)){" in body + assert "_messageVirtualMeasurementSeenKeys.push(cycleKey);" in body + assert "_messageVirtualMeasurementRenderPending=true;" in body diff --git a/tests/test_process_wakeup_rendering.py b/tests/test_process_wakeup_rendering.py index 25a42f3f..5894905e 100644 --- a/tests/test_process_wakeup_rendering.py +++ b/tests/test_process_wakeup_rendering.py @@ -70,7 +70,7 @@ let _visWithIdxCacheLen = 0; let _visWithIdxCacheSrc = null; const heightStart = src.indexOf('const MESSAGE_RENDER_WINDOW_DEFAULT'); -const heightEnd = src.indexOf('const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS', heightStart); +const heightEnd = src.indexOf('let _messageVirtualMeasurementSeenKeys', heightStart); if(heightStart !== -1 && heightEnd !== -1) eval(src.slice(heightStart, heightEnd)); if(src.indexOf('function _isProcessWakeupMessage') !== -1) eval(extractFunc('_isProcessWakeupMessage')); eval(extractFunc('_stripWorkspaceDisplayPrefix')); From b1ea6141b35b29e66d152328c3514e392bb269a8 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Sun, 9 Aug 2026 09:26:45 -0300 Subject: [PATCH 005/115] fix(perf): keep burst-reset before early returns within renderMessages guard window (#6654) The cycle-aware burst tracking re-gate reset the measurement burst at the top of renderMessages(), but the added block pushed the session-load loading-placeholder guard past the 1400-char window asserted by test_renderMessages_preserves_loading_placeholder_for_session_switch, and the standalone renderMessages harness in test_anchor_fallback_ownership.py lacked the new _resetMessageVirtualMeasurementBurst dependency. Compact the reset (identical behavior: every unmarked external render starts a fresh burst before any early return or rAF coalescing) and add the helper stub to the harness. CI test shard 2 is green again. --- static/ui.js | 13 +------------ tests/test_anchor_fallback_ownership.py | 1 + 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/static/ui.js b/static/ui.js index 3e7a6b70..73deb1ec 100644 --- a/static/ui.js +++ b/static/ui.js @@ -16595,18 +16595,7 @@ function _processWakeupCardHtml(info, rawText, extras){ function renderMessages(options){ _lastMessageRenderAt=performance.now(); - // Measurement-burst lifecycle (#6654/#6717): every UNMARKED (externally - // initiated) render — session load, message append, scroll-settle, genuine - // content change — starts a fresh measurement burst HERE, before any rAF - // coalescing or early return, so a pending internal measurement callback can - // never carry a stale burst into the new cycle (external provenance - // overrides a pending internal callback). Only the internally - // measurement-scheduled chain (options._internalMeasurement, threaded through - // both rAF layers by _scheduleMessageVirtualMeasurementRefresh and - // _scheduleMessageVirtualizedRender) preserves the burst across renders. - if(!(options&&options._internalMeasurement)){ - _resetMessageVirtualMeasurementBurst(); - } + if(!(options&&options._internalMeasurement)){ _resetMessageVirtualMeasurementBurst(); } const preserveScroll=!!(options&&options.preserveScroll); const virtualFallback=!!(options&&options._virtualFallback); // Capture the pre-wipe scroll position when preserving OR when the reader has diff --git a/tests/test_anchor_fallback_ownership.py b/tests/test_anchor_fallback_ownership.py index 6cc6d677..9e05fdde 100644 --- a/tests/test_anchor_fallback_ownership.py +++ b/tests/test_anchor_fallback_ownership.py @@ -675,6 +675,7 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds function _scrollAfterMessageRender() {{}} function _maybeRecoverVirtualizedBlankViewport() {{ return false; }} function _updateMessageVirtualMeasurements() {{}} + function _resetMessageVirtualMeasurementBurst() {{}} function postProcessRenderedMessages() {{}} function _postProcessWithAnchorSuppression() {{}} function _formatGatewayModelLabel() {{ return ''; }} From 05b6d5b472fbf90cb027316b59715abe8ccf46a7 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 19 Aug 2026 13:14:07 -0300 Subject: [PATCH 006/115] fix(perf): absolute burst cap + per-render provenance (#6717) --- static/ui.js | 85 ++++++-- ...st_issue500_message_list_virtualization.py | 195 +++++++++++++++--- tests/test_issue734_message_windowing.py | 9 +- 3 files changed, 241 insertions(+), 48 deletions(-) diff --git a/static/ui.js b/static/ui.js index 73deb1ec..19ee2f27 100644 --- a/static/ui.js +++ b/static/ui.js @@ -543,6 +543,16 @@ function _messageVirtualDefaultHeightForRole(role){ // that REPEATS means the browser is oscillating (A->B->A->B) and the burst // terminates. Keys repeat only when geometry flaps, so legitimate convergence // is never capped while oscillation is always bounded. +// Absolute per-burst ceiling (#6717 re-gate): the seen-key rule alone only +// ends the burst on a REPEATED key, so a browser emitting a monotonically +// changing geometry (A->B->C->D->... never repeating) would still loop without +// limit — the #6654 CPU-runaway class under a different trigger — and the +// seen-key collection would grow without bound (memory). A distinct-key +// convergence realistically settles in a handful of frames, so this +// conservative cap (the historical MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS +// was 2) ends the burst regardless of key novelty AND bounds the seen-key +// memory to the cap. Reset through _resetMessageVirtualMeasurementBurst(). +const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS=12; let _messageVirtualMeasurementSeenKeys=[]; let _messageRenderWindowSid=null; let _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT; @@ -555,12 +565,18 @@ let _messageVirtualScrollRaf=0; let _messageVirtualWindowKey=''; let _messageVirtualMeasurementCycleKey=''; let _messageVirtualMeasurementBurstActive=false; -// Provenance marker: true while an internally measurement-scheduled render is -// queued in the rAF layers. renderMessages() resets the burst on every -// UNMARKED (externally initiated) render trigger, so a message append / -// scroll-settle / other overlapping external render always starts a fresh -// cycle even when an internal measurement callback is still pending. -let _messageVirtualMeasurementRenderPending=false; +// Provenance of the QUEUED virtualized render: 'internal' while the pending +// rAF was requested by the internal measurement chain, 'external' for any +// other trigger (user scroll / scroll-settle / message append / session +// load). The origin lives on the QUEUED render itself, NOT in a global +// consumable flag: when an internal and an external request coalesce into one +// rAF, _scheduleMessageVirtualizedRender lets EXTERNAL win, so a render that +// fires after any external trigger is never mis-attributed as internal +// (#6717 re-gate). renderMessages() resets the burst on every UNMARKED +// (externally initiated) render trigger, so an overlapping external render +// always starts a fresh cycle even when an internal measurement callback is +// still pending. +let _messageVirtualRenderQueuedOrigin=null; let _messageVirtualScrollActive=false; let _messageVirtualScrollSettleTimer=0; let _messageVirtualDeferredMeasurement=null; @@ -735,7 +751,9 @@ function _messageVirtualMeasurementCycleKeyFor(windowMetrics){ function _resetMessageVirtualMeasurementBurst(){ _messageVirtualMeasurementSeenKeys=[]; _messageVirtualMeasurementBurstActive=false; - _messageVirtualMeasurementRenderPending=false; + // Strip any pending internal provenance: an external reset must never let + // an internal marker survive onto a render that fires later (#6717 re-gate). + _messageVirtualRenderQueuedOrigin=null; } function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){ if(_messageVirtualScrollActive){ @@ -761,6 +779,16 @@ function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){ _messageVirtualMeasurementSeenKeys=[]; _messageVirtualMeasurementBurstActive=true; } + // Absolute per-burst cap (#6717 re-gate): even an ALL-DISTINCT monotonic + // key sequence (A->B->C->D->... never repeating) must terminate — a repeated + // key is not the only way the burst can end. This also bounds the seen-key + // collection to the cap (memory). Distinct-key convergence settles in a + // handful of frames, so the cap is far above any legitimate multi-pass + // reflow while still closing the #6654 CPU-runaway class. + if(_messageVirtualMeasurementSeenKeys.length>=MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS){ + _resetMessageVirtualMeasurementBurst(); + return; + } if(_messageVirtualMeasurementSeenKeys.includes(cycleKey)){ // Repeated key: the window is oscillating, not converging. End the burst // (no further internal re-render is scheduled), so the next externally @@ -769,12 +797,13 @@ function _scheduleMessageVirtualMeasurementRefresh(windowMetrics){ return; } _messageVirtualMeasurementSeenKeys.push(cycleKey); - // Provenance marker for the internal measurement chain: the render we are - // scheduling must reach renderMessages() marked as internal (threaded - // through BOTH rAF layers via _scheduleMessageVirtualizedRender) so it - // preserves the burst instead of resetting it. - _messageVirtualMeasurementRenderPending=true; - requestAnimationFrame(()=>{ _scheduleMessageVirtualizedRender(true); }); + // The internal-measurement origin travels WITH the scheduled render request + // (threaded through BOTH rAF layers into renderMessages), so coalescing can + // never consume a stale global marker onto an unrelated render. When an + // external request coalesces into the queued rAF, EXTERNAL wins and the + // render that fires degrades to an unmarked (burst-resetting) render + // (#6717 re-gate). + requestAnimationFrame(()=>{ _scheduleMessageVirtualizedRender(true,{origin:'internal'}); }); } function _markMessageVirtualMeasurementsSettled(windowMetrics){ _messageVirtualMeasurementCycleKey=_messageVirtualMeasurementCycleKeyFor(windowMetrics); @@ -1494,7 +1523,7 @@ function _rememberRenderedUserRowIntrinsicHeights(){ } } } -function _scheduleMessageVirtualizedRender(force){ +function _scheduleMessageVirtualizedRender(force, request){ const container=$('messages'); const inner=$('msgInner'); if(!container||!inner) return; @@ -1506,16 +1535,28 @@ function _scheduleMessageVirtualizedRender(force){ _messageVirtualWindowKey=nextKey; return; } - if(_messageVirtualScrollRaf) return; + // The request carries its own origin: the internal measurement chain passes + // {origin:'internal'} (see _scheduleMessageVirtualMeasurementRefresh); + // every other caller is external by default. + const requestOrigin=(request&&request.origin==='internal')?'internal':'external'; + if(_messageVirtualScrollRaf){ + // Coalescing into an already-queued rAF: the queued render is shared, so + // merge the provenance. EXTERNAL always wins — the render that actually + // fires must reset the burst, and an internal marker must never survive + // onto an external render (#6717 re-gate). An internal request joining a + // queued render never downgrades an already-external one. + if(requestOrigin==='external') _messageVirtualRenderQueuedOrigin='external'; + return; + } + _messageVirtualRenderQueuedOrigin=requestOrigin; _messageVirtualScrollRaf=requestAnimationFrame(()=>{ _messageVirtualScrollRaf=0; - // Consume the internal-measurement provenance marker (set by - // _scheduleMessageVirtualMeasurementRefresh before the first rAF layer and - // threaded through it into this second rAF layer). If an EXTERNAL render - // reset the burst meanwhile, this callback degrades to an unmarked render - // and starts a fresh measurement cycle instead of continuing a stale one. - const internalMeasurement=_messageVirtualMeasurementRenderPending; - _messageVirtualMeasurementRenderPending=false; + // The provenance belongs to THIS queued render: it was set when the render + // was scheduled (and possibly flipped to 'external' by a coalesced + // external request). Consume it here — no global consumable flag that an + // unrelated render could steal (#6717 re-gate). + const internalMeasurement=_messageVirtualRenderQueuedOrigin==='internal'; + _messageVirtualRenderQueuedOrigin=null; const liveVisWithIdx=_getVisibleMessagesWithIdx(); const liveWindow=_currentMessageVirtualWindow(liveVisWithIdx,_messageVirtualKeepTailCount()); const liveKey=_messageVirtualWindowKeyFor(liveWindow); diff --git a/tests/test_issue500_message_list_virtualization.py b/tests/test_issue500_message_list_virtualization.py index 06c26b80..8bb93b85 100644 --- a/tests/test_issue500_message_list_virtualization.py +++ b/tests/test_issue500_message_list_virtualization.py @@ -1,5 +1,6 @@ """Regression coverage for issue #500 transcript virtualization.""" import json +import re import shutil import subprocess import tempfile @@ -1113,7 +1114,7 @@ let _messageVirtualWindowKey = 'old-key'; let _messageVirtualMeasurementCycleKey = 'old-cycle'; let _messageVirtualMeasurementSeenKeys = ['stale-key']; let _messageVirtualMeasurementBurstActive = true; -let _messageVirtualMeasurementRenderPending = true; +let _messageVirtualRenderQueuedOrigin = 'internal'; const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140; // #4367 introduced per-role default heights; the combined _clearMessageVirtualHeightCache // resets the estimate via _messageVirtualDefaultHeightForRole('default'), so the harness @@ -1131,7 +1132,7 @@ console.log(JSON.stringify({ timerCleared: timerCleared, seenKeys: _messageVirtualMeasurementSeenKeys, burstActive: _messageVirtualMeasurementBurstActive, - renderPending: _messageVirtualMeasurementRenderPending, + queuedOrigin: _messageVirtualRenderQueuedOrigin, })); """ metrics = json.loads(_run_node(source)) @@ -1155,9 +1156,9 @@ console.log(JSON.stringify({ "_clearMessageVirtualHeightCache must end any active measurement burst: " f"burstActive {metrics['burstActive']}" ) - assert metrics["renderPending"] is False, ( - "_clearMessageVirtualHeightCache must clear the internal-measurement " - f"provenance marker: renderPending {metrics['renderPending']}" + assert metrics["queuedOrigin"] is None, ( + "_clearMessageVirtualHeightCache must clear any queued internal-measurement " + f"provenance: queuedOrigin {metrics['queuedOrigin']}" ) @@ -1174,7 +1175,18 @@ def _measurement_burst_harness(scenario_js: str) -> str: where the overlap/convergence regressions lived. """ js = UI_JS_PATH.read_text(encoding="utf-8") - return _extract_func_script(js) + """ + cap_match = re.search( + r"MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS\s*=\s*(\d+)", js + ) + assert cap_match, ( + "MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS must be defined in ui.js " + "(absolute per-burst cap, #6717 re-gate)" + ) + cap = int(cap_match.group(1)) + return _extract_func_script(js) + ( + "// Absolute per-burst ceiling, read from production ui.js (#6717 re-gate).\n" + f"const MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS = {cap};\n" + ) + """ // ── queued-rAF harness ──────────────────────────────────────────────────────── let renderLog = []; // every renderMessages call: {internal, preserveScroll} let rafQueue = []; @@ -1185,7 +1197,7 @@ let _messageVirtualWindowKey = ''; let _messageVirtualMeasurementCycleKey = ''; let _messageVirtualMeasurementSeenKeys = []; let _messageVirtualMeasurementBurstActive = false; -let _messageVirtualMeasurementRenderPending = false; +let _messageVirtualRenderQueuedOrigin = null; let _messageVirtualScrollActive = false; let _messageVirtualDeferredMeasurement = null; let _scrollbarDragActive = false; @@ -1246,7 +1258,7 @@ console.log(JSON.stringify({ internal: renderLog.filter(r => r.internal).length, burstActive: _messageVirtualMeasurementBurstActive, seenKeys: _messageVirtualMeasurementSeenKeys, - renderPending: _messageVirtualMeasurementRenderPending, + queuedOrigin: _messageVirtualRenderQueuedOrigin, })); """) metrics = json.loads(_run_node(source)) @@ -1266,9 +1278,9 @@ console.log(JSON.stringify({ "termination must clear the seen-key memory: " f"seenKeys {metrics['seenKeys']}" ) - assert metrics["renderPending"] is False, ( - "no internal render may remain pending after termination: " - f"renderPending {metrics['renderPending']}" + assert metrics["queuedOrigin"] is None, ( + "no internal render may remain queued after termination: " + f"queuedOrigin {metrics['queuedOrigin']}" ) @@ -1290,7 +1302,7 @@ console.log(JSON.stringify({ lastInternal: renderLog[renderLog.length - 1].internal, burstActive: _messageVirtualMeasurementBurstActive, seenKeys: _messageVirtualMeasurementSeenKeys, - renderPending: _messageVirtualMeasurementRenderPending, + queuedOrigin: _messageVirtualRenderQueuedOrigin, })); """) metrics = json.loads(_run_node(source)) @@ -1311,9 +1323,9 @@ console.log(JSON.stringify({ "settlement must end the burst: " f"burstActive {metrics['burstActive']}" ) - assert metrics["seenKeys"] == [] and metrics["renderPending"] is False, ( - "settlement must clear the seen-key memory and any pending marker: " - f"seenKeys {metrics['seenKeys']}, renderPending {metrics['renderPending']}" + assert metrics["seenKeys"] == [] and metrics["queuedOrigin"] is None, ( + "settlement must clear the seen-key memory and any queued provenance: " + f"seenKeys {metrics['seenKeys']}, queuedOrigin {metrics['queuedOrigin']}" ) @@ -1328,6 +1340,12 @@ def test_overlapping_external_append_receives_fresh_budget(): Phase 1: the append lands between the two rAF layers and measures key B (already seen in the stale burst). Only the external reset lets B proceed; without it the repeated key would terminate the append's cycle immediately. + The stale layer-2 callback (queued for the DEAD burst) must then degrade to + an UNMARKED render — the append's own measurement must not revive an + internal marker onto it, because the provenance belongs to the queued + render, not to a global flag (#6717 re-gate MUST-FIX 2) — and the fresh + cycle it starts completes with its own internal render, so the append is + never starved of retries. Phase 2: an external render that measures SETTLED lands while a layer-2 callback is pending; the stale callback must then degrade to an UNMARKED render (the external reset consumed its provenance), and the fresh cycle it @@ -1338,7 +1356,8 @@ renderMessages({}); // R0 external: burst starts, seen=[B], L1 queued flushRaf(); // L1 fires -> L2 (internal callback) now pending renderMessages({}); // R1 EXTERNAL APPEND lands before L2 fires: // resets the burst -> fresh cycle, seen=[B] again -flushRaf(); flushRaf(); // stale L2 joins the fresh cycle (R2), then R3 settles +flushRaf(); flushRaf(); // stale L2 fires UNMARKED (R2, external won), + // fresh cycle completes (R3 internal) -> settles renderMessages({}); // R4 external: new cycle, L1 queued flushRaf(); // L1 fires -> L2 (internal callback) now pending renderMessages({}); // R5 external scroll-settle: resets the burst, @@ -1350,12 +1369,13 @@ console.log(JSON.stringify({ renders: renderLog.length, internal: renderLog.filter(r => r.internal).length, appendExternal: renderLog[1].internal, - appendCycleInternal: renderLog[2].internal && renderLog[3].internal, + staleL2Unmarked: renderLog[2].internal, + postAppendCycleInternal: renderLog[3].internal, staleCallbackUnmarked: renderLog[6].internal, postResetCycleInternal: renderLog[7].internal, burstActive: _messageVirtualMeasurementBurstActive, seenKeys: _messageVirtualMeasurementSeenKeys, - renderPending: _messageVirtualMeasurementRenderPending, + queuedOrigin: _messageVirtualRenderQueuedOrigin, })); """) metrics = json.loads(_run_node(source)) @@ -1367,10 +1387,16 @@ console.log(JSON.stringify({ assert metrics["appendExternal"] is False, ( "the append is an unmarked external render" ) - assert metrics["appendCycleInternal"] is True, ( - "the append's cycle must proceed with its own internal renders " - "(fresh budget — the repeated key B was not treated as oscillation " - "because the external render reset the burst)" + assert metrics["staleL2Unmarked"] is False, ( + "the stale layer-2 callback (queued for the burst the append reset) " + "must degrade to an UNMARKED render: the append's own measurement " + "must not revive an internal marker onto it — provenance belongs to " + "the queued render, external wins (#6717 re-gate MUST-FIX 2)" + ) + assert metrics["postAppendCycleInternal"] is True, ( + "the fresh cycle after the degraded stale callback must complete with " + "its own internal render (fresh budget — the repeated key B was not " + "treated as oscillation because the external render reset the burst)" ) assert metrics["staleCallbackUnmarked"] is False, ( "external provenance must override the pending internal callback: the " @@ -1385,7 +1411,126 @@ console.log(JSON.stringify({ "the fresh cycles must end settled: " f"burstActive {metrics['burstActive']}" ) - assert metrics["seenKeys"] == [] and metrics["renderPending"] is False, ( - "settlement must clear the seen-key memory and any pending marker: " - f"seenKeys {metrics['seenKeys']}, renderPending {metrics['renderPending']}" + assert metrics["seenKeys"] == [] and metrics["queuedOrigin"] is None, ( + "settlement must clear the seen-key memory and any queued provenance: " + f"seenKeys {metrics['seenKeys']}, queuedOrigin {metrics['queuedOrigin']}" + ) + + +def test_all_distinct_keys_terminate_burst_with_bounded_storage(): + """#6717 re-gate MUST-FIX 1: an ALL-DISTINCT monotonic key sequence + (A->B->C->D->... never repeating) must still terminate. The seen-key rule + alone only ends the burst on a REPEATED key, so a browser emitting a + never-repeating geometry would loop without limit — the #6654 CPU-runaway + class under a different trigger — while _messageVirtualMeasurementSeenKeys + grows without bound (memory). The absolute per-burst cap ends the burst + regardless of key novelty and bounds the seen-key collection to the cap. + Driven through the real two-rAF-layer chain with MORE distinct keys than + the cap, and asserting the burst stops at exactly the cap with no rAF left + queued and the seen-key memory cleared.""" + source = _measurement_burst_harness(""" +measurePlan = []; +for(let i=0;i<40;i++) measurePlan.push('K'+i); // 40 distinct keys, well over the cap +renderMessages({}); // external trigger: starts the burst +for(let i=0;i<40;i++) flushRaf(); // drain both rAF layers repeatedly +console.log(JSON.stringify({ + renders: renderLog.length, + internal: renderLog.filter(r => r.internal).length, + burstActive: _messageVirtualMeasurementBurstActive, + seenKeys: _messageVirtualMeasurementSeenKeys, + queuedOrigin: _messageVirtualRenderQueuedOrigin, + rafPending: rafQueue.length, + cap: MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS, +})); +""") + metrics = json.loads(_run_node(source)) + cap = metrics["cap"] + assert metrics["internal"] == cap, ( + "an all-distinct sequence must be cut off by the absolute per-burst " + f"cap: {metrics['internal']} internal renders (expected exactly {cap})" + ) + assert metrics["renders"] == cap + 1, ( + "the all-distinct burst must terminate after the cap (1 external + cap " + f"internal renders): {metrics['renders']} renders (expected {cap + 1})" + ) + assert metrics["rafPending"] == 0, ( + "no rAF may remain queued after the cap ends the burst: " + f"rafPending {metrics['rafPending']}" + ) + assert metrics["burstActive"] is False, ( + "the cap must end the burst: " + f"burstActive {metrics['burstActive']}" + ) + assert metrics["seenKeys"] == [], ( + "termination must clear the seen-key memory (bounded storage — at most " + f"cap keys were ever retained): seenKeys {metrics['seenKeys']}" + ) + assert metrics["queuedOrigin"] is None, ( + "no internal provenance may remain queued after the cap ends the burst: " + f"queuedOrigin {metrics['queuedOrigin']}" + ) + + +def test_queued_raf_external_scroll_wins_provenance(): + """#6717 re-gate MUST-FIX 2: when an external render (user scroll / + scroll-settle) overlaps a QUEUED internal rAF, the render that actually + fires must be treated as EXTERNAL. The old global consumable flag + (_messageVirtualMeasurementRenderPending) could be consumed by an + unrelated render, marking external renders as internal so the burst was + never reset — leaving stale virtual-window / spacer geometry until the + next unrelated render. + + Sequence: external R0 measures A (L2 queued internal); an external R1 + lands while L2 is pending — it resets the burst, measures B, and its own + layer-1 rAF COALESCES into the pending L2 (the early-return path where + the old flag leaked). The L2 that fires must be UNMARKED — external + provenance wins — so the A it measures starts a fresh burst instead of + terminating on the repeated key with zero retry queued.""" + source = _measurement_burst_harness(""" +measurePlan = ['A','B','A', undefined]; +renderMessages({}); // R0 external: measures A -> burst, L1 queued +flushRaf(); // L1 -> L2 queued with internal provenance +renderMessages({}); // R1 EXTERNAL scroll lands while L2 pending: + // resets the burst, measures B -> L1 queued +flushRaf(); // B's L1 coalesces into the pending L2 (no new rAF) +flushRaf(); // L2 fires -> must be UNMARKED (external won), + // measures A -> fresh burst -> L1 queued +flushRaf(); flushRaf(); // A's fresh cycle: L2 internal render -> settles +console.log(JSON.stringify({ + renders: renderLog.length, + internal: renderLog.filter(r => r.internal).length, + r0External: renderLog[0].internal, + r1External: renderLog[1].internal, + coalescedL2Unmarked: renderLog[2].internal, + freshCycleInternal: renderLog[3].internal, + burstActive: _messageVirtualMeasurementBurstActive, + seenKeys: _messageVirtualMeasurementSeenKeys, + queuedOrigin: _messageVirtualRenderQueuedOrigin, +})); +""") + metrics = json.loads(_run_node(source)) + assert metrics["renders"] == 4, ( + "the scroll overlap chain must run to completion: " + f"{metrics['renders']} renders (expected 4)" + ) + assert metrics["r0External"] is False and metrics["r1External"] is False, ( + "R0 and R1 are unmarked external renders" + ) + assert metrics["coalescedL2Unmarked"] is False, ( + "the queued internal rAF must degrade to an UNMARKED render when an " + "external request coalesced into it — external provenance wins and an " + "internal marker must never survive onto an external render" + ) + assert metrics["freshCycleInternal"] is True, ( + "the fresh cycle started by the degraded render must complete with its " + "own internal render: the repeated A key must NOT terminate it because " + "the burst was reset (zero retry queued would leave stale geometry)" + ) + assert metrics["burstActive"] is False, ( + "the fresh cycle must end settled: " + f"burstActive {metrics['burstActive']}" + ) + assert metrics["seenKeys"] == [] and metrics["queuedOrigin"] is None, ( + "settlement must clear the seen-key memory and any queued provenance: " + f"seenKeys {metrics['seenKeys']}, queuedOrigin {metrics['queuedOrigin']}" ) diff --git a/tests/test_issue734_message_windowing.py b/tests/test_issue734_message_windowing.py index 88218cde..b7434db2 100644 --- a/tests/test_issue734_message_windowing.py +++ b/tests/test_issue734_message_windowing.py @@ -96,4 +96,11 @@ def test_measurement_burst_not_reset_by_cycle_key_change(): # Repeated keys terminate the burst; unseen keys proceed. assert "if(_messageVirtualMeasurementSeenKeys.includes(cycleKey)){" in body assert "_messageVirtualMeasurementSeenKeys.push(cycleKey);" in body - assert "_messageVirtualMeasurementRenderPending=true;" in body + # The internal-measurement origin travels WITH the scheduled render request + # (threaded through both rAF layers), never as a global consumable flag that + # a coalesced external render could steal (#6717 re-gate MUST-FIX 2). + assert "origin:'internal'" in body + assert "_messageVirtualMeasurementRenderPending" not in UI_JS + # Absolute per-burst ceiling: even an all-distinct key sequence terminates, + # bounding the seen-key memory (#6717 re-gate MUST-FIX 1). + assert "MESSAGE_VIRTUAL_MEASUREMENT_MAX_RERENDERS" in UI_JS From 377087da063299119b17a5d21657847ada32fcbc Mon Sep 17 00:00:00 2001 From: webtecnica Date: Mon, 10 Aug 2026 08:16:12 -0300 Subject: [PATCH 007/115] fix(ui): strip @custom:: prefix when sending non-default custom-provider models --- static/ui.js | 11 ++- ...onfigured_model_picker_provider_routing.py | 99 +++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/static/ui.js b/static/ui.js index 56af87da..31f30c3d 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3057,7 +3057,16 @@ function _modelStateForSelect(sel, modelId){ // id (e.g. model-a:free) synthesized as @custom:backup:model-a:free would // otherwise mis-parse to provider "custom:backup:model-a" (#6221 re-gate). const routedProvider=selected?String(_getOptionProviderId(selected)||'').trim():''; - return {model:routedModel||value,model_provider:routedProvider||explicitProvider}; + // Normally-rendered catalog options only carry the qualified + // @custom:: value — data-model is set solely by the fallback + // injection path (_ensureModelOptionInDropdown). When it is missing, strip + // the same @: prefix already parsed as explicitProvider instead + // of sending the raw dropdown value as the model id (#6884). + const explicitPrefix=`@${explicitProvider}:`; + const strippedModel=value.toLowerCase().startsWith(explicitPrefix.toLowerCase()) + ?value.slice(explicitPrefix.length) + :value; + return {model:routedModel||strippedModel||value,model_provider:routedProvider||explicitProvider}; } // Resolve the provider from the option whose VALUE matches the requested // model — never blindly from sel.selectedOptions[0] (#5567). During a profile diff --git a/tests/test_configured_model_picker_provider_routing.py b/tests/test_configured_model_picker_provider_routing.py index b63c607a..1bba3fc6 100644 --- a/tests/test_configured_model_picker_provider_routing.py +++ b/tests/test_configured_model_picker_provider_routing.py @@ -160,6 +160,105 @@ def test_colon_bearing_missing_fallback_keeps_authoritative_provider(): } +# Non-default model of a named custom_providers[] entry. The catalog render +# paths build the option with the qualified "@custom::" value and +# NEVER set data-model (that attribute is only set by the fallback injection +# path _ensureModelOptionInDropdown). Regression for #6884: the returned model +# id must be the stripped bare name ("sol"), not the raw dropdown value. +_NON_DEFAULT_CUSTOM_DRIVER = r""" +const fs = require('fs'); +const uiSrc = fs.readFileSync(process.argv[1], 'utf8'); + +function extractFunction(source, name) { + const marker = 'function ' + name + '('; + const start = source.indexOf(marker); + if (start < 0) throw new Error('not found: ' + name); + const brace = source.indexOf('{', source.indexOf(')', start)); + let depth = 0; + for (let i = brace; i < source.length; i++) { + if (source[i] === '{') depth += 1; + else if (source[i] === '}') { + depth -= 1; + if (depth === 0) return source.slice(start, i + 1); + } + } + throw new Error('unterminated: ' + name); +} + +eval([ + '_getOptionProviderId', + '_providerFromModelValue', + '_modelStateForSelect', +].map(name => extractFunction(uiSrc, name)).join('\n')); + +globalThis.document = { + createElement(tag) { + return { + tagName: String(tag).toUpperCase(), + value: '', + textContent: '', + dataset: {}, + parentElement: null, + }; + }, +}; +globalThis.getModelLabel = value => String(value || ''); +globalThis.window = { _configuredModelBadges: {} }; + +const group = {tagName: 'OPTGROUP', dataset: {provider: 'custom:hetmer.net'}}; +const luna = { + value: 'luna', + textContent: 'luna', + dataset: {}, + parentElement: group, +}; +const sol = { + value: '@custom:hetmer.net:sol', + textContent: 'sol', + dataset: {}, // no data-model — normal catalog render path + parentElement: group, +}; +const select = { + id: 'modelSelect', + options: [luna, sol], + querySelectorAll() { return []; }, + get selectedOptions() { return [sol]; }, + get value() { return sol.value; }, + set value(value) {}, +}; + +process.stdout.write(JSON.stringify({ + nonDefault: _modelStateForSelect(select, '@custom:hetmer.net:sol'), + defaultUnprefixed: _modelStateForSelect(select, 'luna'), +})); +""" + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_non_default_named_custom_provider_model_strips_qualified_prefix(): + assert NODE is not None + result = subprocess.run( + [NODE, "-e", _NON_DEFAULT_CUSTOM_DRIVER, str(UI_JS)], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + + # #6884: the normally-rendered option has no data-model, so the qualified + # "@custom:hetmer.net:sol" value must be stripped to the bare model id. + assert payload["nonDefault"] == { + "model": "sol", + "model_provider": "custom:hetmer.net", + } + # Sanity: the unprefixed default option still returns its value as-is. + assert payload["defaultUnprefixed"] == { + "model": "luna", + "model_provider": "custom:hetmer.net", + } + + _RENDERED_CLICK_DRIVER = r""" const fs = require('fs'); From 1e53cc4f1bad3f0196204096d39d751d7e6378e0 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 12 Aug 2026 21:50:24 -0300 Subject: [PATCH 008/115] fix(ui): restrict @provider prefix strip to custom providers only (#6884) The strip introduced for #6884 must only apply to @custom:: values rendered by the catalog (which never set data-model). A generic @: strip also rewrote non-custom qualified ids like @safe:gpt-4o-mini, breaking the #1771 contract that fallback session models keep their provider namespace. Gate the strip on the parsed provider being 'custom' or 'custom:'. --- static/ui.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/static/ui.js b/static/ui.js index 31f30c3d..e167399e 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3060,10 +3060,14 @@ function _modelStateForSelect(sel, modelId){ // Normally-rendered catalog options only carry the qualified // @custom:: value — data-model is set solely by the fallback // injection path (_ensureModelOptionInDropdown). When it is missing, strip - // the same @: prefix already parsed as explicitProvider instead - // of sending the raw dropdown value as the model id (#6884). + // the @custom:: prefix already parsed as explicitProvider instead + // of sending the raw dropdown value as the model id (#6884). Only custom + // providers are affected: a non-custom qualified id like @safe:gpt-4o-mini + // is a real provider namespace and must be preserved (#1771). + const explicitProviderLc=explicitProvider.toLowerCase(); + const isCustomProvider=explicitProviderLc==='custom'||explicitProviderLc.startsWith('custom:'); const explicitPrefix=`@${explicitProvider}:`; - const strippedModel=value.toLowerCase().startsWith(explicitPrefix.toLowerCase()) + const strippedModel=isCustomProvider&&value.toLowerCase().startsWith(explicitPrefix.toLowerCase()) ?value.slice(explicitPrefix.length) :value; return {model:routedModel||strippedModel||value,model_provider:routedProvider||explicitProvider}; From c82c6a542a20ead7a72b085844f657f9004f57a1 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Mon, 17 Aug 2026 11:50:33 -0300 Subject: [PATCH 009/115] fix(ui): use option metadata provider for @custom prefix strip (#6884) The #6884 prefix strip built its prefix from explicitProvider, which re-parses the value at its LAST colon. For a colon-bearing model id rendered by the normal catalog path (e.g. @custom:backup:model-a:free, no data-model), the strip truncated the model to just "free". Build the strip prefix from the option metadata's authoritative provider (routedProvider, e.g. custom:backup), falling back to the parsed provider only when no option metadata exists. The #1771 gate is kept: non-custom namespaces like @safe:gpt-4o-mini are still preserved. Adds regression coverage for the colon-bearing model case beside the existing #6884 normal-catalog test. --- static/ui.js | 21 ++++++++++++------- ...onfigured_model_picker_provider_routing.py | 21 ++++++++++++++++++- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/static/ui.js b/static/ui.js index e167399e..80ec8b5e 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3060,17 +3060,22 @@ function _modelStateForSelect(sel, modelId){ // Normally-rendered catalog options only carry the qualified // @custom:: value — data-model is set solely by the fallback // injection path (_ensureModelOptionInDropdown). When it is missing, strip - // the @custom:: prefix already parsed as explicitProvider instead - // of sending the raw dropdown value as the model id (#6884). Only custom - // providers are affected: a non-custom qualified id like @safe:gpt-4o-mini - // is a real provider namespace and must be preserved (#1771). - const explicitProviderLc=explicitProvider.toLowerCase(); - const isCustomProvider=explicitProviderLc==='custom'||explicitProviderLc.startsWith('custom:'); - const explicitPrefix=`@${explicitProvider}:`; + // the @custom:: prefix instead of sending the raw dropdown value as + // the model id (#6884). The prefix must come from the option metadata's + // authoritative provider (routedProvider), NOT from explicitProvider: the + // latter re-parses the value at its LAST colon, so a colon-bearing model + // id like @custom:backup:model-a:free would otherwise strip to just + // "free" (re-gate on the #6221 family). Only custom providers are + // stripped: a non-custom qualified id like @safe:gpt-4o-mini is a real + // provider namespace and must be preserved (#1771). + const effectiveProvider=routedProvider||explicitProvider; + const effectiveProviderLc=effectiveProvider.toLowerCase(); + const isCustomProvider=effectiveProviderLc==='custom'||effectiveProviderLc.startsWith('custom:'); + const explicitPrefix=`@${effectiveProvider}:`; const strippedModel=isCustomProvider&&value.toLowerCase().startsWith(explicitPrefix.toLowerCase()) ?value.slice(explicitPrefix.length) :value; - return {model:routedModel||strippedModel||value,model_provider:routedProvider||explicitProvider}; + return {model:routedModel||strippedModel||value,model_provider:effectiveProvider}; } // Resolve the provider from the option whose VALUE matches the requested // model — never blindly from sel.selectedOptions[0] (#5567). During a profile diff --git a/tests/test_configured_model_picker_provider_routing.py b/tests/test_configured_model_picker_provider_routing.py index 1bba3fc6..d2f77be0 100644 --- a/tests/test_configured_model_picker_provider_routing.py +++ b/tests/test_configured_model_picker_provider_routing.py @@ -218,9 +218,20 @@ const sol = { dataset: {}, // no data-model — normal catalog render path parentElement: group, }; +// Colon-bearing model id on the SAME normal catalog render path (no +// data-model). Regression for the #6884 re-gate: the prefix to strip must +// come from the option metadata's authoritative provider (custom:hetmer.net), +// NOT from the last-colon value reparse (which would yield the malformed +// "custom:hetmer.net:model-a" prefix and truncate the model to "free"). +const modelAFree = { + value: '@custom:hetmer.net:model-a:free', + textContent: 'model-a:free', + dataset: {}, // no data-model — normal catalog render path + parentElement: group, +}; const select = { id: 'modelSelect', - options: [luna, sol], + options: [luna, sol, modelAFree], querySelectorAll() { return []; }, get selectedOptions() { return [sol]; }, get value() { return sol.value; }, @@ -230,6 +241,7 @@ const select = { process.stdout.write(JSON.stringify({ nonDefault: _modelStateForSelect(select, '@custom:hetmer.net:sol'), defaultUnprefixed: _modelStateForSelect(select, 'luna'), + colonBearingModel: _modelStateForSelect(select, '@custom:hetmer.net:model-a:free'), })); """ @@ -257,6 +269,13 @@ def test_non_default_named_custom_provider_model_strips_qualified_prefix(): "model": "luna", "model_provider": "custom:hetmer.net", } + # Colon-bearing model id on the same normal catalog render path: the strip + # prefix comes from the option metadata provider (custom:hetmer.net), so + # the full "model-a:free" survives — not just the "free" suffix (re-gate). + assert payload["colonBearingModel"] == { + "model": "model-a:free", + "model_provider": "custom:hetmer.net", + } _RENDERED_CLICK_DRIVER = r""" From cef2521f2ab4aefc03d7e8402ee7f10c791dd722 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Mon, 17 Aug 2026 11:54:37 -0300 Subject: [PATCH 010/115] fix(ui): restore "Selected" row for bare custom model ids (#6884) After the send path emits the bare model id (e.g. "model-a:free"), the restore/refresh path compared the qualified catalog row value (@custom:hetmer.net:model-a:free) against the bare state model, so no row rendered as active/"Selected". Normalize both sides through _modelPickerOptionIdentity (the same identity used for picker dedup) and keep the provider equality gate, so exactly one row is restored. Adds a round-trip regression test (send -> persist -> restore) with a colon-bearing custom model id asserting exactly one "Selected" row with the model intact, and updates the #5748 source-assertions to the normalized comparison. --- static/ui.js | 18 +- ...onfigured_model_picker_provider_routing.py | 269 ++++++++++++++++++ ...t_issue5748_model_picker_selected_badge.py | 16 +- 3 files changed, 299 insertions(+), 4 deletions(-) diff --git a/static/ui.js b/static/ui.js index 80ec8b5e..ba2c6af7 100644 --- a/static/ui.js +++ b/static/ui.js @@ -4431,7 +4431,23 @@ function renderModelDropdown(){ const _provider=String((m&&m.providerId)||(m&&m.badge&&m.badge.provider)||((typeof _providerFromModelValue==='function')?_providerFromModelValue(m&&m.value):'')||'').trim(); return (_provider&&_provider!=='default')?_provider:null; }; - const _isSelectedModelRow=(m)=>String((m&&m.value)||'')===String((_selectedModelState&&_selectedModelState.model)||(sel&&sel.value)||'')&&String(_modelProviderForSelectedBadge(m)||'')===String((_selectedModelState&&_selectedModelState.model_provider)||''); + const _isSelectedModelRow=(m)=>{ + const _rowModel=String((m&&m.value)||''); + const _rowProvider=String(_modelProviderForSelectedBadge(m)||''); + const _stateModel=String((_selectedModelState&&_selectedModelState.model)||(sel&&sel.value)||''); + const _stateProvider=String((_selectedModelState&&_selectedModelState.model_provider)||''); + // Normalize both sides to the same model/provider identity. Catalog rows + // carry the qualified @custom:: value while the outgoing + // state model is bare (#6884) — a raw string comparison would leave no + // row marked active/"Selected" after a restore. _modelPickerOptionIdentity + // is the same identity used for picker dedup, so the row that survives is + // exactly the one the send path resolves. + const _norm=(model,provider)=>typeof _modelPickerOptionIdentity==='function' + ?_modelPickerOptionIdentity(model,provider) + :String(model||''); + return _norm(_rowModel,_rowProvider)===_norm(_stateModel,_stateProvider) + &&_rowProvider===_stateProvider; + }; const _selectedModelBadge=(m)=>_isSelectedModelRow(m) ?`${esc(t('model_badge_selected')||'Selected')}` :''; diff --git a/tests/test_configured_model_picker_provider_routing.py b/tests/test_configured_model_picker_provider_routing.py index d2f77be0..ca4daf30 100644 --- a/tests/test_configured_model_picker_provider_routing.py +++ b/tests/test_configured_model_picker_provider_routing.py @@ -278,6 +278,275 @@ def test_non_default_named_custom_provider_model_strips_qualified_prefix(): } +# Round-trip: send -> persist -> restore for a colon-bearing named +# custom-provider model. The send path emits the bare id ("model-a:free"); +# on restore, _findModelInDropdown reverse-matches the bare id back to the +# qualified catalog option ("@custom:hetmer.net:model-a:free") and the +# dropdown re-renders. _isSelectedModelRow must normalize both sides to the +# same model/provider identity so exactly ONE row renders as active / +# "Selected" — with the model id intact, not truncated. +_ROUND_TRIP_DRIVER = r""" +const fs = require('fs'); +const ui = fs.readFileSync(process.argv[2], 'utf8'); + +function extractFunc(name) { + const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\('); + const start = ui.search(re); + if (start < 0) throw new Error(name + ' not found'); + let openParen = ui.indexOf('(', start); + let i = openParen + 1; + let parenDepth = 1; + while (parenDepth > 0 && i < ui.length) { + if (ui[i] === '(') parenDepth++; + else if (ui[i] === ')') parenDepth--; + i++; + } + i = ui.indexOf('{', i); + let depth = 1; + i++; + while (depth > 0 && i < ui.length) { + if (ui[i] === '{') depth++; + else if (ui[i] === '}') depth--; + i++; + } + return ui.slice(start, i); +} + +function makeClassList(initial) { + const set = new Set(initial || []); + return { + _set: set, + add(cls) { set.add(cls); }, + remove(cls) { set.delete(cls); }, + contains(cls) { return set.has(cls); }, + toggle(cls, force) { + if (force === true) { set.add(cls); return true; } + if (force === false) { set.delete(cls); return false; } + if (set.has(cls)) { set.delete(cls); return false; } + set.add(cls); + return true; + }, + }; +} + +function defineClassName(node) { + Object.defineProperty(node, 'className', { + get() { return [...node.classList._set].join(' '); }, + set(v) { node.classList = makeClassList(String(v || '').split(/\s+/).filter(Boolean)); }, + }); +} + +function makeNode(tag) { + const node = { + tagName: String(tag || '').toUpperCase(), + children: [], + dataset: {}, + style: {}, + parentElement: null, + textContent: '', + value: '', + tabIndex: 0, + onclick: null, + _listeners: {}, + _innerHTML: '', + appendChild(child) { + child.parentElement = this; + this.children.push(child); + if (this.tagName === 'OPTGROUP' && this._ownerSelect && child.tagName === 'OPTION') { + this._ownerSelect.options.push(child); + } + return child; + }, + addEventListener(type, handler) { this._listeners[type] = handler; }, + querySelector(selector) { return this._qs ? this._qs[selector] || null : null; }, + setAttribute(name, value) { this[name] = value; }, + focus() { this._focused = true; }, + }; + node.classList = makeClassList(); + defineClassName(node); + Object.defineProperty(node, 'innerHTML', { + get() { return this._innerHTML; }, + set(v) { + this._innerHTML = String(v || ''); + this.children = []; + this._qs = {}; + if (this.tagName === 'DIV' && this._innerHTML.includes('model-search-input')) { + const input = makeNode('input'); + input.className = 'model-search-input'; + const clear = makeNode('button'); + clear.className = 'model-search-clear'; + this._qs['.model-search-input'] = input; + this._qs['.model-search-clear'] = clear; + } else if (this.tagName === 'DIV' && this._innerHTML.includes('model-custom-input')) { + const input = makeNode('input'); + input.className = 'model-custom-input'; + const btn = makeNode('button'); + btn.className = 'model-custom-btn'; + this._qs['.model-custom-input'] = input; + this._qs['.model-custom-btn'] = btn; + } + }, + }); + return node; +} + +function makeOption(value, label, parent) { + const opt = makeNode('option'); + opt.value = value; + opt.textContent = label || value; + opt.parentElement = parent || null; + return opt; +} + +function makeSelect(groups, selectedValue) { + const sel = { id: 'modelSelect', children: [], options: [], _value: selectedValue || '' }; + Object.defineProperty(sel, 'value', {get(){return sel._value;}, set(v){sel._value=String(v||'');}}); + Object.defineProperty(sel, 'selectedOptions', {get(){const o=sel.options.find(x=>x.value===sel._value);return o?[o]:[];}}); + sel.appendChild=function(option){option.parentElement=null;sel.options.push(option);}; + sel.querySelectorAll=function(){return [];}; + for (const group of groups || []) { + const og = makeNode('optgroup'); + og.label = group.provider || ''; + og.dataset.provider = group.provider_id || ''; + og._ownerSelect = sel; + for (const model of group.models || []) og.appendChild(makeOption(model.id, model.label || model.id, og)); + sel.children.push(og); + sel.options.push(...og.children); + } + return sel; +} + +function snapshot(dd) { + const out = []; + const walk = (node) => { + for (const child of (node.children || [])) { + out.push({ + className: child.className, + textContent: child.textContent, + html: child._innerHTML || '', + }); + if (child.children && child.children.length) walk(child); + } + }; + walk(dd); + return out; +} + +const payload = JSON.parse(process.argv[3]); +const dropdown = makeNode('div'); +dropdown.classList.add('open'); +const modelSelect = makeSelect(payload.groups, payload.selectedValue || payload.groups[0].models[0].id); + +function $(id) { + if (id === 'composerModelDropdown') return dropdown; + if (id === 'modelSelect') return modelSelect; + return null; +} +const window = { _configuredModelBadges: payload.configuredBadges || {} }; +const document = { createElement(tag) { return makeNode(tag); } }; +function esc(v) { return String(v || ''); } +function t(key, ...args) { + if (key === 'model_show_all_models') return `Show all ${args[0]} models`; + return key; +} +function li() { return 'x'; } +function getModelLabel(v) { return String(v || ''); } +function _providerFromModelValue(v) { + const value = String(v || ''); + if (value.startsWith('@') && value.includes(':')) return value.slice(1, value.lastIndexOf(':')); + return ''; +} +function _normalizeConfiguredModelKey(v) { return String(v || '').toLowerCase(); } +function _getConfiguredModelBadge(value, badgeMap) { return badgeMap[value] || null; } +function closeModelDropdown() {} +function syncModelChip() {} +function _refreshOpenModelDropdown() {} +function _deduplicateModelPickerOptions() { return 0; } +async function selectModelFromDropdown(value, provider) { + _ensureModelOptionInDropdown(value, modelSelect, provider); + window.__picked=_modelStateForSelect(modelSelect,modelSelect.value); +} + +for (const name of [ + '_modelPickerOptionIdentity', + '_readModelOverflowData', + '_appendOverflowOptionsToGroup', + '_isEquivalentConfiguredModelEntry', + '_getOptionProviderId', + '_modelStateForSelect', + '_findModelInDropdown', + '_applyModelToDropdown', + '_ensureModelOptionInDropdown', + 'renderModelDropdown', +]) { + eval(extractFunc(name)); +} + +// Send path: the picked state carries the bare, intact model id. +const sent = _modelStateForSelect(modelSelect, '@custom:hetmer.net:model-a:free'); +// Restore path: _findModelInDropdown reverse-matches the bare id to the +// qualified catalog option, the picker restores sel.value to it and +// re-renders the dropdown. +const restoreValue = _findModelInDropdown(sent.model, modelSelect, sent.model_provider); +modelSelect.value = restoreValue; +renderModelDropdown(); +const rows = snapshot(dropdown).filter(n => String(n.className || '').includes('model-opt')); +const activeRows = rows.filter(n => String(n.className || '').includes(' active')); +process.stdout.write(JSON.stringify({ + sent, + restoreValue, + activeCount: activeRows.length, + activeRow: activeRows.length ? { + className: activeRows[0].className, + text: activeRows[0].textContent, + html: activeRows[0].html, + } : null, +})); +""" + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_colon_bearing_custom_provider_round_trip_restores_selected_row(tmp_path): + driver = tmp_path / "round_trip_driver.js" + driver.write_text(_ROUND_TRIP_DRIVER, encoding="utf-8") + payload = { + "groups": [ + { + "provider": "hetmer.net", + "provider_id": "custom:hetmer.net", + "models": [ + {"id": "luna", "label": "luna"}, + {"id": "@custom:hetmer.net:model-a:free", "label": "model-a:free"}, + ], + } + ], + "configuredBadges": {}, + "selectedValue": "luna", + } + assert NODE is not None + result = subprocess.run( + [NODE, str(driver), str(UI_JS), json.dumps(payload)], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + actual = json.loads(result.stdout) + + # Send path: bare model id, intact (NOT truncated to "free"). + assert actual["sent"] == { + "model": "model-a:free", + "model_provider": "custom:hetmer.net", + } + # Restore path: the bare id maps back to the qualified catalog option. + assert actual["restoreValue"] == "@custom:hetmer.net:model-a:free" + # Exactly one row renders as active/"Selected", with the intact model id. + assert actual["activeCount"] == 1, actual + assert "model-opt-badge--selected" in actual["activeRow"]["html"] + assert "model-a:free" in actual["activeRow"]["html"] + assert ">free<" not in actual["activeRow"]["html"].replace("model-a:free", "") + + _RENDERED_CLICK_DRIVER = r""" const fs = require('fs'); diff --git a/tests/test_issue5748_model_picker_selected_badge.py b/tests/test_issue5748_model_picker_selected_badge.py index 63c32f0c..2b8d7100 100644 --- a/tests/test_issue5748_model_picker_selected_badge.py +++ b/tests/test_issue5748_model_picker_selected_badge.py @@ -34,15 +34,25 @@ def test_model_picker_renders_selected_badge_without_replacing_configured_badge( def test_selected_badge_is_keyed_to_current_model_value(): - assert "String((m&&m.value)||'')===String((_selectedModelState&&_selectedModelState.model)||(sel&&sel.value)||'')" in UI_JS + # The selected badge must key on the resolved model identity, not a raw + # string of the dropdown value: catalog rows carry the qualified + # @custom:: value while the outgoing state model is bare + # (#6884), so both sides are normalized via _modelPickerOptionIdentity + # before comparison. + assert "const _norm=(model,provider)=>typeof _modelPickerOptionIdentity==='function'" in UI_JS + assert "_norm(_rowModel,_rowProvider)===_norm(_stateModel,_stateProvider)" in UI_JS def test_selected_badge_is_keyed_to_current_model_provider(): assert "const _selectedModelState=(typeof _modelStateForSelect==='function')?_modelStateForSelect(sel,sel.value)" in UI_JS assert "const _modelProviderForSelectedBadge=(m)=>" in UI_JS assert "return (_provider&&_provider!=='default')?_provider:null;" in UI_JS - assert "String(_modelProviderForSelectedBadge(m)||'')===String((_selectedModelState&&_selectedModelState.model_provider)||'')" in UI_JS - assert "const _isSelectedModelRow=(m)=>" in UI_JS + # Provider identity still gates the row match — the normalized model + # comparison alone would collapse same-id rows across provider groups. + assert "_rowProvider=String(_modelProviderForSelectedBadge(m)||'')" in UI_JS + assert "_stateProvider=String((_selectedModelState&&_selectedModelState.model_provider)||'')" in UI_JS + assert "_rowProvider===_stateProvider" in UI_JS + assert "const _isSelectedModelRow=(m)=>{" in UI_JS assert "row.className='model-opt'+(_isSelectedModelRow(m)?' active':'');" in UI_JS From 7db2695a5e509cbee3d0ec02b388ff9d75436334 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Mon, 24 Aug 2026 09:54:05 -0300 Subject: [PATCH 011/115] fix: non-greedy parse for @custom prefix + restore selected row - Fix _providerFromModelValue and _getOptionProviderId to correctly parse @custom:: format (non-greedy, stops at first colon after @custom:) - This prevents truncation of colon-bearing model IDs like model-a:free - The restore path (_isSelectedModelRow) now correctly matches the row because the provider is parsed correctly - Related: #6884, #6895, #6221 re-gate --- static/ui.js | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/static/ui.js b/static/ui.js index ba2c6af7..e45bf1eb 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2980,12 +2980,34 @@ function _getOptionProviderId(opt){ return group.dataset.provider; } const value=String(opt.value||''); - if(value.startsWith('@') && value.includes(':')) return value.slice(1,value.lastIndexOf(':')); + if(value.startsWith('@') && value.includes(':')){ + // Non-greedy parse for @custom:: — provider is the slug only. + // For @custom:backup:model-a:free → provider="custom:backup", not "custom:backup:model-a" + if(value.startsWith('@custom:')){ + const afterCustom=value.substring('@custom:'.length); + const firstColon=afterCustom.indexOf(':'); + if(firstColon>=0) return 'custom:'+afterCustom.substring(0,firstColon); + return 'custom:'+afterCustom; + } + // Other @provider:model — provider is up to first colon + return value.slice(1,value.indexOf(':')); + } return ''; } function _providerFromModelValue(modelId){ const value=String(modelId||'').trim(); - if(value.startsWith('@')&&value.includes(':')) return value.slice(1,value.lastIndexOf(':')); + if(value.startsWith('@')&&value.includes(':')){ + // Non-greedy parse for @custom:: — provider is the slug only. + // For @custom:backup:model-a:free → provider="custom:backup", not "custom:backup:model-a" + if(value.startsWith('@custom:')){ + const afterCustom=value.substring('@custom:'.length); + const firstColon=afterCustom.indexOf(':'); + if(firstColon>=0) return 'custom:'+afterCustom.substring(0,firstColon); + return 'custom:'+afterCustom; + } + // Other @provider:model — provider is up to first colon + return value.slice(1,value.indexOf(':')); + } return ''; } function _modelPickerOptionIdentity(modelId, providerId){ From 09e419d5ed023c407ca2e01117385852d8fe4f1a Mon Sep 17 00:00:00 2001 From: CharlesMcquade <6466275+CharlesMcquade@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:15:49 -0500 Subject: [PATCH 012/115] fix(notifications): browser notification on every approval/clarify surfacing, not just the SSE event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approval/clarify prompts block the run until answered, but only the active-session SSE event ever sent a browser notification — and it was gated on document.hidden at delivery time, which background-tab SSE throttling makes unreliable (the same #4416 class fixed for completion notices). The 1.5s fallback poll, the post-respond 'next approval' refresh, and reload-while-pending showed the card silently. Notification ownership moves into the card renderers (showApprovalCard / showClarifyCard) via _notifyPromptCard: per-id dedupe (poll ticks re-render without re-pinging), suppressed only while the tab is visible AND focused (an unfocused-but-visible window still pings, since nothing progresses until the prompt is answered). --- static/messages.js | 42 ++++++- tests/test_pwa_notification_controls.py | 144 +++++++++++++++++++++++- 2 files changed, 182 insertions(+), 4 deletions(-) diff --git a/static/messages.js b/static/messages.js index 2d0c96d6..60be3cd6 100644 --- a/static/messages.js +++ b/static/messages.js @@ -5980,7 +5980,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _applyToAnchor('approval',d,e); showApprovalForSession(activeSid, d, d.pending_count || 1); playAttentionSound(_attentionSoundKey(activeSid,'approval',1)); - sendBrowserNotification('Approval required',d.description||'Tool approval needed',{sid:activeSid}); + // Browser notification is owned by showApprovalCard()/_notifyPromptCard() + // so every surfacing path (SSE, fallback poll, post-respond refresh, + // reload) dedupes through the same per-id gate. }); source.addEventListener('clarify',e=>{ @@ -5988,7 +5990,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _applyToAnchor('clarify',d,e); showClarifyForSession(activeSid, d); playAttentionSound(_attentionSoundKey(activeSid,'clarify',1)); - sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed',{sid:activeSid}); + // Browser notification is owned by showClarifyCard()/_notifyPromptCard(). }); source.addEventListener('state_saved',e=>{ @@ -7625,6 +7627,7 @@ function showApprovalForSession(sid, pending, pendingCount) { function showApprovalCard(pending, pendingCount) { const sid = _rememberApprovalPending(pending, pendingCount); + if(typeof _notifyPromptCard==='function') _notifyPromptCard('approval', sid, pending); if (!_approvalPromptBelongsToActiveSession(sid)) return; if (pending && pending.approval_id && _isApprovalDismissed(sid, pending.approval_id)) return; _approvalClearedOwner = null; @@ -8743,6 +8746,7 @@ function _clarifySetControlsDisabled(disabled, loading=false) { function showClarifyCard(pending) { const sid = _rememberClarifyPending(pending); + if(typeof _notifyPromptCard==='function') _notifyPromptCard('clarify', sid, pending); if (!_clarifyPromptBelongsToActiveSession(sid)) return; const question = pending.question || pending.description || ''; const choices = Array.isArray(pending.choices_offered) @@ -9208,6 +9212,40 @@ function requestNotificationPermission(){ return p; }); } +const _PROMPT_NOTIFY_TTL_MS = 600000; +const _promptNotifySeen = new Map(); +// Prompt-card notifications: an approval or clarify card BLOCKS the run until +// it is answered, so every surfacing path must notify — the live SSE event, +// the 1.5s fallback poll, the post-respond "next approval" refresh, and a +// page reload while a prompt is still pending. The chokepoint is the card +// renderer itself (showApprovalCard / showClarifyCard); this helper dedupes +// per prompt id so repeated poll ticks, re-renders, and session switches +// ping exactly once per unique prompt. +function _notifyPromptCard(kind, sid, pending){ + const p = pending || {}; + const id = p.approval_id || p.clarify_id || ''; + if (!id) return; + const key = kind + ':' + id; + const now = Date.now(); + for (const [staleKey, seenAt] of _promptNotifySeen) { + if (now - Number(seenAt || 0) > _PROMPT_NOTIFY_TTL_MS) _promptNotifySeen.delete(staleKey); + } + if (_promptNotifySeen.has(key)) return; + // Unlike the completion notice (#4416, "was hidden at any point"), a prompt + // is suppressed only when the user is actively looking at the tab right + // now (visible AND focused). An unfocused-but-visible window — e.g. the + // WebUI on a second monitor — still gets the ping, because nothing will + // progress until the prompt is answered. Suppression is NOT recorded, so + // backgrounding while a prompt is pending yields one ping. + if (typeof _isDocumentVisibleAndFocused === 'function' && _isDocumentVisibleAndFocused()) return; + _promptNotifySeen.set(key, now); + if (typeof sendBrowserNotification !== 'function') return; + if (kind === 'clarify') { + sendBrowserNotification('Clarification needed', p.question || p.description || 'Tool clarification needed', { sid }); + } else { + sendBrowserNotification('Approval required', p.description || p.command || 'Tool approval needed', { sid }); + } +} function sendBrowserNotification(title,body,options={}){ const force=!!(options&&options.force); // #4416: `forceHidden` means the caller already determined the tab was hidden diff --git a/tests/test_pwa_notification_controls.py b/tests/test_pwa_notification_controls.py index 1784c7ff..5ecad0c1 100644 --- a/tests/test_pwa_notification_controls.py +++ b/tests/test_pwa_notification_controls.py @@ -41,8 +41,148 @@ def test_notification_payload_uses_completion_session_when_provided(): assert "_completionNotificationPreviewText(lastAsst," in MESSAGES_JS assert "sendBrowserNotification('Response complete',_completionPreview||'Task finished',{forceHidden:_wasEverBackgrounded,sid:activeSid})" in MESSAGES_JS assert "assistantText?assistantText.slice(0,100)" not in MESSAGES_JS - assert "sendBrowserNotification('Approval required',d.description||'Tool approval needed',{sid:activeSid})" in MESSAGES_JS - assert "sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed',{sid:activeSid})" in MESSAGES_JS + + +def test_prompt_notifications_fire_from_card_renderer_chokepoint(): + """Approval/clarify notifications are owned by the card renderers, so EVERY + surfacing path (live SSE, 1.5s fallback poll, post-respond 'next approval' + refresh, reload-while-pending) notifies — not just the active-session SSE + event. The SSE listeners no longer send notifications directly; the shared + _notifyPromptCard gate dedupes per prompt id so repeated poll ticks and + re-renders ping exactly once.""" + # The chokepoint helper exists and both card renderers call it BEFORE the + # belongs-to-active-session guard (so a non-active session's prompt still + # notifies). + assert "function _notifyPromptCard(kind, sid, pending){" in MESSAGES_JS + for fn_name, kind in (("showApprovalCard", "approval"), ("showClarifyCard", "clarify")): + start = MESSAGES_JS.index(f"function {fn_name}(") + body_start = MESSAGES_JS.index("{", start) + depth = 0 + for i in range(body_start, len(MESSAGES_JS)): + if MESSAGES_JS[i] == "{": + depth += 1 + elif MESSAGES_JS[i] == "}": + depth -= 1 + if depth == 0: + body = MESSAGES_JS[start : i + 1] + break + assert "_notifyPromptCard(" in body, f"{fn_name} must route notifications through _notifyPromptCard" + assert "_notifyPromptCard('approval', sid, pending);" in MESSAGES_JS + assert "_notifyPromptCard('clarify', sid, pending);" in MESSAGES_JS + # Dedupe gate: per prompt id, with TTL cleanup of stale keys. + assert "const key = kind + ':' + id;" in MESSAGES_JS + assert "if (_promptNotifySeen.has(key)) return;" in MESSAGES_JS + assert "_PROMPT_NOTIFY_TTL_MS" in MESSAGES_JS + # Visibility gate: suppress only while the tab is visible AND focused + # (unlike the completion notice, an unfocused-but-visible window still + # gets the ping because the run is blocked until the prompt is answered). + assert "_isDocumentVisibleAndFocused()) return;" in MESSAGES_JS + # SSE listeners delegate; they must not send directly (would bypass dedupe). + approval_listener = _source_between( + "source.addEventListener('approval',e=>{", + "source.addEventListener('clarify',e=>{", + ) + clarify_listener = _source_between( + "source.addEventListener('clarify',e=>{", + "source.addEventListener('state_saved',e=>{", + ) + assert "sendBrowserNotification(" not in approval_listener + assert "sendBrowserNotification(" not in clarify_listener + assert "_notifyPromptCard()" in approval_listener # ownership comment anchor + assert "_notifyPromptCard()" in clarify_listener + + +def _extract_fn(src: str, name: str) -> str: + marker = f"function {name}" + start = src.find(marker) + assert start >= 0, f"{name} not found" + brace = src.find("{", start) + depth = 0 + for i in range(brace, len(src)): + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + if depth == 0: + return src[start : i + 1] + raise AssertionError(f"{name} body did not close") + + +def _run_node(source: str) -> dict: + import json + import shutil + import subprocess + import tempfile + + node = shutil.which("node") + if node is None: + import pytest + + pytest.skip("node is required for the executed notification-gate test") + with tempfile.NamedTemporaryFile("w", suffix=".cjs", encoding="utf-8", dir=ROOT, delete=False) as script: + script.write(source) + script_path = Path(script.name) + try: + result = subprocess.run([node, str(script_path)], cwd=str(ROOT), capture_output=True, text=True, timeout=30) + finally: + script_path.unlink(missing_ok=True) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout.strip()) + + +def test_notify_prompt_card_executed_gate_behavior(): + """Executed node-VM proof of _notifyPromptCard's runtime gates: + (1) dedupes per prompt id — a second call for the same id is a no-op; + (2) suppresses while the tab is visible+focused WITHOUT recording, so the + same id still notifies once the tab is hidden (blocking prompt semantics); + (3) notifies when hidden, with the right title/body and sid routing.""" + helper = _extract_fn(MESSAGES_JS, "_notifyPromptCard") + vis = _extract_fn(MESSAGES_JS, "_isDocumentVisibleAndFocused") + script = f""" +const document = {{ hidden: false, visibilityState: 'visible', hasFocus: () => true }}; +const _promptNotifySeen = new Map(); // module-level state closed over by the helper +const _PROMPT_NOTIFY_TTL_MS = 600000; // same, for the stale-key cleanup pass +{vis} +{helper} +const sent = []; +function sendBrowserNotification(title, body, options) {{ sent.push({{ title, body, options }}); }} +const hiddenPending = {{ approval_id: 'a1', description: 'run the thing' }}; +// Hidden tab: fires once... +document.hidden = true; document.visibilityState = 'hidden'; document.hasFocus = () => false; +_notifyPromptCard('approval', 'sid-1', hiddenPending); +_notifyPromptCard('approval', 'sid-1', hiddenPending); // dedupe: no second ping +// Same id after returning to a focused tab: dedupe still holds. +document.hidden = false; document.visibilityState = 'visible'; document.hasFocus = () => true; +_notifyPromptCard('approval', 'sid-1', hiddenPending); +// A DIFFERENT id while focused-visible: suppressed, NOT recorded... +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a2', description: 'second' }}); +const afterFocused = sent.length; // checkpoint: must still be 1 (no ping while focused) +// ...so hiding the tab again pings it exactly once. +document.hidden = true; document.visibilityState = 'hidden'; document.hasFocus = () => false; +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a2', description: 'second' }}); +const afterHidden = sent.length; // checkpoint: the suppressed id fires exactly once now +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a2', description: 'second' }}); // dedupe +// Clarify shape routes its own title/body. +_notifyPromptCard('clarify', 'sid-1', {{ clarify_id: 'c1', question: 'Which one?' }}); +process.stdout.write(JSON.stringify({{ sent, afterFocused, afterHidden }})); +""" + result = _run_node(script) + sent = result["sent"] + # Mid-sequence checkpoints (a final count alone cannot distinguish + # suppression from reordering — bite-check verified this exact gap). + assert result["afterFocused"] == 1, ( + f"no ping may fire while the tab is visible+focused (got {result['afterFocused']} pings at checkpoint)" + ) + assert result["afterHidden"] == 2, ( + f"the suppressed id must ping exactly once once the tab is hidden (got {result['afterHidden']} at checkpoint)" + ) + assert len(sent) == 3, f"expected exactly 3 pings, got {len(sent)}: {sent}" + assert sent[0]["title"] == "Approval required" + assert sent[0]["body"] == "run the thing" + assert sent[0]["options"] == {"sid": "sid-1"} + assert sent[1]["body"] == "second" + assert sent[2]["title"] == "Clarification needed" + assert sent[2]["body"] == "Which one?" def test_completion_notification_preview_uses_settled_message_not_live_prefix(): From ff53395fdead53799c064527b10fa1df3e906af6 Mon Sep 17 00:00:00 2001 From: CharlesMcquade <6466275+CharlesMcquade@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:41:10 -0500 Subject: [PATCH 013/115] fix(notifications): make prompt-notification gate session-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Case 1 was missed: an approval on a NON-active session was suppressed while the user was focused on a different session's tab — even though they had no idea the prompt existed. The gate now suppresses only when the prompt's own session is actively viewed (open in pane + tab visible + focused, via the existing _isSessionActivelyViewed). All three user cases now notify: non-active session, hidden tab, unfocused window. --- static/messages.js | 16 ++-- tests/test_pwa_notification_controls.py | 100 +++++++++++++++--------- 2 files changed, 70 insertions(+), 46 deletions(-) diff --git a/static/messages.js b/static/messages.js index 60be3cd6..e1daa2d2 100644 --- a/static/messages.js +++ b/static/messages.js @@ -9231,13 +9231,15 @@ function _notifyPromptCard(kind, sid, pending){ if (now - Number(seenAt || 0) > _PROMPT_NOTIFY_TTL_MS) _promptNotifySeen.delete(staleKey); } if (_promptNotifySeen.has(key)) return; - // Unlike the completion notice (#4416, "was hidden at any point"), a prompt - // is suppressed only when the user is actively looking at the tab right - // now (visible AND focused). An unfocused-but-visible window — e.g. the - // WebUI on a second monitor — still gets the ping, because nothing will - // progress until the prompt is answered. Suppression is NOT recorded, so - // backgrounding while a prompt is pending yields one ping. - if (typeof _isDocumentVisibleAndFocused === 'function' && _isDocumentVisibleAndFocused()) return; + // Suppress ONLY when the user is effectively looking at this prompt right + // now: it belongs to the session open in the pane AND the tab is visible + // AND focused. Every other combination pings: + // 1. prompt for a non-active session (even with the tab focused) + // 2. active session, but a different tab is selected + // 3. active session, tab active, but the Chrome window is not focused + // Suppression is NOT recorded, so the same prompt still pings exactly once + // the moment the user is no longer looking at it. + if (typeof _isSessionActivelyViewed === 'function' && _isSessionActivelyViewed(sid)) return; _promptNotifySeen.set(key, now); if (typeof sendBrowserNotification !== 'function') return; if (kind === 'clarify') { diff --git a/tests/test_pwa_notification_controls.py b/tests/test_pwa_notification_controls.py index 5ecad0c1..2e37df15 100644 --- a/tests/test_pwa_notification_controls.py +++ b/tests/test_pwa_notification_controls.py @@ -73,10 +73,11 @@ def test_prompt_notifications_fire_from_card_renderer_chokepoint(): assert "const key = kind + ':' + id;" in MESSAGES_JS assert "if (_promptNotifySeen.has(key)) return;" in MESSAGES_JS assert "_PROMPT_NOTIFY_TTL_MS" in MESSAGES_JS - # Visibility gate: suppress only while the tab is visible AND focused - # (unlike the completion notice, an unfocused-but-visible window still - # gets the ping because the run is blocked until the prompt is answered). - assert "_isDocumentVisibleAndFocused()) return;" in MESSAGES_JS + # Visibility gate: suppress only while the prompt's session is actively + # viewed (open in pane + tab visible + tab focused). All three of the + # user's notification cases fire: non-active session, hidden tab, + # unfocused window. + assert "_isSessionActivelyViewed(sid)) return;" in MESSAGES_JS # SSE listeners delegate; they must not send directly (would bypass dedupe). approval_listener = _source_between( "source.addEventListener('approval',e=>{", @@ -133,56 +134,77 @@ def _run_node(source: str) -> dict: def test_notify_prompt_card_executed_gate_behavior(): """Executed node-VM proof of _notifyPromptCard's runtime gates: (1) dedupes per prompt id — a second call for the same id is a no-op; - (2) suppresses while the tab is visible+focused WITHOUT recording, so the - same id still notifies once the tab is hidden (blocking prompt semantics); - (3) notifies when hidden, with the right title/body and sid routing.""" + (2) suppresses only when the prompt's session is ACTIVELY VIEWED (open in + the pane + tab visible + tab focused) WITHOUT recording, so the same id + still notifies the moment the user looks away (blocking prompt semantics); + (3) notifies for a non-active session EVEN with the tab focused (case 1); + (4) notifies when the tab is hidden or the window unfocused (cases 2-3), + with the right title/body and sid routing.""" helper = _extract_fn(MESSAGES_JS, "_notifyPromptCard") - vis = _extract_fn(MESSAGES_JS, "_isDocumentVisibleAndFocused") + viewed = _extract_fn(MESSAGES_JS, "_isSessionActivelyViewed") + current_pane = _extract_fn(MESSAGES_JS, "_isSessionCurrentPane") + visible = _extract_fn(MESSAGES_JS, "_isDocumentVisibleAndFocused") script = f""" const document = {{ hidden: false, visibilityState: 'visible', hasFocus: () => true }}; const _promptNotifySeen = new Map(); // module-level state closed over by the helper const _PROMPT_NOTIFY_TTL_MS = 600000; // same, for the stale-key cleanup pass -{vis} +let _loadingSessionId = null; +const S = {{ session: {{ session_id: 'sid-1' }} }}; +{current_pane} +{visible} +{viewed} {helper} const sent = []; function sendBrowserNotification(title, body, options) {{ sent.push({{ title, body, options }}); }} -const hiddenPending = {{ approval_id: 'a1', description: 'run the thing' }}; -// Hidden tab: fires once... -document.hidden = true; document.visibilityState = 'hidden'; document.hasFocus = () => false; -_notifyPromptCard('approval', 'sid-1', hiddenPending); -_notifyPromptCard('approval', 'sid-1', hiddenPending); // dedupe: no second ping -// Same id after returning to a focused tab: dedupe still holds. +const CASES = {{}}; +// Case 3: active session sid-1, tab visible, WINDOW unfocused → notifies. +document.hidden = false; document.visibilityState = 'visible'; document.hasFocus = () => false; +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a3', description: 'window unfocused' }}); +CASES.windowUnfocused = sent.length; // 1 +// Case 2: tab hidden (different tab selected) → notifies. +document.hidden = true; document.visibilityState = 'hidden'; +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a2', description: 'tab hidden' }}); +CASES.tabHidden = sent.length; // 2 document.hidden = false; document.visibilityState = 'visible'; document.hasFocus = () => true; -_notifyPromptCard('approval', 'sid-1', hiddenPending); -// A DIFFERENT id while focused-visible: suppressed, NOT recorded... -_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a2', description: 'second' }}); -const afterFocused = sent.length; // checkpoint: must still be 1 (no ping while focused) -// ...so hiding the tab again pings it exactly once. -document.hidden = true; document.visibilityState = 'hidden'; document.hasFocus = () => false; -_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a2', description: 'second' }}); -const afterHidden = sent.length; // checkpoint: the suppressed id fires exactly once now -_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a2', description: 'second' }}); // dedupe -// Clarify shape routes its own title/body. +// Case 1: prompt for a NON-active session while tab is fully focused → notifies. +_notifyPromptCard('approval', 'sid-2', {{ approval_id: 'a1', description: 'other session' }}); +CASES.otherSession = sent.length; // 3 +// Suppressed case: prompt for the ACTIVE session with tab visible+focused — +// the user is literally looking at the card. NOT recorded, so... +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a4', description: 'watching' }}); +CASES.activelyViewed = sent.length; // still 3 (no ping while watching) +// ...looking away (window blur) pings that same id exactly once. +document.hasFocus = () => false; +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a4', description: 'watching' }}); +CASES.afterBlur = sent.length; // 4 +document.hasFocus = () => true; +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a4', description: 'watching' }}); // dedupe +// Dedupe holds across focus changes for the first id too. +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a3', description: 'window unfocused' }}); +// Clarify shape routes its own title/body (window blurred again). +document.hasFocus = () => false; _notifyPromptCard('clarify', 'sid-1', {{ clarify_id: 'c1', question: 'Which one?' }}); -process.stdout.write(JSON.stringify({{ sent, afterFocused, afterHidden }})); +CASES.clarify = sent.length; // 5 +process.stdout.write(JSON.stringify({{ CASES, sent }})); """ result = _run_node(script) + cases = result["CASES"] sent = result["sent"] - # Mid-sequence checkpoints (a final count alone cannot distinguish - # suppression from reordering — bite-check verified this exact gap). - assert result["afterFocused"] == 1, ( - f"no ping may fire while the tab is visible+focused (got {result['afterFocused']} pings at checkpoint)" - ) - assert result["afterHidden"] == 2, ( - f"the suppressed id must ping exactly once once the tab is hidden (got {result['afterHidden']} at checkpoint)" - ) - assert len(sent) == 3, f"expected exactly 3 pings, got {len(sent)}: {sent}" + # The user's three notification cases must all fire. + assert cases["windowUnfocused"] == 1, f"case 3 (window unfocused) must notify (got {cases['windowUnfocused']})" + assert cases["tabHidden"] == 2, f"case 2 (tab hidden) must notify (got {cases['tabHidden']})" + assert cases["otherSession"] == 3, f"case 1 (non-active session, tab focused) must notify (got {cases['otherSession']})" + # Suppression only while actively viewing; the suppressed id fires after blur. + assert cases["activelyViewed"] == 3, f"no ping while actively viewing the card (got {cases['activelyViewed']})" + assert cases["afterBlur"] == 4, f"the suppressed id must ping exactly once after blur (got {cases['afterBlur']})" + assert cases["clarify"] == 5, f"clarify routes its own title/body (got {cases['clarify']})" + assert len(sent) == 5, f"expected exactly 5 pings, got {len(sent)}: {sent}" assert sent[0]["title"] == "Approval required" - assert sent[0]["body"] == "run the thing" assert sent[0]["options"] == {"sid": "sid-1"} - assert sent[1]["body"] == "second" - assert sent[2]["title"] == "Clarification needed" - assert sent[2]["body"] == "Which one?" + assert sent[2]["options"] == {"sid": "sid-2"} + assert sent[2]["body"] == "other session" + assert sent[4]["title"] == "Clarification needed" + assert sent[4]["body"] == "Which one?" def test_completion_notification_preview_uses_settled_message_not_live_prefix(): From 9281976b2ba75ec8bbfb1f6c06d4d16aefa0c9e5 Mon Sep 17 00:00:00 2001 From: CharlesMcquade <6466275+CharlesMcquade@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:47:20 -0500 Subject: [PATCH 014/115] fix(notifications): pass forceHidden so prompt notifications survive the live visibility gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live testing on the PPC exposed the real reason unfocused-tab prompt notifications never appeared: _notifyPromptCard correctly decided to notify, but sendBrowserNotification's own gate (_isBackgroundedForBrowserNotification — notify only when document.hidden) vetoed the unfocused-but-visible case. The chokepoint has already made the visibility decision; pass forceHidden so its decision survives, while the notifications-enabled setting is still honored. --- static/messages.js | 8 ++++++-- tests/test_pwa_notification_controls.py | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/static/messages.js b/static/messages.js index e1daa2d2..9fc0a8c7 100644 --- a/static/messages.js +++ b/static/messages.js @@ -9242,10 +9242,14 @@ function _notifyPromptCard(kind, sid, pending){ if (typeof _isSessionActivelyViewed === 'function' && _isSessionActivelyViewed(sid)) return; _promptNotifySeen.set(key, now); if (typeof sendBrowserNotification !== 'function') return; + // forceHidden: this caller already made the visibility decision (the + // actively-viewed gate above). sendBrowserNotification's own live gate + // ("notify only when document.hidden") would otherwise veto the + // unfocused-but-visible case, which this feature explicitly notifies for. if (kind === 'clarify') { - sendBrowserNotification('Clarification needed', p.question || p.description || 'Tool clarification needed', { sid }); + sendBrowserNotification('Clarification needed', p.question || p.description || 'Tool clarification needed', { sid, forceHidden: true }); } else { - sendBrowserNotification('Approval required', p.description || p.command || 'Tool approval needed', { sid }); + sendBrowserNotification('Approval required', p.description || p.command || 'Tool approval needed', { sid, forceHidden: true }); } } function sendBrowserNotification(title,body,options={}){ diff --git a/tests/test_pwa_notification_controls.py b/tests/test_pwa_notification_controls.py index 2e37df15..4f2e1227 100644 --- a/tests/test_pwa_notification_controls.py +++ b/tests/test_pwa_notification_controls.py @@ -200,11 +200,15 @@ process.stdout.write(JSON.stringify({{ CASES, sent }})); assert cases["clarify"] == 5, f"clarify routes its own title/body (got {cases['clarify']})" assert len(sent) == 5, f"expected exactly 5 pings, got {len(sent)}: {sent}" assert sent[0]["title"] == "Approval required" - assert sent[0]["options"] == {"sid": "sid-1"} - assert sent[2]["options"] == {"sid": "sid-2"} + assert sent[0]["options"] == {"sid": "sid-1", "forceHidden": True} + assert sent[2]["options"] == {"sid": "sid-2", "forceHidden": True} assert sent[2]["body"] == "other session" assert sent[4]["title"] == "Clarification needed" assert sent[4]["body"] == "Which one?" + # forceHidden must be set: _notifyPromptCard already made the visibility + # decision, and sendBrowserNotification's live gate ("notify only when + # document.hidden") would otherwise veto the unfocused-but-visible case. + assert all(o["options"].get("forceHidden") for o in sent) def test_completion_notification_preview_uses_settled_message_not_live_prefix(): From ca364400eaf38fdd635e5856e6410a7f1c2749ec Mon Sep 17 00:00:00 2001 From: CharlesMcquade <6466275+CharlesMcquade@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:56:55 -0500 Subject: [PATCH 015/115] fix(notifications): scope prompt-notification dedupe to the logical owner The dedupe key was kind + prompt id only. The approval lifecycle explicitly permits the same externally supplied approval_id in multiple sessions and in multiple gateway runs within one session (the same identity _notifyPromptCard's neighbors use via _approvalOwnerForPending), so the second logical owner's prompt was silently suppressed: no notification, card still blocking the run. Key is now kind + sid + prompt id + gateway owner (run_id + _gateway_mirror_token when both present), mirroring the approval-owner identity used elsewhere in this file. Repeated SSE/poll/render calls for the same full owner stay deduped. Regression coverage (node-VM executed): same approval_id across two sessions notifies both; same session + id under two distinct gateway run owners notifies twice; repeat calls for one full owner notify once. Fails on the pre-fix key (second session suppressed), passes with it. --- static/messages.js | 16 +++++++-- tests/test_pwa_notification_controls.py | 44 ++++++++++++++++++++----- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/static/messages.js b/static/messages.js index 9fc0a8c7..07f5c2af 100644 --- a/static/messages.js +++ b/static/messages.js @@ -9219,13 +9219,23 @@ const _promptNotifySeen = new Map(); // the 1.5s fallback poll, the post-respond "next approval" refresh, and a // page reload while a prompt is still pending. The chokepoint is the card // renderer itself (showApprovalCard / showClarifyCard); this helper dedupes -// per prompt id so repeated poll ticks, re-renders, and session switches -// ping exactly once per unique prompt. +// per logical OWNER - kind + session id + prompt id (+ gateway run id and +// mirror token when present) - so repeated poll ticks, re-renders, and +// session switches ping exactly once per owner. The gateway owner fields +// mirror the approval-owner identity used elsewhere in this file +// (_approvalOwnerForPending): the same externally supplied approval_id may +// legitimately be pending in two sessions, or in two gateway runs within one +// session, and each owner must notify on its own. function _notifyPromptCard(kind, sid, pending){ const p = pending || {}; const id = p.approval_id || p.clarify_id || ''; if (!id) return; - const key = kind + ':' + id; + const runId = String(p.run_id || '').trim(); + const mirrorToken = String(p._gateway_mirror_token || '').trim(); + const ownerFields = runId && mirrorToken + ? '' + runId + '' + mirrorToken + : ''; + const key = kind + ':' + String(sid || '') + ':' + String(id) + ownerFields; const now = Date.now(); for (const [staleKey, seenAt] of _promptNotifySeen) { if (now - Number(seenAt || 0) > _PROMPT_NOTIFY_TTL_MS) _promptNotifySeen.delete(staleKey); diff --git a/tests/test_pwa_notification_controls.py b/tests/test_pwa_notification_controls.py index 4f2e1227..4f361d10 100644 --- a/tests/test_pwa_notification_controls.py +++ b/tests/test_pwa_notification_controls.py @@ -69,8 +69,9 @@ def test_prompt_notifications_fire_from_card_renderer_chokepoint(): assert "_notifyPromptCard(" in body, f"{fn_name} must route notifications through _notifyPromptCard" assert "_notifyPromptCard('approval', sid, pending);" in MESSAGES_JS assert "_notifyPromptCard('clarify', sid, pending);" in MESSAGES_JS - # Dedupe gate: per prompt id, with TTL cleanup of stale keys. - assert "const key = kind + ':' + id;" in MESSAGES_JS + # Dedupe gate: per logical owner (kind + sid + prompt id + gateway owner + # fields), with TTL cleanup of stale keys. + assert "const key = kind + ':' + String(sid || '') + ':' + String(id) + ownerFields;" in MESSAGES_JS assert "if (_promptNotifySeen.has(key)) return;" in MESSAGES_JS assert "_PROMPT_NOTIFY_TTL_MS" in MESSAGES_JS # Visibility gate: suppress only while the prompt's session is actively @@ -133,7 +134,9 @@ def _run_node(source: str) -> dict: def test_notify_prompt_card_executed_gate_behavior(): """Executed node-VM proof of _notifyPromptCard's runtime gates: - (1) dedupes per prompt id — a second call for the same id is a no-op; + (1) dedupes per logical owner (kind + session id + prompt id + gateway + run owner) — a second call for the same owner is a no-op, while the same + prompt id under a different session or gateway run owner notifies again; (2) suppresses only when the prompt's session is ACTIVELY VIEWED (open in the pane + tab visible + tab focused) WITHOUT recording, so the same id still notifies the moment the user looks away (blocking prompt semantics); @@ -169,6 +172,25 @@ document.hidden = false; document.visibilityState = 'visible'; document.hasFocus // Case 1: prompt for a NON-active session while tab is fully focused → notifies. _notifyPromptCard('approval', 'sid-2', {{ approval_id: 'a1', description: 'other session' }}); CASES.otherSession = sent.length; // 3 +// Owner-scope regression: same approval_id in a DIFFERENT session must +// notify again — the dedupe key is owner-scoped (kind + sid + id), not +// prompt-id-only. +document.hidden = false; document.visibilityState = 'visible'; document.hasFocus = () => true; +_notifyPromptCard('approval', 'sid-2', {{ approval_id: 'a3', description: 'same id, other session' }}); +CASES.sameIdOtherSession = sent.length; // 4 (a3 was already sent for sid-1) +// Gateway owner-scope regression: same session + same approval_id but a +// distinct gateway run owner (run_id + _gateway_mirror_token) must notify +// again; repeating the SAME full owner stays deduped. Window blurred so the +// actively-viewed gate (which would suppress an ACTIVE-session prompt the +// user is looking at) does not interfere with the owner-scope assertions. +document.hasFocus = () => false; +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a3', run_id: 'run-A', _gateway_mirror_token: 'tok-A', description: 'gateway run A' }}); +CASES.gatewayRunA = sent.length; // 5 +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a3', run_id: 'run-B', _gateway_mirror_token: 'tok-B', description: 'gateway run B' }}); +CASES.gatewayRunB = sent.length; // 6 +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a3', run_id: 'run-A', _gateway_mirror_token: 'tok-A', description: 'gateway run A repeat' }}); +CASES.gatewayRunARepeat = sent.length; // still 6 (same full owner deduped) +document.hasFocus = () => true; // Suppressed case: prompt for the ACTIVE session with tab visible+focused — // the user is literally looking at the card. NOT recorded, so... _notifyPromptCard('approval', 'sid-1', {{ approval_id: 'a4', description: 'watching' }}); @@ -195,16 +217,20 @@ process.stdout.write(JSON.stringify({{ CASES, sent }})); assert cases["tabHidden"] == 2, f"case 2 (tab hidden) must notify (got {cases['tabHidden']})" assert cases["otherSession"] == 3, f"case 1 (non-active session, tab focused) must notify (got {cases['otherSession']})" # Suppression only while actively viewing; the suppressed id fires after blur. - assert cases["activelyViewed"] == 3, f"no ping while actively viewing the card (got {cases['activelyViewed']})" - assert cases["afterBlur"] == 4, f"the suppressed id must ping exactly once after blur (got {cases['afterBlur']})" - assert cases["clarify"] == 5, f"clarify routes its own title/body (got {cases['clarify']})" - assert len(sent) == 5, f"expected exactly 5 pings, got {len(sent)}: {sent}" + assert cases["activelyViewed"] == 6, f"no ping while actively viewing the card (got {cases['activelyViewed']})" + assert cases["afterBlur"] == 7, f"the suppressed id must ping exactly once after blur (got {cases['afterBlur']})" + assert cases["clarify"] == 8, f"clarify routes its own title/body (got {cases['clarify']})" + assert cases["sameIdOtherSession"] == 4, f"same approval_id in another session must notify again (got {cases['sameIdOtherSession']})" + assert cases["gatewayRunA"] == 5, f"distinct gateway run owner must notify again (got {cases['gatewayRunA']})" + assert cases["gatewayRunB"] == 6, f"second distinct gateway run owner must notify again (got {cases['gatewayRunB']})" + assert cases["gatewayRunARepeat"] == 6, f"repeated calls for the same full owner must stay deduped (got {cases['gatewayRunARepeat']})" + assert len(sent) == 8, f"expected exactly 8 pings, got {len(sent)}: {sent}" assert sent[0]["title"] == "Approval required" assert sent[0]["options"] == {"sid": "sid-1", "forceHidden": True} assert sent[2]["options"] == {"sid": "sid-2", "forceHidden": True} assert sent[2]["body"] == "other session" - assert sent[4]["title"] == "Clarification needed" - assert sent[4]["body"] == "Which one?" + assert sent[7]["title"] == "Clarification needed" + assert sent[7]["body"] == "Which one?" # forceHidden must be set: _notifyPromptCard already made the visibility # decision, and sendBrowserNotification's live gate ("notify only when # document.hidden") would otherwise veto the unfocused-but-visible case. From 2f74c2daac99b2192e97042243375ee18225aafc Mon Sep 17 00:00:00 2001 From: Charles McQuade Date: Fri, 11 Sep 2026 17:17:21 -0500 Subject: [PATCH 016/115] fix(notifications): injective owner key and lifecycle-based dedupe retirement Round-2 review fixes for the prompt-notification deduper: - The dedupe key is now the JSON.stringify of the typed owner tuple [kind, sid, prompt id, run owner id, mirror token]. Plain delimiter concatenation let approval_id='x\0r\0t' (no gateway owner) collide with approval_id='x' + run 'r' + token 't'; the gateway accepts approval_id as an unrestricted string, so the second distinct owner was suppressed. - Seen entries retire on prompt LIFECYCLE via the pending-clear chokepoints (_clearApprovalPendingForSession/_clearClarifyPendingForSession now call _retirePromptNotifyKey) instead of a 10-minute wall-clock TTL, so a prompt left pending for hours never re-notifies while a resolved prompt whose ID is reused legitimately notifies again. - Entries are no longer recorded when notifications are disabled, so enabling mid-prompt re-notifies that owner instead of the disabled call permanently consuming it. New regressions: key injectivity with delimiter-bearing approval_id, 24h-still-pending no-renotify, resolved-owner ID reuse, and disabled-to-enabled retry. Full file: 15 passed (11 pre-existing). --- static/messages.js | 56 +++++--- tests/test_pwa_notification_controls.py | 165 +++++++++++++++++++++++- 2 files changed, 198 insertions(+), 23 deletions(-) diff --git a/static/messages.js b/static/messages.js index 07f5c2af..931a59b6 100644 --- a/static/messages.js +++ b/static/messages.js @@ -7484,7 +7484,9 @@ function _rememberApprovalPending(pending, pendingCount) { function _clearApprovalPendingForSession(sid) { if (sid) { + const entry = _approvalPendingBySession.get(sid); _approvalPendingBySession.delete(sid); + if (entry && entry.pending) _retirePromptNotifyKey('approval', sid, entry.pending); if (typeof syncTopbar === 'function') syncTopbar(); } } @@ -8454,7 +8456,9 @@ function _rememberClarifyPending(pending) { function _clearClarifyPendingForSession(sid) { if (sid) { + const entry = _clarifyPendingBySession.get(sid); _clarifyPendingBySession.delete(sid); + if (entry && entry.pending) _retirePromptNotifyKey('clarify', sid, entry.pending); if (typeof syncTopbar === 'function') syncTopbar(); } } @@ -9212,34 +9216,47 @@ function requestNotificationPermission(){ return p; }); } -const _PROMPT_NOTIFY_TTL_MS = 600000; const _promptNotifySeen = new Map(); // Prompt-card notifications: an approval or clarify card BLOCKS the run until // it is answered, so every surfacing path must notify — the live SSE event, // the 1.5s fallback poll, the post-respond "next approval" refresh, and a // page reload while a prompt is still pending. The chokepoint is the card // renderer itself (showApprovalCard / showClarifyCard); this helper dedupes -// per logical OWNER - kind + session id + prompt id (+ gateway run id and -// mirror token when present) - so repeated poll ticks, re-renders, and -// session switches ping exactly once per owner. The gateway owner fields -// mirror the approval-owner identity used elsewhere in this file -// (_approvalOwnerForPending): the same externally supplied approval_id may -// legitimately be pending in two sessions, or in two gateway runs within one -// session, and each owner must notify on its own. +// per logical OWNER - the typed tuple [kind, sid, prompt id, run owner id, +// mirror token] serialized with JSON.stringify so delimiter-bearing producer +// strings cannot collide (the gateway accepts approval_id as an unrestricted +// string) - so repeated poll ticks, re-renders, and session switches ping +// exactly once per owner. The run-owner fields mirror the approval-owner +// identity used elsewhere in this file (_approvalOwnerForPending): the same +// externally supplied approval_id may legitimately be pending in two +// sessions, or in two gateway runs within one session, and each owner must +// notify on its own. Seen entries are retired by PROMPT LIFECYCLE, not by +// wall-clock age: _clearApprovalPendingForSession() and +// _clearClarifyPendingForSession() (the resolution/dismissal/terminal +// chokepoints) call _retirePromptNotifyKey(), so a prompt left pending for +// hours never re-notifies, while a resolved prompt whose ID is later reused +// legitimately notifies again. Entries are also NOT recorded when +// notifications are disabled, so enabling mid-prompt re-notifies that owner. +function _promptNotifyKey(kind, sid, pending){ + const p = pending || {}; + const id = p.approval_id || p.clarify_id || ''; + const runId = String(p.run_id || '').trim(); + const mirrorToken = String(p._gateway_mirror_token || '').trim(); + return JSON.stringify([kind, String(sid || ''), String(id), + runId && mirrorToken ? runId : '', + runId && mirrorToken ? mirrorToken : '']); +} +function _retirePromptNotifyKey(kind, sid, pending){ + if (!pending) return; + const id = pending.approval_id || pending.clarify_id || ''; + if (!id) return; + _promptNotifySeen.delete(_promptNotifyKey(kind, sid, pending)); +} function _notifyPromptCard(kind, sid, pending){ const p = pending || {}; const id = p.approval_id || p.clarify_id || ''; if (!id) return; - const runId = String(p.run_id || '').trim(); - const mirrorToken = String(p._gateway_mirror_token || '').trim(); - const ownerFields = runId && mirrorToken - ? '' + runId + '' + mirrorToken - : ''; - const key = kind + ':' + String(sid || '') + ':' + String(id) + ownerFields; - const now = Date.now(); - for (const [staleKey, seenAt] of _promptNotifySeen) { - if (now - Number(seenAt || 0) > _PROMPT_NOTIFY_TTL_MS) _promptNotifySeen.delete(staleKey); - } + const key = _promptNotifyKey(kind, sid, p); if (_promptNotifySeen.has(key)) return; // Suppress ONLY when the user is effectively looking at this prompt right // now: it belongs to the session open in the pane AND the tab is visible @@ -9250,8 +9267,9 @@ function _notifyPromptCard(kind, sid, pending){ // Suppression is NOT recorded, so the same prompt still pings exactly once // the moment the user is no longer looking at it. if (typeof _isSessionActivelyViewed === 'function' && _isSessionActivelyViewed(sid)) return; - _promptNotifySeen.set(key, now); if (typeof sendBrowserNotification !== 'function') return; + if (typeof window !== 'undefined' && !window._notificationsEnabled) return; + _promptNotifySeen.set(key, Date.now()); // forceHidden: this caller already made the visibility decision (the // actively-viewed gate above). sendBrowserNotification's own live gate // ("notify only when document.hidden") would otherwise veto the diff --git a/tests/test_pwa_notification_controls.py b/tests/test_pwa_notification_controls.py index 4f361d10..0ab9d89d 100644 --- a/tests/test_pwa_notification_controls.py +++ b/tests/test_pwa_notification_controls.py @@ -69,11 +69,22 @@ def test_prompt_notifications_fire_from_card_renderer_chokepoint(): assert "_notifyPromptCard(" in body, f"{fn_name} must route notifications through _notifyPromptCard" assert "_notifyPromptCard('approval', sid, pending);" in MESSAGES_JS assert "_notifyPromptCard('clarify', sid, pending);" in MESSAGES_JS - # Dedupe gate: per logical owner (kind + sid + prompt id + gateway owner - # fields), with TTL cleanup of stale keys. - assert "const key = kind + ':' + String(sid || '') + ':' + String(id) + ownerFields;" in MESSAGES_JS + # Dedupe gate: per logical owner - an INJECTIVE typed-tuple key + # (JSON.stringify of [kind, sid, prompt id, gateway run id, mirror token]), + # so delimiter-bearing producer strings cannot collide. + assert "function _promptNotifyKey(kind, sid, pending){" in MESSAGES_JS + assert "return JSON.stringify([kind, String(sid || ''), String(id)," in MESSAGES_JS + assert "const key = _promptNotifyKey(kind, sid, p);" in MESSAGES_JS assert "if (_promptNotifySeen.has(key)) return;" in MESSAGES_JS - assert "_PROMPT_NOTIFY_TTL_MS" in MESSAGES_JS + # Entries retire by prompt LIFECYCLE (the pending-clear chokepoints), never + # by wall-clock age - a prompt pending >10min must not re-notify. + assert "_PROMPT_NOTIFY_TTL_MS" not in MESSAGES_JS + assert "function _retirePromptNotifyKey(kind, sid, pending){" in MESSAGES_JS + assert "_retirePromptNotifyKey('approval', sid, entry.pending);" in MESSAGES_JS + assert "_retirePromptNotifyKey('clarify', sid, entry.pending);" in MESSAGES_JS + # Disabled notifications must not consume the notify, so enabling + # mid-prompt still re-notifies that owner. + assert "!window._notificationsEnabled) return;" in MESSAGES_JS # Visibility gate: suppress only while the prompt's session is actively # viewed (open in pane + tab visible + tab focused). All three of the # user's notification cases fire: non-active session, hidden tab, @@ -144,6 +155,7 @@ def test_notify_prompt_card_executed_gate_behavior(): (4) notifies when the tab is hidden or the window unfocused (cases 2-3), with the right title/body and sid routing.""" helper = _extract_fn(MESSAGES_JS, "_notifyPromptCard") + keyfn = _extract_fn(MESSAGES_JS, "_promptNotifyKey") viewed = _extract_fn(MESSAGES_JS, "_isSessionActivelyViewed") current_pane = _extract_fn(MESSAGES_JS, "_isSessionCurrentPane") visible = _extract_fn(MESSAGES_JS, "_isDocumentVisibleAndFocused") @@ -156,6 +168,7 @@ const S = {{ session: {{ session_id: 'sid-1' }} }}; {current_pane} {visible} {viewed} +{keyfn} {helper} const sent = []; function sendBrowserNotification(title, body, options) {{ sent.push({{ title, body, options }}); }} @@ -236,6 +249,150 @@ process.stdout.write(JSON.stringify({{ CASES, sent }})); # document.hidden") would otherwise veto the unfocused-but-visible case. assert all(o["options"].get("forceHidden") for o in sent) +# ── Reviewer reproduction regressions (PR #7493 round 2) ──────────────────── + +def test_owner_key_is_injective(): + """The dedupe key is built from a typed owner TUPLE serialized with + JSON.stringify, so delimiter-bearing producer strings (the gateway + accepts approval_id as an unrestricted string) cannot collide with a + different owner that merely contains the same delimiters.""" + helper = _extract_fn(MESSAGES_JS, "_notifyPromptCard") + keyfn = _extract_fn(MESSAGES_JS, "_promptNotifyKey") + viewed = _extract_fn(MESSAGES_JS, "_isSessionActivelyViewed") + current_pane = _extract_fn(MESSAGES_JS, "_isSessionCurrentPane") + visible = _extract_fn(MESSAGES_JS, "_isDocumentVisibleAndFocused") + script = f""" +const document = {{ hidden: true, visibilityState: 'hidden', hasFocus: () => false }}; +const _promptNotifySeen = new Map(); +let _loadingSessionId = null; +const S = {{ session: {{ session_id: 'sid-1' }} }}; +{current_pane} +{visible} +{viewed} +{keyfn} +{helper} +const sent = []; +function sendBrowserNotification(title, body, options) {{ sent.push({{ title, body, options }}); }} +// Owner 1: approval_id='x' with the full gateway-owner pair. +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'x', run_id: 'r', _gateway_mirror_token: 't', description: 'owner one' }}); +// Owner 2: approval_id CONTAINING the exact delimiter sequence of owner 1, +// with NO gateway-owner fields - a plain concatenation key would collide +// here and suppress this second, distinct owner. +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'x\u0000r\u0000t', description: 'owner two' }}); +process.stdout.write(JSON.stringify({{ count: sent.length, bodies: sent.map(s => s.body) }})); +""" + result = _run_node(script) + assert result["count"] == 2, f"two distinct owners must both notify (got {result})" + assert result["bodies"] == ['owner one', 'owner two'] + +def test_same_pending_owner_does_not_renotify_on_wall_clock(): + """A prompt left pending beyond the old 10-minute TTL must NOT re-notify: + seen entries retire on prompt lifecycle (resolution), not wall-clock age. + Simulated by backdating the seen entry far past the retired TTL window.""" + helper = _extract_fn(MESSAGES_JS, "_notifyPromptCard") + keyfn = _extract_fn(MESSAGES_JS, "_promptNotifyKey") + viewed = _extract_fn(MESSAGES_JS, "_isSessionActivelyViewed") + current_pane = _extract_fn(MESSAGES_JS, "_isSessionCurrentPane") + visible = _extract_fn(MESSAGES_JS, "_isDocumentVisibleAndFocused") + script = f""" +const document = {{ hidden: true, visibilityState: 'hidden', hasFocus: () => false }}; +const _promptNotifySeen = new Map(); +let _loadingSessionId = null; +const S = {{ session: {{ session_id: 'sid-1' }} }}; +{current_pane} +{visible} +{viewed} +{keyfn} +{helper} +const sent = []; +function sendBrowserNotification(title, body, options) {{ sent.push({{ title, body, options }}); }} +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'pending-1', description: 'first ping' }}); +// Backdate the seen entry by 24 hours - far past the old TTL. With +// lifecycle-based retirement this must NOT re-notify on the next poll tick. +const ownerKey = _promptNotifyKey('approval', 'sid-1', {{ approval_id: 'pending-1' }}); +_promptNotifySeen.set(ownerKey, Date.now() - 24 * 60 * 60 * 1000); +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'pending-1', description: 'poll tick 24h later' }}); +process.stdout.write(JSON.stringify({{ count: sent.length }})); +""" + result = _run_node(script) + assert result["count"] == 1, f"still-pending owner must not re-notify after 24h (got {result})" + +def test_resolved_owner_allows_legitimate_id_reuse(): + """When a prompt's lifecycle ENDS (resolution/dismissal clears pending + state via _clearApprovalPendingForSession), the seen entry is retired, so + a later prompt reusing the same ID notifies again.""" + helper = _extract_fn(MESSAGES_JS, "_notifyPromptCard") + keyfn = _extract_fn(MESSAGES_JS, "_promptNotifyKey") + retire = _extract_fn(MESSAGES_JS, "_retirePromptNotifyKey") + viewed = _extract_fn(MESSAGES_JS, "_isSessionActivelyViewed") + current_pane = _extract_fn(MESSAGES_JS, "_isSessionCurrentPane") + visible = _extract_fn(MESSAGES_JS, "_isDocumentVisibleAndFocused") + script = f""" +const document = {{ hidden: true, visibilityState: 'hidden', hasFocus: () => false }}; +const _promptNotifySeen = new Map(); +let _loadingSessionId = null; +const S = {{ session: {{ session_id: 'sid-1' }} }}; +{current_pane} +{visible} +{viewed} +{keyfn} +{retire} +{helper} +const sent = []; +function sendBrowserNotification(title, body, options) {{ sent.push({{ title, body, options }}); }} +const pending1 = {{ approval_id: 'id-1', run_id: 'r1', _gateway_mirror_token: 't1', description: 'first' }}; +_notifyPromptCard('approval', 'sid-1', pending1); +// Resolution path: the pending map entry clears, which must retire the +// dedupe entry for that exact owner. +_retirePromptNotifyKey('approval', 'sid-1', pending1); +// Same session + same ID + same run owner later: legitimate reuse, notifies. +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'id-1', run_id: 'r1', _gateway_mirror_token: 't1', description: 'reused after resolution' }}); +// But an owner whose prompt is STILL pending stays deduped. +_notifyPromptCard('approval', 'sid-1', pending1); +process.stdout.write(JSON.stringify({{ count: sent.length, bodies: sent.map(s => s.body) }})); +""" + result = _run_node(script) + assert result["count"] == 2, f"resolved owner ID reuse must notify again, still-pending owner must not (got {result})" + assert result["bodies"] == ['first', 'reused after resolution'] + +def test_disabled_notifications_do_not_consume_notify(): + """When notifications are disabled, no seen entry is recorded - so the + same owner still notifies once the user enables notifications mid-prompt. + Failed/denied delivery must not permanently consume the notification.""" + helper = _extract_fn(MESSAGES_JS, "_notifyPromptCard") + keyfn = _extract_fn(MESSAGES_JS, "_promptNotifyKey") + viewed = _extract_fn(MESSAGES_JS, "_isSessionActivelyViewed") + current_pane = _extract_fn(MESSAGES_JS, "_isSessionCurrentPane") + visible = _extract_fn(MESSAGES_JS, "_isDocumentVisibleAndFocused") + script = f""" +const document = {{ hidden: true, visibilityState: 'hidden', hasFocus: () => false }}; +const window = {{ _notificationsEnabled: false }}; +const _promptNotifySeen = new Map(); +let _loadingSessionId = null; +const S = {{ session: {{ session_id: 'sid-1' }} }}; +{current_pane} +{visible} +{viewed} +{keyfn} +{helper} +const sent = []; +function sendBrowserNotification(title, body, options) {{ sent.push({{ title, body, options }}); }} +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'd1', description: 'while disabled' }}); +// Enabling notifications mid-prompt must re-notify the SAME owner. +window._notificationsEnabled = true; +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'd1', description: 'after enabling' }}); +// And once the owner HAS been notified while enabled, dedupe holds. +_notifyPromptCard('approval', 'sid-1', {{ approval_id: 'd1', description: 'repeat while enabled' }}); +// The disabled first call recorded nothing; the enabled second call is now +// the consumed notify for this owner. +const ownerKey = _promptNotifyKey('approval', 'sid-1', {{ approval_id: 'd1' }}); +process.stdout.write(JSON.stringify({{ count: sent.length, bodies: sent.map(s => s.body), consumed: _promptNotifySeen.has(ownerKey) }})); +""" + result = _run_node(script) + assert result["count"] == 2 or result["count"] == 1, f"unexpected ping count (got {result})" + assert result["bodies"][0] == 'after enabling', f"enabling mid-prompt must re-notify the same owner (got {result})" + assert result["consumed"] is True, "the enabled notify must be recorded (consumed) for the owner" + def test_completion_notification_preview_uses_settled_message_not_live_prefix(): """Background completion preview must not slice the live-stream accumulator.""" From 78affab91cd14b0b559fc1da93b52937e26433dd Mon Sep 17 00:00:00 2001 From: Charles Inglis Date: Sat, 12 Sep 2026 18:46:56 -0500 Subject: [PATCH 017/115] fix(notifications): retire displaced prompt notify keys on pending replacement Greptile P1: when a new approval/clarify replaces the per-session pending entry, the displaced prompt's notification-dedupe key was never retired. Cleanup only removed the key of the currently stored prompt, and since dedupe entries no longer expire on a wall-clock TTL, a stale key persisted forever - so the same externally supplied prompt ID re-surfacing later was wrongly suppressed as already-notified. _rememberApprovalPending() and _rememberClarifyPending() now retire the displaced entry's notify key on owner change. A re-render of the SAME owner serializes to the same typed tuple key, so repeated poll ticks stay deduped. Regression: node-VM test drives displacement, same-owner re-render, and clear retirement for both approval and clarify paths (fails on the prior head, passes here). --- static/messages.js | 18 ++++++++ tests/test_pwa_notification_controls.py | 59 +++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/static/messages.js b/static/messages.js index 931a59b6..24744498 100644 --- a/static/messages.js +++ b/static/messages.js @@ -7477,6 +7477,16 @@ function _rememberApprovalPending(pending, pendingCount) { if (!pending) return null; const sid = pending._session_id || _promptActiveSessionId(); if (!sid) return null; + const prev = _approvalPendingBySession.get(sid); + // A replacement pending entry DISPLACES the previous prompt for this + // session: retire the displaced prompt notification-dedupe key so the same + // externally supplied ID can legitimately notify again later. A re-render + // of the SAME prompt serializes to the same owner key - skip retirement + // there so repeated poll ticks stay deduped. + if (prev && prev.pending + && _promptNotifyKey("approval", sid, prev.pending) !== _promptNotifyKey("approval", sid, pending)) { + _retirePromptNotifyKey("approval", sid, prev.pending); + } const nextPending = {...pending, _session_id: sid}; _approvalPendingBySession.set(sid, {pending: nextPending, pendingCount: pendingCount || 1}); return sid; @@ -8449,6 +8459,14 @@ function _rememberClarifyPending(pending) { if (!pending) return null; const sid = pending._session_id || _promptActiveSessionId(); if (!sid) return null; + const prev = _clarifyPendingBySession.get(sid); + // Mirror of the approval displacement retirement above: a NEW clarification + // replaces the session pending entry, so the displaced prompt dedupe key + // retires (same-owner re-renders keep their key). + if (prev && prev.pending + && _promptNotifyKey("clarify", sid, prev.pending) !== _promptNotifyKey("clarify", sid, pending)) { + _retirePromptNotifyKey("clarify", sid, prev.pending); + } const nextPending = {...pending, _session_id: sid}; _clarifyPendingBySession.set(sid, {pending: nextPending}); return sid; diff --git a/tests/test_pwa_notification_controls.py b/tests/test_pwa_notification_controls.py index 0ab9d89d..cd7257be 100644 --- a/tests/test_pwa_notification_controls.py +++ b/tests/test_pwa_notification_controls.py @@ -394,6 +394,65 @@ process.stdout.write(JSON.stringify({{ count: sent.length, bodies: sent.map(s => assert result["consumed"] is True, "the enabled notify must be recorded (consumed) for the owner" +def test_displaced_pending_prompt_key_is_retired_on_replacement(): + """Greptile round-2 regression: when a NEW approval/clarify replaces the + session pending entry, the DISPLACED prompt notification-dedupe key must + retire. Without retirement (and with TTL-based expiry gone) the stale key + persists forever, so the same externally supplied ID re-surfacing later is + wrongly suppressed as already-notified. A re-render of the SAME owner must + keep its key (still deduped).""" + remember_ap = _extract_fn(MESSAGES_JS, "_rememberApprovalPending") + remember_cl = _extract_fn(MESSAGES_JS, "_rememberClarifyPending") + clear_ap = _extract_fn(MESSAGES_JS, "_clearApprovalPendingForSession") + keyfn = _extract_fn(MESSAGES_JS, "_promptNotifyKey") + retire = _extract_fn(MESSAGES_JS, "_retirePromptNotifyKey") + active = _extract_fn(MESSAGES_JS, "_promptActiveSessionId") + script = f""" +const _promptNotifySeen = new Map(); +const S = {{ session: {{ session_id: "sid-1" }} }}; +{keyfn} +{retire} +{active} +{remember_ap} +{remember_cl} +{clear_ap} +const _approvalPendingBySession = new Map(); +const _clarifyPendingBySession = new Map(); +// Displaced approval owner: id-1, then a NEW different approval for sid-1. +_rememberApprovalPending({{ approval_id: "id-1", description: "first" }}, 1); +const key1 = _promptNotifyKey("approval", "sid-1", {{ approval_id: "id-1" }}); +_promptNotifySeen.set(key1, Date.now()); +_rememberApprovalPending({{ approval_id: "id-2", description: "replacement" }}, 1); +const displacedRetired = !_promptNotifySeen.has(key1); +// Same-owner re-render must NOT retire its own key (seed it as already +// notified, as a prior poll tick would have). +const key2 = _promptNotifyKey("approval", "sid-1", {{ approval_id: "id-2" }}); +_promptNotifySeen.set(key2, Date.now()); +_rememberApprovalPending({{ approval_id: "id-2", description: "re-render same owner" }}, 1); +const sameOwnerKept = _promptNotifySeen.has(key2); +// Clear path retires the CURRENT entry. +_clearApprovalPendingForSession("sid-1"); +const clearedRetired = !_promptNotifySeen.has(_promptNotifyKey("approval", "sid-1", {{ approval_id: "id-2" }})); +// Clarify mirror: displacement retires, same-owner re-render keeps. +_rememberClarifyPending({{ clarify_id: "c-1" }}); +const ckey1 = _promptNotifyKey("clarify", "sid-1", {{ clarify_id: "c-1" }}); +_promptNotifySeen.set(ckey1, Date.now()); +_rememberClarifyPending({{ clarify_id: "c-2" }}); +const clarifyDisplacedRetired = !_promptNotifySeen.has(ckey1); +const ckey2 = _promptNotifyKey("clarify", "sid-1", {{ clarify_id: "c-2" }}); +_promptNotifySeen.set(ckey2, Date.now()); +_rememberClarifyPending({{ clarify_id: "c-2" }}); +const clarifySameOwnerKept = _promptNotifySeen.has(ckey2); +process.stdout.write(JSON.stringify({{ displacedRetired, sameOwnerKept, clearedRetired, clarifyDisplacedRetired, clarifySameOwnerKept }})); +""" + result = _run_node(script) + assert result["displacedRetired"] is True, f"displaced approval key must retire on replacement (got {result})" + assert result["sameOwnerKept"] is True, f"same-owner re-render must keep its dedupe key (got {result})" + assert result["clearedRetired"] is True, f"clear path must retire the current entry key (got {result})" + assert result["clarifyDisplacedRetired"] is True, f"displaced clarify key must retire on replacement (got {result})" + assert result["clarifySameOwnerKept"] is True, f"clarify same-owner re-render must keep its key (got {result})" + + def test_completion_notification_preview_uses_settled_message_not_live_prefix(): """Background completion preview must not slice the live-stream accumulator.""" assert "function _completionNotificationPreviewText" in MESSAGES_JS From ac49d623f58f5a841d3e1d5eab094821d11ba48f Mon Sep 17 00:00:00 2001 From: CharlesMcquade <6466275+CharlesMcquade@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:56:39 -0500 Subject: [PATCH 018/115] fix(notifications): close prompt lifecycle races --- static/boot.js | 2 + static/messages.js | 55 ++++++++++++++++--- ...t_issue4771_gateway_approval_failure_ui.py | 12 ++++ tests/test_pwa_notification_controls.py | 10 ++++ 4 files changed, 72 insertions(+), 7 deletions(-) diff --git a/static/boot.js b/static/boot.js index 526caa24..721f2b13 100644 --- a/static/boot.js +++ b/static/boot.js @@ -59,6 +59,7 @@ async function cancelStream(reason){ else setStatus(''); // /api/chat/cancel only exposes `cancelled:bool`, so we cannot // distinguish reasons — keep the toast generic and short. + if(typeof _clearPendingPromptsForSession==='function') _clearPendingPromptsForSession(sid); if(typeof showToast==='function') showToast('Stream is no longer active',2000); } return respOk; @@ -91,6 +92,7 @@ async function cancelSessionStream(session){ if(typeof setComposerStatus==='function') setComposerStatus(''); else setStatus(''); } + if(typeof _clearPendingPromptsForSession==='function') _clearPendingPromptsForSession(sid); if(typeof _approvalSessionId!=='undefined' && _approvalSessionId===sid){ stopApprovalPolling(); hideApprovalCard(true); diff --git a/static/messages.js b/static/messages.js index 24744498..85261739 100644 --- a/static/messages.js +++ b/static/messages.js @@ -7414,6 +7414,7 @@ function hideApprovalCard(force=false) { let _approvalSessionId = null; let _approvalCurrentId = null; // approval_id of the card currently shown let _approvalPendingBySession = new Map(); +const _approvalPromptGenerationBySession = new Map(); let _approvalResponding = null; let _approvalClearedOwner = null; let _approvalDisplayedOwner = null; @@ -7473,6 +7474,14 @@ function activeSessionHasPendingPromptAttention() { )); } +function _approvalPromptGeneration(sid) { + return Number(_approvalPromptGenerationBySession.get(sid) || 0); +} + +function _bumpApprovalPromptGeneration(sid) { + if (sid) _approvalPromptGenerationBySession.set(sid, _approvalPromptGeneration(sid) + 1); +} + function _rememberApprovalPending(pending, pendingCount) { if (!pending) return null; const sid = pending._session_id || _promptActiveSessionId(); @@ -7486,6 +7495,9 @@ function _rememberApprovalPending(pending, pendingCount) { if (prev && prev.pending && _promptNotifyKey("approval", sid, prev.pending) !== _promptNotifyKey("approval", sid, pending)) { _retirePromptNotifyKey("approval", sid, prev.pending); + _bumpApprovalPromptGeneration(sid); + } else if (!prev) { + _bumpApprovalPromptGeneration(sid); } const nextPending = {...pending, _session_id: sid}; _approvalPendingBySession.set(sid, {pending: nextPending, pendingCount: pendingCount || 1}); @@ -7496,6 +7508,7 @@ function _clearApprovalPendingForSession(sid) { if (sid) { const entry = _approvalPendingBySession.get(sid); _approvalPendingBySession.delete(sid); + if (entry) _bumpApprovalPromptGeneration(sid); if (entry && entry.pending) _retirePromptNotifyKey('approval', sid, entry.pending); if (typeof syncTopbar === 'function') syncTopbar(); } @@ -7639,9 +7652,9 @@ function showApprovalForSession(sid, pending, pendingCount) { function showApprovalCard(pending, pendingCount) { const sid = _rememberApprovalPending(pending, pendingCount); + if (pending && pending.approval_id && _isApprovalDismissed(sid, pending.approval_id)) return; if(typeof _notifyPromptCard==='function') _notifyPromptCard('approval', sid, pending); if (!_approvalPromptBelongsToActiveSession(sid)) return; - if (pending && pending.approval_id && _isApprovalDismissed(sid, pending.approval_id)) return; _approvalClearedOwner = null; const keys = pending.pattern_keys || (pending.pattern_key ? [pending.pattern_key] : []); const desc = (pending.description || "") + (keys.length ? " [" + keys.join(", ") + "]" : ""); @@ -7701,8 +7714,10 @@ function showApprovalCard(pending, pendingCount) { function dismissApprovalCard() { const sid = _approvalSessionId; if (_approvalCurrentId) _markApprovalDismissed(sid, _approvalCurrentId); + // Dismissal is local UI state, not authoritative prompt completion. Keep + // the pending owner and its notification-dedupe key until the server reports + // resolution or a terminal/cancel lifecycle clears it. hideApprovalCard(true); - if (sid) _clearApprovalPendingForSession(sid); } function _syncApprovalCollapseButton(card) { @@ -7894,7 +7909,9 @@ function _startApprovalFallbackPoll(sid) { if (_approvalFallbackPollInFlight) return; _approvalFallbackPollInFlight = true; try { + const generation = _approvalPromptGeneration(sid); const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false}); + if (_approvalPollingSessionMissingOrMismatched(sid) || _approvalPromptGeneration(sid) !== generation) return; if (data.pending) { showApprovalForSession(sid, data.pending, data.pending_count||1); } else if (!_approvalPollingSessionMissingOrMismatched(sid)) { const _resolvedEntry = _approvalPendingBySession.get(sid); @@ -8449,12 +8466,21 @@ let _clarifyMissingEndpointWarned = false; let _clarifyCountdownTimer = null; let _clarifyExpiresAt = 0; let _clarifyPendingBySession = new Map(); +const _clarifyPromptGenerationBySession = new Map(); const CLARIFY_MIN_VISIBLE_MS = 30000; function _clarifyPromptBelongsToActiveSession(sid) { return !!(sid && _promptActiveSessionId() === sid); } +function _clarifyPromptGeneration(sid) { + return Number(_clarifyPromptGenerationBySession.get(sid) || 0); +} + +function _bumpClarifyPromptGeneration(sid) { + if (sid) _clarifyPromptGenerationBySession.set(sid, _clarifyPromptGeneration(sid) + 1); +} + function _rememberClarifyPending(pending) { if (!pending) return null; const sid = pending._session_id || _promptActiveSessionId(); @@ -8466,6 +8492,9 @@ function _rememberClarifyPending(pending) { if (prev && prev.pending && _promptNotifyKey("clarify", sid, prev.pending) !== _promptNotifyKey("clarify", sid, pending)) { _retirePromptNotifyKey("clarify", sid, prev.pending); + _bumpClarifyPromptGeneration(sid); + } else if (!prev) { + _bumpClarifyPromptGeneration(sid); } const nextPending = {...pending, _session_id: sid}; _clarifyPendingBySession.set(sid, {pending: nextPending}); @@ -8476,11 +8505,17 @@ function _clearClarifyPendingForSession(sid) { if (sid) { const entry = _clarifyPendingBySession.get(sid); _clarifyPendingBySession.delete(sid); + if (entry) _bumpClarifyPromptGeneration(sid); if (entry && entry.pending) _retirePromptNotifyKey('clarify', sid, entry.pending); if (typeof syncTopbar === 'function') syncTopbar(); } } +function _clearPendingPromptsForSession(sid) { + _clearApprovalPendingForSession(sid); + _clearClarifyPendingForSession(sid); +} + function _hideClarifyCardIfOwner(sid, force=false, reason="dismissed") { if (!sid || _clarifySessionId === sid) hideClarifyCard(force, reason); } @@ -9015,7 +9050,9 @@ function _startClarifyFallbackPoll(sid) { if (_clarifyFallbackPollInFlight) return; _clarifyFallbackPollInFlight = true; try { + const generation = _clarifyPromptGeneration(sid); const data = await api("/api/clarify/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false}); + if (!S.session || S.session.session_id !== sid || _clarifyPromptGeneration(sid) !== generation) return; if (data.pending) { showClarifyForSession(sid, data.pending); } else { _clearClarifyPendingForSession(sid); _hideClarifyCardIfOwner(sid, false, 'expired'); } } catch(e) { @@ -9257,7 +9294,11 @@ const _promptNotifySeen = new Map(); // notifications are disabled, so enabling mid-prompt re-notifies that owner. function _promptNotifyKey(kind, sid, pending){ const p = pending || {}; - const id = p.approval_id || p.clarify_id || ''; + // Older clarify producers can omit clarify_id. Their stable prompt payload is + // still a usable lifecycle identity and is retired with the pending entry. + const id = p.approval_id || p.clarify_id || (kind === 'clarify' + ? JSON.stringify([p.question || p.description || '', p.choices_offered || p.choices || [], p.requested_at || '']) + : ''); const runId = String(p.run_id || '').trim(); const mirrorToken = String(p._gateway_mirror_token || '').trim(); return JSON.stringify([kind, String(sid || ''), String(id), @@ -9266,13 +9307,13 @@ function _promptNotifyKey(kind, sid, pending){ } function _retirePromptNotifyKey(kind, sid, pending){ if (!pending) return; - const id = pending.approval_id || pending.clarify_id || ''; - if (!id) return; - _promptNotifySeen.delete(_promptNotifyKey(kind, sid, pending)); + const key = _promptNotifyKey(kind, sid, pending); + if (!key) return; + _promptNotifySeen.delete(key); } function _notifyPromptCard(kind, sid, pending){ const p = pending || {}; - const id = p.approval_id || p.clarify_id || ''; + const id = p.approval_id || p.clarify_id || (kind === 'clarify' ? p.question || p.description || '' : ''); if (!id) return; const key = _promptNotifyKey(kind, sid, p); if (_promptNotifySeen.has(key)) return; diff --git a/tests/test_issue4771_gateway_approval_failure_ui.py b/tests/test_issue4771_gateway_approval_failure_ui.py index 4825f418..d0b0928d 100644 --- a/tests/test_issue4771_gateway_approval_failure_ui.py +++ b/tests/test_issue4771_gateway_approval_failure_ui.py @@ -67,6 +67,10 @@ def _run_failure_case(api_js: str) -> dict: _extract_fn(MESSAGES_JS, "_getDismissedApprovals"), _extract_fn(MESSAGES_JS, "_isApprovalDismissed"), _extract_fn(MESSAGES_JS, "_unmarkApprovalDismissed"), + _extract_fn(MESSAGES_JS, "_promptNotifyKey"), + _extract_fn(MESSAGES_JS, "_retirePromptNotifyKey"), + _extract_fn(MESSAGES_JS, "_approvalPromptGeneration"), + _extract_fn(MESSAGES_JS, "_bumpApprovalPromptGeneration"), _extract_fn(MESSAGES_JS, "_promptActiveSessionId"), _extract_fn(MESSAGES_JS, "_approvalPromptBelongsToActiveSession"), _extract_fn(MESSAGES_JS, "_rememberApprovalPending"), @@ -103,6 +107,7 @@ let renderCalls = 0; let _approvalSessionId = 'sess-1'; let _approvalCurrentId = 'appr-1'; let _approvalPendingBySession = new Map(); +const _approvalPromptGenerationBySession = new Map(); let _loadSessionGeneration = 1; let _approvalResponding = null; let _approvalClearedOwner = null; @@ -111,6 +116,7 @@ let _approvalSignature = ''; let _approvalVisibleSince = 0; let _approvalHideTimer = null; const _clarifyPendingBySession = new Map(); +const _promptNotifySeen = new Map(); const buttons = new Map(); function makeButton(id) {{ return {{ @@ -258,6 +264,10 @@ def test_poll_rerender_keeps_inflight_buttons_disabled_and_blocks_duplicates(): _extract_fn(MESSAGES_JS, "_getDismissedApprovals"), _extract_fn(MESSAGES_JS, "_isApprovalDismissed"), _extract_fn(MESSAGES_JS, "_unmarkApprovalDismissed"), + _extract_fn(MESSAGES_JS, "_promptNotifyKey"), + _extract_fn(MESSAGES_JS, "_retirePromptNotifyKey"), + _extract_fn(MESSAGES_JS, "_approvalPromptGeneration"), + _extract_fn(MESSAGES_JS, "_bumpApprovalPromptGeneration"), _extract_fn(MESSAGES_JS, "_promptActiveSessionId"), _extract_fn(MESSAGES_JS, "_approvalPromptBelongsToActiveSession"), _extract_fn(MESSAGES_JS, "_rememberApprovalPending"), @@ -289,6 +299,7 @@ const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals'; let _approvalSessionId = 'sess-1'; let _approvalCurrentId = 'appr-1'; let _approvalPendingBySession = new Map(); +const _approvalPromptGenerationBySession = new Map(); let _loadSessionGeneration = 1; let _approvalResponding = null; let _approvalClearedOwner = null; @@ -297,6 +308,7 @@ let _approvalSignature = ''; let _approvalVisibleSince = 0; let _approvalHideTimer = null; const _clarifyPendingBySession = new Map(); +const _promptNotifySeen = new Map(); let resolveApi; let apiCalls = 0; const buttons = new Map(); diff --git a/tests/test_pwa_notification_controls.py b/tests/test_pwa_notification_controls.py index cd7257be..48a72162 100644 --- a/tests/test_pwa_notification_controls.py +++ b/tests/test_pwa_notification_controls.py @@ -407,17 +407,27 @@ def test_displaced_pending_prompt_key_is_retired_on_replacement(): keyfn = _extract_fn(MESSAGES_JS, "_promptNotifyKey") retire = _extract_fn(MESSAGES_JS, "_retirePromptNotifyKey") active = _extract_fn(MESSAGES_JS, "_promptActiveSessionId") + approval_generation = _extract_fn(MESSAGES_JS, "_approvalPromptGeneration") + bump_approval_generation = _extract_fn(MESSAGES_JS, "_bumpApprovalPromptGeneration") + clarify_generation = _extract_fn(MESSAGES_JS, "_clarifyPromptGeneration") + bump_clarify_generation = _extract_fn(MESSAGES_JS, "_bumpClarifyPromptGeneration") script = f""" const _promptNotifySeen = new Map(); const S = {{ session: {{ session_id: "sid-1" }} }}; {keyfn} {retire} {active} +{approval_generation} +{bump_approval_generation} +{clarify_generation} +{bump_clarify_generation} {remember_ap} {remember_cl} {clear_ap} const _approvalPendingBySession = new Map(); const _clarifyPendingBySession = new Map(); +const _approvalPromptGenerationBySession = new Map(); +const _clarifyPromptGenerationBySession = new Map(); // Displaced approval owner: id-1, then a NEW different approval for sid-1. _rememberApprovalPending({{ approval_id: "id-1", description: "first" }}, 1); const key1 = _promptNotifyKey("approval", "sid-1", {{ approval_id: "id-1" }}); From 819d3661e13e19c543bf9a86661931ba471bf668 Mon Sep 17 00:00:00 2001 From: CharlesMcquade <6466275+CharlesMcquade@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:04:58 -0500 Subject: [PATCH 019/115] test(notifications): include prompt generation helpers --- tests/test_issue3668_prompt_card_reshow_on_switch.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_issue3668_prompt_card_reshow_on_switch.py b/tests/test_issue3668_prompt_card_reshow_on_switch.py index b13aabcf..e8aa5485 100644 --- a/tests/test_issue3668_prompt_card_reshow_on_switch.py +++ b/tests/test_issue3668_prompt_card_reshow_on_switch.py @@ -79,6 +79,8 @@ function extractFunc(name){ // ── module-scope state the extracted functions close over ── let _clarifyPendingBySession = new Map(); let _approvalPendingBySession = new Map(); +let _clarifyPromptGenerationBySession = new Map(); +let _approvalPromptGenerationBySession = new Map(); let _clarifySessionId = null; let _approvalSessionId = null; let S = { session: null }; @@ -109,6 +111,10 @@ function hideApprovalCard(force){ // ── real functions under test ── eval(extractFunc('_promptActiveSessionId')); +eval(extractFunc('_clarifyPromptGeneration')); +eval(extractFunc('_bumpClarifyPromptGeneration')); +eval(extractFunc('_approvalPromptGeneration')); +eval(extractFunc('_bumpApprovalPromptGeneration')); eval(extractFunc('_clarifyPromptBelongsToActiveSession')); eval(extractFunc('_rememberClarifyPending')); eval(extractFunc('_clearClarifyPendingForSession')); From fabf5e0b7845a5f51e2a9f81f81b63056445283d Mon Sep 17 00:00:00 2001 From: CharlesMcquade <6466275+CharlesMcquade@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:11:21 -0500 Subject: [PATCH 020/115] fix(notifications): clear dismissed approval attention --- static/messages.js | 10 +++++++--- tests/test_pwa_notification_controls.py | 1 + 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/static/messages.js b/static/messages.js index 85261739..f160fe17 100644 --- a/static/messages.js +++ b/static/messages.js @@ -7486,6 +7486,10 @@ function _rememberApprovalPending(pending, pendingCount) { if (!pending) return null; const sid = pending._session_id || _promptActiveSessionId(); if (!sid) return null; + if (pending.approval_id && _isApprovalDismissed(sid, pending.approval_id)) { + _clearApprovalPendingForSession(sid); + return sid; + } const prev = _approvalPendingBySession.get(sid); // A replacement pending entry DISPLACES the previous prompt for this // session: retire the displaced prompt notification-dedupe key so the same @@ -7714,9 +7718,9 @@ function showApprovalCard(pending, pendingCount) { function dismissApprovalCard() { const sid = _approvalSessionId; if (_approvalCurrentId) _markApprovalDismissed(sid, _approvalCurrentId); - // Dismissal is local UI state, not authoritative prompt completion. Keep - // the pending owner and its notification-dedupe key until the server reports - // resolution or a terminal/cancel lifecycle clears it. + // Dismissal clears local attention while the server remains authoritative. + // A later poll of the same dismissed approval stays suppressed below. + _clearApprovalPendingForSession(sid); hideApprovalCard(true); } diff --git a/tests/test_pwa_notification_controls.py b/tests/test_pwa_notification_controls.py index 48a72162..beaad244 100644 --- a/tests/test_pwa_notification_controls.py +++ b/tests/test_pwa_notification_controls.py @@ -413,6 +413,7 @@ def test_displaced_pending_prompt_key_is_retired_on_replacement(): bump_clarify_generation = _extract_fn(MESSAGES_JS, "_bumpClarifyPromptGeneration") script = f""" const _promptNotifySeen = new Map(); +const _isApprovalDismissed = () => false; const S = {{ session: {{ session_id: "sid-1" }} }}; {keyfn} {retire} From 6f593f80e88f9c9f3c8a314cc4e5362c03e101c8 Mon Sep 17 00:00:00 2001 From: CharlesMcquade <6466275+CharlesMcquade@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:21:54 -0500 Subject: [PATCH 021/115] fix(notifications): preserve prompt owners through lifecycle exits --- README.md | 1 + static/boot.js | 2 + static/messages.js | 72 ++++++----- ...test_issue4754_approval_dismiss_persist.py | 48 +------- tests/test_prompt_notification_lifecycle.py | 112 ++++++++++++++++++ tests/test_pwa_notification_controls.py | 67 +---------- 6 files changed, 163 insertions(+), 139 deletions(-) create mode 100644 tests/test_prompt_notification_lifecycle.py diff --git a/README.md b/README.md index 7e73b9f1..e75397fa 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,7 @@ If an AI assistant is helping with install, reinstall, bootstrap, provider setup - Unsaved changes guard -- discard/save prompt when closing with unpersisted changes - Cron completion alerts -- toast notifications and unread badges scoped to the active profile on the Tasks tab and session sidebar - Background agent error alerts -- banner when a non-active session encounters an error +- Approval and clarification browser alerts notify once per pending owner when its session is not actively viewed. Local approval dismissal hides attention without resolving the prompt; resolution, replacement, and cancellation retire that owner's alert state. Denied or failed notification delivery can retry while the prompt remains pending. ### Slash commands - Type `/` in the composer for autocomplete dropdown diff --git a/static/boot.js b/static/boot.js index 721f2b13..755751cc 100644 --- a/static/boot.js +++ b/static/boot.js @@ -93,6 +93,8 @@ async function cancelSessionStream(session){ else setStatus(''); } if(typeof _clearPendingPromptsForSession==='function') _clearPendingPromptsForSession(sid); + if(typeof stopApprovalPollingForSession==='function') stopApprovalPollingForSession(sid); + if(typeof stopClarifyPollingForSession==='function') stopClarifyPollingForSession(sid); if(typeof _approvalSessionId!=='undefined' && _approvalSessionId===sid){ stopApprovalPolling(); hideApprovalCard(true); diff --git a/static/messages.js b/static/messages.js index f160fe17..58ee6ed6 100644 --- a/static/messages.js +++ b/static/messages.js @@ -7421,13 +7421,12 @@ let _approvalDisplayedOwner = null; const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals'; -// Dismissed approvals are namespaced by session so that two sessions carrying -// the SAME approval_id (e.g. a gateway/run source that reuses externally -// supplied IDs across sessions) can't have a dismissal in one session hide the -// other's still-pending approval. Stored value is "\u0000". +// Dismissal uses the same injective full-owner tuple as notifications. +// Legacy session/ID-only tombstones cannot safely identify a gateway run. function _approvalDismissKey(sid, approvalId) { if (!approvalId) return ''; - return String(sid || '') + '\u0000' + String(approvalId); + const pending = typeof approvalId === 'object' ? approvalId : {approval_id: approvalId}; + return _promptNotifyKey('approval', sid, pending); } function _getDismissedApprovals() { @@ -7469,7 +7468,8 @@ function _approvalPromptBelongsToActiveSession(sid) { function activeSessionHasPendingPromptAttention() { const sid = _promptActiveSessionId(); return !!(sid && ( - _approvalPendingBySession.has(sid) || + (_approvalPendingBySession.has(sid) && + !_isApprovalDismissed(sid, _approvalPendingBySession.get(sid).pending)) || _clarifyPendingBySession.has(sid) )); } @@ -7486,10 +7486,6 @@ function _rememberApprovalPending(pending, pendingCount) { if (!pending) return null; const sid = pending._session_id || _promptActiveSessionId(); if (!sid) return null; - if (pending.approval_id && _isApprovalDismissed(sid, pending.approval_id)) { - _clearApprovalPendingForSession(sid); - return sid; - } const prev = _approvalPendingBySession.get(sid); // A replacement pending entry DISPLACES the previous prompt for this // session: retire the displaced prompt notification-dedupe key so the same @@ -7499,6 +7495,7 @@ function _rememberApprovalPending(pending, pendingCount) { if (prev && prev.pending && _promptNotifyKey("approval", sid, prev.pending) !== _promptNotifyKey("approval", sid, pending)) { _retirePromptNotifyKey("approval", sid, prev.pending); + _unmarkApprovalDismissed(sid, prev.pending); _bumpApprovalPromptGeneration(sid); } else if (!prev) { _bumpApprovalPromptGeneration(sid); @@ -7512,7 +7509,8 @@ function _clearApprovalPendingForSession(sid) { if (sid) { const entry = _approvalPendingBySession.get(sid); _approvalPendingBySession.delete(sid); - if (entry) _bumpApprovalPromptGeneration(sid); + _bumpApprovalPromptGeneration(sid); + if (entry && entry.pending) _unmarkApprovalDismissed(sid, entry.pending); if (entry && entry.pending) _retirePromptNotifyKey('approval', sid, entry.pending); if (typeof syncTopbar === 'function') syncTopbar(); } @@ -7656,7 +7654,7 @@ function showApprovalForSession(sid, pending, pendingCount) { function showApprovalCard(pending, pendingCount) { const sid = _rememberApprovalPending(pending, pendingCount); - if (pending && pending.approval_id && _isApprovalDismissed(sid, pending.approval_id)) return; + if (pending && pending.approval_id && _isApprovalDismissed(sid, pending)) return; if(typeof _notifyPromptCard==='function') _notifyPromptCard('approval', sid, pending); if (!_approvalPromptBelongsToActiveSession(sid)) return; _approvalClearedOwner = null; @@ -7717,10 +7715,11 @@ function showApprovalCard(pending, pendingCount) { function dismissApprovalCard() { const sid = _approvalSessionId; - if (_approvalCurrentId) _markApprovalDismissed(sid, _approvalCurrentId); - // Dismissal clears local attention while the server remains authoritative. - // A later poll of the same dismissed approval stays suppressed below. - _clearApprovalPendingForSession(sid); + const entry = _approvalPendingBySession.get(sid); + if (entry && entry.pending) _markApprovalDismissed(sid, entry.pending); + // Keep the unresolved owner for authoritative resolution/replacement. + // Local dismissal hides attention, not the server-owned prompt. + if (typeof syncTopbar === 'function') syncTopbar(); hideApprovalCard(true); } @@ -8509,7 +8508,7 @@ function _clearClarifyPendingForSession(sid) { if (sid) { const entry = _clarifyPendingBySession.get(sid); _clarifyPendingBySession.delete(sid); - if (entry) _bumpClarifyPromptGeneration(sid); + _bumpClarifyPromptGeneration(sid); if (entry && entry.pending) _retirePromptNotifyKey('clarify', sid, entry.pending); if (typeof syncTopbar === 'function') syncTopbar(); } @@ -9332,16 +9331,23 @@ function _notifyPromptCard(kind, sid, pending){ if (typeof _isSessionActivelyViewed === 'function' && _isSessionActivelyViewed(sid)) return; if (typeof sendBrowserNotification !== 'function') return; if (typeof window !== 'undefined' && !window._notificationsEnabled) return; - _promptNotifySeen.set(key, Date.now()); + const attempt = {}; + _promptNotifySeen.set(key, attempt); // forceHidden: this caller already made the visibility decision (the // actively-viewed gate above). sendBrowserNotification's own live gate // ("notify only when document.hidden") would otherwise veto the // unfocused-but-visible case, which this feature explicitly notifies for. - if (kind === 'clarify') { - sendBrowserNotification('Clarification needed', p.question || p.description || 'Tool clarification needed', { sid, forceHidden: true }); - } else { - sendBrowserNotification('Approval required', p.description || p.command || 'Tool approval needed', { sid, forceHidden: true }); - } + const rollback = () => { + if (_promptNotifySeen.get(key) === attempt) _promptNotifySeen.delete(key); + }; + try { + const delivery = kind === 'clarify' + ? sendBrowserNotification('Clarification needed', p.question || p.description || 'Tool clarification needed', { sid, forceHidden: true }) + : sendBrowserNotification('Approval required', p.description || p.command || 'Tool approval needed', { sid, forceHidden: true }); + Promise.resolve(delivery).then(accepted => { + if (accepted !== true) rollback(); + }, rollback); + } catch (_) { rollback(); } } function sendBrowserNotification(title,body,options={}){ const force=!!(options&&options.force); @@ -9355,14 +9361,24 @@ function sendBrowserNotification(title,body,options={}){ if(!force&&!window._notificationsEnabled) return; if(!force&&!forceHidden&&!_isBackgroundedForBrowserNotification()) return; if(!('Notification' in window)) return; - if(Notification.permission==='granted'){ - _showPwaNotification(title,body,options).catch(()=>{try{new Notification(title||assistantDisplayName(),_notificationOptions(body,options));}catch(_err){}}); - }else if(Notification.permission==='denied'){ + const deliver = async () => { + try { + await _showPwaNotification(title,body,options); + return true; + } catch (_) { + try { + new Notification(title||assistantDisplayName(),_notificationOptions(body,options)); + return true; + } catch (_err) { return false; } + } + }; + if(Notification.permission==='granted') return deliver(); + if(Notification.permission==='denied'){ // Explicit "Send test" (force) deserves feedback instead of a silent no-op. if(force&&typeof showToast==='function') showToast(t('notifications_denied'),3500,'error'); - }else{ - requestNotificationPermission().then(p=>{if(p==='granted') _showPwaNotification(title,body,options).catch(()=>{try{new Notification(title||assistantDisplayName(),_notificationOptions(body,options));}catch(_err){}});}); + return false; } + return requestNotificationPermission().then(p => p==='granted' ? deliver() : false).catch(() => false); } // ── /btw ephemeral stream ──────────────────────────────────────────────────── diff --git a/tests/test_issue4754_approval_dismiss_persist.py b/tests/test_issue4754_approval_dismiss_persist.py index 2a97f379..878a56de 100644 --- a/tests/test_issue4754_approval_dismiss_persist.py +++ b/tests/test_issue4754_approval_dismiss_persist.py @@ -53,45 +53,12 @@ def test_dismissed_set_capped_at_100(): # Guard in showApprovalCard # --------------------------------------------------------------------------- -def test_guard_in_show_approval_card(): - compact = _compact(MESSAGES_JS) - # Guard must appear inside showApprovalCard, after _rememberApprovalPending - func_start = compact.find("functionshowApprovalCard(") - assert func_start != -1 - # Locate the guard after the function start (dismissals are namespaced by - # session, so the guard passes sid + approval_id). - guard = "_isApprovalDismissed(sid,pending.approval_id)" - guard_idx = compact.find(guard, func_start) - assert guard_idx != -1, "guard _isApprovalDismissed must appear in showApprovalCard" - # _rememberApprovalPending must appear before the guard - remember = "_rememberApprovalPending(" - remember_idx = compact.find(remember, func_start) - assert remember_idx != -1 - assert remember_idx < guard_idx, "guard must come after _rememberApprovalPending" -def test_guard_returns_early(): - # The guard must be a return statement - compact = _compact(MESSAGES_JS) - assert "if(pending&&pending.approval_id&&_isApprovalDismissed(sid,pending.approval_id))return;" in compact - - -# --------------------------------------------------------------------------- -# dismissApprovalCard function -# --------------------------------------------------------------------------- - def test_dismiss_approval_card_defined(): assert "function dismissApprovalCard(" in MESSAGES_JS -def test_dismiss_approval_card_marks_dismissed(): - compact = _compact(MESSAGES_JS) - func_start = compact.find("functiondismissApprovalCard(") - assert func_start != -1 - body_end = compact.find("}", func_start) - body = compact[func_start:body_end + 1] - assert "_markApprovalDismissed(sid,_approvalCurrentId)" in body - def test_dismiss_approval_card_hides_card(): compact = _compact(MESSAGES_JS) @@ -102,19 +69,6 @@ def test_dismiss_approval_card_hides_card(): assert "hideApprovalCard(true)" in body -def test_dismiss_approval_card_clears_pending_attention(): - # dismissApprovalCard must call _clearApprovalPendingForSession so the tab - # indicator stops blinking after dismiss. Without this, _rememberApprovalPending - # reinsertes the pending entry on every poll tick and the indicator stays lit. - compact = _compact(MESSAGES_JS) - func_start = compact.find("functiondismissApprovalCard(") - assert func_start != -1 - body_end = compact.find("}", func_start) - body = compact[func_start:body_end + 1] - assert "_clearApprovalPendingForSession(sid)" in body, ( - "dismissApprovalCard must call _clearApprovalPendingForSession(sid)" - ) - def test_dismiss_approval_card_captures_sid_before_hide(): # The session ID must be captured before hideApprovalCard(true) nulls out @@ -286,7 +240,7 @@ def test_same_approval_id_in_two_sessions_does_not_collide(): pytest.skip("node not available") helpers = "\n".join( _extract_fn(MESSAGES_JS, n) - for n in ("_approvalDismissKey", "_getDismissedApprovals", + for n in ("_promptNotifyKey", "_approvalDismissKey", "_getDismissedApprovals", "_isApprovalDismissed", "_markApprovalDismissed", "_unmarkApprovalDismissed") ) script = ( diff --git a/tests/test_prompt_notification_lifecycle.py b/tests/test_prompt_notification_lifecycle.py new file mode 100644 index 00000000..d76ef2fa --- /dev/null +++ b/tests/test_prompt_notification_lifecycle.py @@ -0,0 +1,112 @@ +"""Composed prompt-owner and real delivery regressions for #7493.""" +from tests.test_pwa_notification_controls import MESSAGES_JS, _extract_fn, _run_node + + +def run(body, extra=()): + names = ['_promptNotifyKey', '_retirePromptNotifyKey', '_notifyPromptCard', + '_approvalPromptGeneration', '_bumpApprovalPromptGeneration', + '_clarifyPromptGeneration', '_bumpClarifyPromptGeneration', + '_rememberApprovalPending', '_clearApprovalPendingForSession', + '_clearClarifyPendingForSession', '_approvalDismissKey', + '_getDismissedApprovals', '_isApprovalDismissed', '_markApprovalDismissed', + '_unmarkApprovalDismissed', 'dismissApprovalCard', + '_startApprovalFallbackPoll', 'showApprovalCard', + 'activeSessionHasPendingPromptAttention'] + source = '\n'.join(_extract_fn(MESSAGES_JS, n) for n in names + list(extra)) + return _run_node(''' +const _promptNotifySeen = new Map(); +const _approvalPendingBySession = new Map(), _clarifyPendingBySession = new Map(); +const _approvalPromptGenerationBySession = new Map(), _clarifyPromptGenerationBySession = new Map(); +const _DISMISSED_APPROVALS_KEY = 'dismissed'; +const store = new Map(); +const localStorage = {getItem:k=>store.get(k), setItem:(k,v)=>store.set(k,v)}; +const S = {busy:true,session:{session_id:'s'}}; +const _approvalPromptBelongsToActiveSession=()=>false; +let _approvalSessionId = 's', _approvalCurrentId = 'id'; +let _approvalFallbackPollInFlight=false, _approvalPollTimer; +let _clarifyPollingSessionId, _clarifyPollTimer, _clarifyFallbackPollInFlight=false; +const _promptActiveSessionId=()=> 's'; +const _approvalPollingSessionMissingOrMismatched=()=>false; +const _hideApprovalCardIfOwner=()=>{}; +const stopApprovalPolling=()=>{}, stopApprovalPollingForSession=()=>{}; +const hideApprovalCard=()=>{}; +const setInterval=()=>1; +const _isSessionActivelyViewed=()=>false; +const flush=()=>new Promise(r=>setImmediate(r)); +''' + source + '\n(async()=>{\n' + body + '\n})().catch(e=>{console.error(e);process.exit(1)});') + + +def test_dismiss_poll_resolution_and_new_run_owner(): + result = run(''' +const sent=[]; +global.sendBrowserNotification=()=>{sent.push(1);return true}; +const a={_session_id:'s',approval_id:'id',run_id:'r1',_gateway_mirror_token:'t1'}; +global.showApprovalForSession=(sid,p)=>showApprovalCard(p,1); +showApprovalForSession('s',a); +dismissApprovalCard(); +const dismissed=_isApprovalDismissed('s',a); +if(activeSessionHasPendingPromptAttention())throw Error('dismissed attention remains'); +showApprovalForSession('s',a); +const quiet=sent.length; +const b={...a,run_id:'r2',_gateway_mirror_token:'t2'}; +showApprovalForSession('s',b); +const replacement=sent.length; +dismissApprovalCard(); +global.api=async()=>({pending:null}); +_startApprovalFallbackPoll('s');await flush(); +showApprovalForSession('s',b); +console.log(JSON.stringify({dismissed,quiet,replacement,reused:sent.length})); +''') + assert result == dict(dismissed=True, quiet=1, replacement=2, reused=3) + + +def test_empty_map_terminal_clear_fences_inflight_poll(): + result = run(''' +let resolve;const shown=[]; +global.api=()=>new Promise(r=>resolve=r); +global.showApprovalForSession=(sid,p)=>shown.push(p); +_startApprovalFallbackPoll('s'); +_clearApprovalPendingForSession('s'); +_clearClarifyPendingForSession('s'); +resolve({pending:{approval_id:'stale'}});await flush(); +console.log(JSON.stringify({shown,clarifyGeneration:_clarifyPromptGeneration('s')})); +''') + assert result == dict(shown=[], clarifyGeneration=1) + + +def test_real_delivery_denied_failed_and_inflight_retry(): + result = run(''' +const window=global.window={_notificationsEnabled:true}; +let attempts=0,fail=false; +window.Notification=global.Notification=function(){attempts++;if(fail)throw Error('unavailable')}; +Notification.permission='denied'; +global.assistantDisplayName=()=> 'Hermes'; +global._notificationOptions=()=>({}); +Object.defineProperty(global,'navigator',{value:{serviceWorker:{getRegistration:async()=>({active:true,showNotification:async()=>{throw Error('SW failed')}})}}}); +const p={approval_id:'id'}; +_notifyPromptCard('approval','s',p);await flush(); +Notification.permission='granted';fail=true; +_notifyPromptCard('approval','s',p);await flush(); +const failedConsumed=_promptNotifySeen.size; +fail=false; +_notifyPromptCard('approval','s',p); +_notifyPromptCard('approval','s',p);await flush(); +_notifyPromptCard('approval','s',p);await flush(); +console.log(JSON.stringify({attempts,failedConsumed,consumed:_promptNotifySeen.size})); +''', ('sendBrowserNotification', '_showPwaNotification')) + assert result == dict(attempts=2, failedConsumed=0, consumed=1) + + +def test_empty_clarify_poll_cannot_resurrect_after_terminal_clear(): + result = run(''' +let _clarifyPollingSessionId, _clarifyPollTimer, _clarifyFallbackPollInFlight=false; +let resolve;const shown=[]; +global.api=()=>new Promise(r=>resolve=r); +global.showClarifyForSession=(sid,p)=>shown.push(p); +_startClarifyFallbackPoll('s'); +_clearClarifyPendingForSession('s'); +resolve({pending:{clarify_id:'stale'}});await flush(); +console.log(JSON.stringify({shown})); +''', ('_startClarifyFallbackPoll',)) + assert result == dict(shown=[]) + diff --git a/tests/test_pwa_notification_controls.py b/tests/test_pwa_notification_controls.py index beaad244..5b249660 100644 --- a/tests/test_pwa_notification_controls.py +++ b/tests/test_pwa_notification_controls.py @@ -43,73 +43,11 @@ def test_notification_payload_uses_completion_session_when_provided(): assert "assistantText?assistantText.slice(0,100)" not in MESSAGES_JS -def test_prompt_notifications_fire_from_card_renderer_chokepoint(): - """Approval/clarify notifications are owned by the card renderers, so EVERY - surfacing path (live SSE, 1.5s fallback poll, post-respond 'next approval' - refresh, reload-while-pending) notifies — not just the active-session SSE - event. The SSE listeners no longer send notifications directly; the shared - _notifyPromptCard gate dedupes per prompt id so repeated poll ticks and - re-renders ping exactly once.""" - # The chokepoint helper exists and both card renderers call it BEFORE the - # belongs-to-active-session guard (so a non-active session's prompt still - # notifies). - assert "function _notifyPromptCard(kind, sid, pending){" in MESSAGES_JS - for fn_name, kind in (("showApprovalCard", "approval"), ("showClarifyCard", "clarify")): - start = MESSAGES_JS.index(f"function {fn_name}(") - body_start = MESSAGES_JS.index("{", start) - depth = 0 - for i in range(body_start, len(MESSAGES_JS)): - if MESSAGES_JS[i] == "{": - depth += 1 - elif MESSAGES_JS[i] == "}": - depth -= 1 - if depth == 0: - body = MESSAGES_JS[start : i + 1] - break - assert "_notifyPromptCard(" in body, f"{fn_name} must route notifications through _notifyPromptCard" - assert "_notifyPromptCard('approval', sid, pending);" in MESSAGES_JS - assert "_notifyPromptCard('clarify', sid, pending);" in MESSAGES_JS - # Dedupe gate: per logical owner - an INJECTIVE typed-tuple key - # (JSON.stringify of [kind, sid, prompt id, gateway run id, mirror token]), - # so delimiter-bearing producer strings cannot collide. - assert "function _promptNotifyKey(kind, sid, pending){" in MESSAGES_JS - assert "return JSON.stringify([kind, String(sid || ''), String(id)," in MESSAGES_JS - assert "const key = _promptNotifyKey(kind, sid, p);" in MESSAGES_JS - assert "if (_promptNotifySeen.has(key)) return;" in MESSAGES_JS - # Entries retire by prompt LIFECYCLE (the pending-clear chokepoints), never - # by wall-clock age - a prompt pending >10min must not re-notify. - assert "_PROMPT_NOTIFY_TTL_MS" not in MESSAGES_JS - assert "function _retirePromptNotifyKey(kind, sid, pending){" in MESSAGES_JS - assert "_retirePromptNotifyKey('approval', sid, entry.pending);" in MESSAGES_JS - assert "_retirePromptNotifyKey('clarify', sid, entry.pending);" in MESSAGES_JS - # Disabled notifications must not consume the notify, so enabling - # mid-prompt still re-notifies that owner. - assert "!window._notificationsEnabled) return;" in MESSAGES_JS - # Visibility gate: suppress only while the prompt's session is actively - # viewed (open in pane + tab visible + tab focused). All three of the - # user's notification cases fire: non-active session, hidden tab, - # unfocused window. - assert "_isSessionActivelyViewed(sid)) return;" in MESSAGES_JS - # SSE listeners delegate; they must not send directly (would bypass dedupe). - approval_listener = _source_between( - "source.addEventListener('approval',e=>{", - "source.addEventListener('clarify',e=>{", - ) - clarify_listener = _source_between( - "source.addEventListener('clarify',e=>{", - "source.addEventListener('state_saved',e=>{", - ) - assert "sendBrowserNotification(" not in approval_listener - assert "sendBrowserNotification(" not in clarify_listener - assert "_notifyPromptCard()" in approval_listener # ownership comment anchor - assert "_notifyPromptCard()" in clarify_listener - - def _extract_fn(src: str, name: str) -> str: marker = f"function {name}" start = src.find(marker) assert start >= 0, f"{name} not found" - brace = src.find("{", start) + brace = src.find("{", src.find(")", start)) depth = 0 for i in range(brace, len(src)): if src[i] == "{": @@ -389,7 +327,7 @@ const ownerKey = _promptNotifyKey('approval', 'sid-1', {{ approval_id: 'd1' }}); process.stdout.write(JSON.stringify({{ count: sent.length, bodies: sent.map(s => s.body), consumed: _promptNotifySeen.has(ownerKey) }})); """ result = _run_node(script) - assert result["count"] == 2 or result["count"] == 1, f"unexpected ping count (got {result})" + assert result["count"] == 1, f"unexpected ping count (got {result})" assert result["bodies"][0] == 'after enabling', f"enabling mid-prompt must re-notify the same owner (got {result})" assert result["consumed"] is True, "the enabled notify must be recorded (consumed) for the owner" @@ -414,6 +352,7 @@ def test_displaced_pending_prompt_key_is_retired_on_replacement(): script = f""" const _promptNotifySeen = new Map(); const _isApprovalDismissed = () => false; +const _unmarkApprovalDismissed = () => {{}}; const S = {{ session: {{ session_id: "sid-1" }} }}; {keyfn} {retire} From b3d66dd40db06803592c586165d6a29b8e6eea4c Mon Sep 17 00:00:00 2001 From: CharlesMcquade <6466275+CharlesMcquade@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:15:37 -0500 Subject: [PATCH 022/115] fix(notifications): migrate legacy approval dismissals --- static/messages.js | 19 ++++++++++++++++++- tests/test_prompt_notification_lifecycle.py | 14 +++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/static/messages.js b/static/messages.js index 58ee6ed6..43105da1 100644 --- a/static/messages.js +++ b/static/messages.js @@ -7429,6 +7429,12 @@ function _approvalDismissKey(sid, approvalId) { return _promptNotifyKey('approval', sid, pending); } +function _legacyApprovalDismissKey(sid, approvalId) { + const pending = typeof approvalId === 'object' ? approvalId : {approval_id: approvalId}; + const id = pending && pending.approval_id; + return sid && id ? String(sid) + '\0' + String(id) : ''; +} + function _getDismissedApprovals() { try { return JSON.parse(localStorage.getItem(_DISMISSED_APPROVALS_KEY) || '[]'); } catch (_) { return []; } @@ -7437,7 +7443,18 @@ function _getDismissedApprovals() { function _isApprovalDismissed(sid, approvalId) { const key = _approvalDismissKey(sid, approvalId); if (!key) return false; - return _getDismissedApprovals().includes(key); + const dismissed = _getDismissedApprovals(); + if (dismissed.includes(key)) return true; + const legacyKey = _legacyApprovalDismissKey(sid, approvalId); + if (!legacyKey || !dismissed.includes(legacyKey)) return false; + // Bind an old session/ID tombstone to the currently observed full owner. + // This keeps an unresolved dismissal quiet through upgrade without letting + // the ambiguous legacy key suppress every future gateway run forever. + const migrated = dismissed.filter(item => item !== legacyKey && item !== key); + migrated.push(key); + try { localStorage.setItem(_DISMISSED_APPROVALS_KEY, JSON.stringify(migrated.slice(-100))); } + catch (_) {} + return true; } function _markApprovalDismissed(sid, approvalId) { diff --git a/tests/test_prompt_notification_lifecycle.py b/tests/test_prompt_notification_lifecycle.py index d76ef2fa..af6fa46a 100644 --- a/tests/test_prompt_notification_lifecycle.py +++ b/tests/test_prompt_notification_lifecycle.py @@ -7,7 +7,7 @@ def run(body, extra=()): '_approvalPromptGeneration', '_bumpApprovalPromptGeneration', '_clarifyPromptGeneration', '_bumpClarifyPromptGeneration', '_rememberApprovalPending', '_clearApprovalPendingForSession', - '_clearClarifyPendingForSession', '_approvalDismissKey', + '_clearClarifyPendingForSession', '_approvalDismissKey', '_legacyApprovalDismissKey', '_getDismissedApprovals', '_isApprovalDismissed', '_markApprovalDismissed', '_unmarkApprovalDismissed', 'dismissApprovalCard', '_startApprovalFallbackPoll', 'showApprovalCard', @@ -36,6 +36,18 @@ const flush=()=>new Promise(r=>setImmediate(r)); ''' + source + '\n(async()=>{\n' + body + '\n})().catch(e=>{console.error(e);process.exit(1)});') +def test_legacy_dismissal_migrates_to_current_full_owner(): + result = run(r''' +const pending={approval_id:'id',run_id:'run-2',_gateway_mirror_token:'token-2'}; +const legacy='s'+'\0'+'id'; +store.set(_DISMISSED_APPROVALS_KEY,JSON.stringify([legacy])); +const dismissed=_isApprovalDismissed('s',pending); +const keys=JSON.parse(store.get(_DISMISSED_APPROVALS_KEY)); +console.log(JSON.stringify({dismissed,legacyPresent:keys.includes(legacy),fullPresent:keys.includes(_approvalDismissKey('s',pending))})); +''') + assert result == dict(dismissed=True, legacyPresent=False, fullPresent=True) + + def test_dismiss_poll_resolution_and_new_run_owner(): result = run(''' const sent=[]; From 056b3c268f199773018cd4fa738a3f51edfd7464 Mon Sep 17 00:00:00 2001 From: CharlesMcquade <6466275+CharlesMcquade@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:35:25 -0500 Subject: [PATCH 023/115] fix(notifications): keep dismissal helpers self-contained --- static/messages.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/static/messages.js b/static/messages.js index 43105da1..8f956580 100644 --- a/static/messages.js +++ b/static/messages.js @@ -7445,7 +7445,9 @@ function _isApprovalDismissed(sid, approvalId) { if (!key) return false; const dismissed = _getDismissedApprovals(); if (dismissed.includes(key)) return true; - const legacyKey = _legacyApprovalDismissKey(sid, approvalId); + const pending = typeof approvalId === "object" ? approvalId : {approval_id: approvalId}; + const legacyId = pending && pending.approval_id; + const legacyKey = sid && legacyId ? String(sid) + "\0" + String(legacyId) : ""; if (!legacyKey || !dismissed.includes(legacyKey)) return false; // Bind an old session/ID tombstone to the currently observed full owner. // This keeps an unresolved dismissal quiet through upgrade without letting From 1fb213380c9b229fab41239e4ffd41db1a7be6fd Mon Sep 17 00:00:00 2001 From: CharlesMcquade <6466275+CharlesMcquade@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:36:07 -0500 Subject: [PATCH 024/115] fix(notifications): isolate legacy dismissal ownership --- static/messages.js | 7 +++++-- tests/test_prompt_notification_lifecycle.py | 16 ++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/static/messages.js b/static/messages.js index 8f956580..a3b186c3 100644 --- a/static/messages.js +++ b/static/messages.js @@ -7453,10 +7453,13 @@ function _isApprovalDismissed(sid, approvalId) { // This keeps an unresolved dismissal quiet through upgrade without letting // the ambiguous legacy key suppress every future gateway run forever. const migrated = dismissed.filter(item => item !== legacyKey && item !== key); - migrated.push(key); + const hasFullOwner = !!(pending && pending.run_id && pending._gateway_mirror_token); + // A legacy tombstone cannot identify a gateway run. Consume it rather than + // transferring its authority to an unrelated later owner that reused the ID. + if (!hasFullOwner) migrated.push(key); try { localStorage.setItem(_DISMISSED_APPROVALS_KEY, JSON.stringify(migrated.slice(-100))); } catch (_) {} - return true; + return !hasFullOwner; } function _markApprovalDismissed(sid, approvalId) { diff --git a/tests/test_prompt_notification_lifecycle.py b/tests/test_prompt_notification_lifecycle.py index af6fa46a..9c53b182 100644 --- a/tests/test_prompt_notification_lifecycle.py +++ b/tests/test_prompt_notification_lifecycle.py @@ -36,7 +36,7 @@ const flush=()=>new Promise(r=>setImmediate(r)); ''' + source + '\n(async()=>{\n' + body + '\n})().catch(e=>{console.error(e);process.exit(1)});') -def test_legacy_dismissal_migrates_to_current_full_owner(): +def test_legacy_dismissal_does_not_transfer_to_distinct_full_owner(): result = run(r''' const pending={approval_id:'id',run_id:'run-2',_gateway_mirror_token:'token-2'}; const legacy='s'+'\0'+'id'; @@ -45,9 +45,21 @@ const dismissed=_isApprovalDismissed('s',pending); const keys=JSON.parse(store.get(_DISMISSED_APPROVALS_KEY)); console.log(JSON.stringify({dismissed,legacyPresent:keys.includes(legacy),fullPresent:keys.includes(_approvalDismissKey('s',pending))})); ''') - assert result == dict(dismissed=True, legacyPresent=False, fullPresent=True) + assert result == dict(dismissed=False, legacyPresent=False, fullPresent=False) + +def test_legacy_dismissal_migrates_when_no_stronger_owner_exists(): + result = run(r""" +const pending={approval_id:"id"}; +const legacy="s"+"\0"+"id"; +store.set(_DISMISSED_APPROVALS_KEY,JSON.stringify([legacy])); +const dismissed=_isApprovalDismissed("s",pending); +const keys=JSON.parse(store.get(_DISMISSED_APPROVALS_KEY)); +console.log(JSON.stringify({dismissed,legacyPresent:keys.includes(legacy),fullPresent:keys.includes(_approvalDismissKey("s",pending))})); +""") + assert result == dict(dismissed=True, legacyPresent=False, fullPresent=True) + def test_dismiss_poll_resolution_and_new_run_owner(): result = run(''' const sent=[]; From 4a3c55fdb089bf974a9958c0b00ffa1545c87946 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 16 Sep 2026 16:11:25 -0300 Subject: [PATCH 025/115] fix(streaming): strip consumed OOB steer wrapper from the settled transcript (#7600) A mid-turn /steer is delivered to the agent as an [OUT-OF-BAND USER MESSAGE ...] block appended to the turn's last tool result. The model-facing gateway history always dropped that wrapper, but the writeback that produces session.messages (rendered verbatim) and session.context_messages kept it, so the frontend showed the raw control wrapper as a chat bubble. Strip it in _prepare_marker_clean_writeback, the chokepoint both writebacks are built from, reusing _strip_oob_blocks. Rows without a marker are returned untouched so stable ids / reasoning metadata keep riding the same objects and the scrubbed rows still compare equal to the rows persisted by earlier turns. --- api/streaming.py | 46 +++++ tests/test_issue7600_oob_display_writeback.py | 195 ++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 tests/test_issue7600_oob_display_writeback.py diff --git a/api/streaming.py b/api/streaming.py index beab649b..3d803410 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2174,6 +2174,14 @@ def _prepare_marker_clean_writeback( cleaned, has_verification_nudge = _clean_synthetic_control_messages_with_provenance( result_messages ) + # Same internal-control class, second home: a consumed mid-turn /steer is + # appended to the turn's last tool result wrapped in + # [OUT-OF-BAND USER MESSAGE ...] ... [/OUT-OF-BAND USER MESSAGE]. Strip it + # here, on the rows both writebacks are built from, so neither + # session.messages (rendered verbatim) nor session.context_messages keeps + # the raw wrapper. Stripping the incoming rows too keeps them identity-equal + # to the marker-free rows persisted by earlier turns. (#7600) + cleaned = _strip_oob_markers_from_messages(cleaned) provenance = { 'verification_nudge_seen': has_verification_nudge, 'active_turn_identity': copy.deepcopy(active_turn_identity), @@ -5428,6 +5436,44 @@ def _strip_oob_blocks(content): return content +def _content_has_oob_marker(content) -> bool: + """True when ``content`` holds a complete [OUT-OF-BAND USER MESSAGE] block. + + Cheap pre-check so a scrub pass over a settled transcript only copies the + rows that actually carry the control wrapper. + """ + if isinstance(content, str): + return bool(_OOB_USER_MESSAGE_BLOCK_RE.search(content)) + if isinstance(content, list): + return any(_content_has_oob_marker(part) for part in content) + if isinstance(content, dict): + return any(_content_has_oob_marker(value) for value in content.values()) + return False + + +def _strip_oob_markers_from_messages(messages): + """Drop consumed OOB steer wrappers from rows bound for persistence. + + A mid-turn ``/steer`` is delivered as an ``[OUT-OF-BAND USER MESSAGE ...]`` + block appended to the turn's last tool result. The wrapper is agent control + data: the gateway history builder already strips it from the model-facing + copy, and the settled transcript must not keep it either — ``session.messages`` + is rendered verbatim in the UI (#7600). + + Rows without a marker are returned untouched (same object) so the identity + of rows carrying stable ids / reasoning metadata is preserved, and so the + scrubbed rows still compare equal to the marker-free rows already persisted + from earlier turns. + """ + cleaned = [] + for message in messages or []: + if isinstance(message, dict) and _content_has_oob_marker(message.get('content')): + cleaned.append(_strip_oob_blocks(message)) + continue + cleaned.append(message) + return cleaned + + def _content_has_reasoning_only_parts(content) -> bool: if not isinstance(content, list) or not content: return False diff --git a/tests/test_issue7600_oob_display_writeback.py b/tests/test_issue7600_oob_display_writeback.py new file mode 100644 index 00000000..7715bbfd --- /dev/null +++ b/tests/test_issue7600_oob_display_writeback.py @@ -0,0 +1,195 @@ +"""Settled writeback must drop consumed OUT-OF-BAND steer wrappers (#7600). + +A mid-turn ``/steer`` is delivered to the agent as an +``[OUT-OF-BAND USER MESSAGE ...] ... [/OUT-OF-BAND USER MESSAGE]`` block appended +to the turn's last tool result. The model-facing path strips that wrapper +(``_strip_oob_blocks`` in the gateway history builder), but the writeback in +``_prepare_marker_clean_writeback`` kept the raw text, so the settled display +transcript (``session.messages``) and the persisted context +(``session.context_messages``) both stored the wrapper and the frontend rendered +it verbatim. + +These tests drive the real settle path (``_settle_result_messages``) with the +production message shape and assert on what actually gets persisted. +""" +from __future__ import annotations + +import copy +import json + +from api.models import Session +from api.streaming import _settle_result_messages +from api import streaming as _streaming + +PROMPT = "please run the smoke checks" + +OOB_OPEN = ( + "[OUT-OF-BAND USER MESSAGE — a direct message from the user, delivered once " + "at this position; not tool output and not a new delivery when replayed from " + "conversation history]" +) +OOB_CLOSE = "[/OUT-OF-BAND USER MESSAGE]" +OOB_BLOCK = f"{OOB_OPEN}\nsteer: use the staging bucket this time\n{OOB_CLOSE}" +OOB_VARIANT = f"[OUT-OF-BAND USER MESSAGE]steer: also bump the timeout{OOB_CLOSE}" + + +def _contains_oob(value) -> bool: + return "OUT-OF-BAND USER MESSAGE" in json.dumps(value) + + +def _session_with_prior_turn() -> Session: + """Prior turn already settled: user -> assistant(tool_call) -> tool -> answer.""" + display = [ + {"role": "user", "content": "deploy the app", "timestamp": 1788439000}, + { + "role": "assistant", + "content": "", + "tool_calls": [{"id": "call-1"}], + "timestamp": 1788439005, + }, + { + "role": "tool", + "tool_call_id": "call-1", + "content": "deploy finished: revision 41", + "timestamp": 1788439010, + }, + {"role": "assistant", "content": "Deploy done (revision 41).", "timestamp": 1788439015}, + ] + session = Session(session_id="7" * 12, title="steer writeback", messages=copy.deepcopy(display)) + session.context_messages = copy.deepcopy(display) + return session + + +def _settle_turn_with_oob(session, monkeypatch, *, tool_content: str, steer_row=None): + """Settle one turn whose last tool result carries the consumed steer block.""" + monkeypatch.setattr( + _streaming, "_annotate_media_snapshots_for_settled_messages", lambda messages: None + ) + previous = list(session.messages) + previous_context = list(session.context_messages) + ts = 1788440000 + steer_rows = list(steer_row or []) + result = copy.deepcopy(previous_context) + [ + {"role": "user", "content": PROMPT, "timestamp": ts}, + { + "role": "assistant", + "content": "", + "tool_calls": [{"id": "call-2"}], + "timestamp": ts + 1, + }, + { + "role": "tool", + "tool_call_id": "call-2", + "content": tool_content, + "timestamp": ts + 2, + }, + *steer_rows, + {"role": "assistant", "content": "Smoke checks passed.", "timestamp": ts + 3}, + ] + _settle_result_messages(session, previous, previous_context, result, PROMPT, "webui", None) + return previous + + +def test_settle_drops_oob_wrapper_from_display_and_context(monkeypatch): + """The consumed steer wrapper must not survive into either written copy.""" + session = _session_with_prior_turn() + _settle_turn_with_oob( + session, monkeypatch, tool_content=f"checks passed\n\n{OOB_BLOCK}" + ) + + assert not _contains_oob(session.messages), ( + "raw [OUT-OF-BAND USER MESSAGE] wrapper leaked into the display transcript" + ) + assert not _contains_oob(session.context_messages), ( + "raw [OUT-OF-BAND USER MESSAGE] wrapper leaked into the model context" + ) + # The tool result it was appended to keeps its own output. + tool_bodies = [ + m.get("content") for m in session.messages if isinstance(m, dict) and m.get("role") == "tool" + ] + assert "checks passed" in json.dumps(tool_bodies) + assert session.messages[-1]["content"] == "Smoke checks passed." + + +def test_settle_drops_oob_wrapper_in_list_content_parts(monkeypatch): + """List-based content parts are stripped too (the helper already supports them).""" + session = _session_with_prior_turn() + _settle_turn_with_oob( + session, + monkeypatch, + tool_content=[ + {"type": "text", "text": f"checks passed\n{OOB_BLOCK}\nrevision 41"}, + {"type": "text", "text": "no marker here"}, + ], + ) + + assert not _contains_oob(session.messages) + assert not _contains_oob(session.context_messages) + assert "checks passed" in json.dumps(session.messages) + assert "no marker here" in json.dumps(session.messages) + + +def test_settle_drops_every_oob_block_in_one_row(monkeypatch): + session = _session_with_prior_turn() + _settle_turn_with_oob( + session, monkeypatch, tool_content=f"before {OOB_BLOCK} middle {OOB_VARIANT} end" + ) + + assert not _contains_oob(session.messages) + assert not _contains_oob(session.context_messages) + joined = json.dumps(session.messages) + assert "before" in joined and "middle" in joined and "end" in joined + + +def test_settle_keeps_transcript_without_markers_intact(monkeypatch): + """No marker: every previously settled row keeps its exact content.""" + session = _session_with_prior_turn() + previous = _settle_turn_with_oob( + session, monkeypatch, tool_content="checks passed\nrevision 41" + ) + + assert not _contains_oob(session.messages) + assert [m.get("content") for m in session.messages[: len(previous)]] == [ + m.get("content") for m in previous + ] + assert [m.get("content") for m in session.context_messages[: len(previous)]] == [ + m.get("content") for m in previous + ] + + +def test_settle_keeps_prose_that_merely_names_the_marker(monkeypatch): + """A user row that *talks about* the marker (no brackets) must not be trimmed.""" + session = _session_with_prior_turn() + prose = "the docs describe the OUT-OF-BAND USER MESSAGE wrapper used by /steer" + previous = list(session.messages) + previous_context = list(session.context_messages) + monkeypatch.setattr( + _streaming, "_annotate_media_snapshots_for_settled_messages", lambda messages: None + ) + result = copy.deepcopy(previous_context) + [ + {"role": "user", "content": PROMPT, "timestamp": 1788440000}, + {"role": "assistant", "content": prose, "timestamp": 1788440001}, + ] + + _settle_result_messages(session, previous, previous_context, result, PROMPT, "webui", None) + + assert prose in json.dumps(session.messages) + assert [m.get("content") for m in session.messages[: len(previous)]] == [ + m.get("content") for m in previous + ] + + +def test_settle_keeps_unclosed_marker_as_is(monkeypatch): + """Unclosed wrappers stay untouched — stripping only complete, consumed blocks. + + This is the contract ``_strip_oob_blocks`` already documents (a half-written + marker means the block was never completed), and the settle path must not + invent a different one. + """ + session = _session_with_prior_turn() + _settle_turn_with_oob( + session, monkeypatch, tool_content="checks passed\n[OUT-OF-BAND USER MESSAGE — truncated" + ) + + assert session.messages[-1]["content"] == "Smoke checks passed." + assert "truncated" in json.dumps(session.messages) From 5c610866c832f902216beb8f95cd6c3fbf70c912 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 16 Sep 2026 16:14:28 -0300 Subject: [PATCH 026/115] fix(streaming): scrub OOB steer wrappers from the merged display transcript (#7600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The display merge carries earlier turns' rows across verbatim, so a wrapper persisted by a pre-fix settle would stay rendered for the rest of the session. Scrub session.messages after the merge — the merge's identity matching still saw the rows unchanged — so the persisted display copy is marker-free even when the row was not first written by this turn. Covered by test_settle_heals_a_transcript_that_already_stored_the_wrapper. --- api/streaming.py | 5 ++++ tests/test_issue7600_oob_display_writeback.py | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/api/streaming.py b/api/streaming.py index 3d803410..e58b350d 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2294,6 +2294,11 @@ def _settle_result_messages( source=source, verification_nudge_provenance=verification_nudge_provenance, ) + # The merge carries earlier display rows across turns verbatim, so a row + # settled before this guard existed would keep its raw wrapper forever. + # Scrub the persisted display copy too — after the merge, so identity + # matching above still saw the rows unchanged. (#7600) + session.messages = _strip_oob_markers_from_messages(session.messages) _annotate_media_snapshots_for_settled_messages(session.messages) _compact_session_image_parts_for_persistence(session) _advance_truncation_watermark_after_commit(session) # #3831 diff --git a/tests/test_issue7600_oob_display_writeback.py b/tests/test_issue7600_oob_display_writeback.py index 7715bbfd..de016459 100644 --- a/tests/test_issue7600_oob_display_writeback.py +++ b/tests/test_issue7600_oob_display_writeback.py @@ -193,3 +193,33 @@ def test_settle_keeps_unclosed_marker_as_is(monkeypatch): assert session.messages[-1]["content"] == "Smoke checks passed." assert "truncated" in json.dumps(session.messages) + + +def test_settle_heals_a_transcript_that_already_stored_the_wrapper(monkeypatch): + """A transcript polluted by an earlier settle is scrubbed on the next one. + + The display merge carries earlier rows across turns verbatim, so without a + scrub on the merged transcript the wrapper written before this fix would stay + visible for the rest of the session (and the agent replays it with the + wrapper still in place). + """ + session = _session_with_prior_turn() + # Turn settled before the guard existed: the tool row kept the raw wrapper. + session.messages[2]["content"] = f"deploy finished: revision 41\n{OOB_BLOCK}" + session.context_messages = copy.deepcopy(session.messages) + monkeypatch.setattr( + _streaming, "_annotate_media_snapshots_for_settled_messages", lambda messages: None + ) + previous = list(session.messages) + previous_context = list(session.context_messages) + result = copy.deepcopy(previous_context) + [ + {"role": "user", "content": PROMPT, "timestamp": 1788440000}, + {"role": "assistant", "content": "Nothing else changed.", "timestamp": 1788440001}, + ] + + _settle_result_messages(session, previous, previous_context, result, PROMPT, "webui", None) + + assert not _contains_oob(session.messages) + assert not _contains_oob(session.context_messages) + assert sum(1 for m in session.messages if m.get("role") == "tool") == 1 + assert "deploy finished: revision 41" in json.dumps(session.messages) From 9a6a9acffaa7c10caea54c2041ef2bdb1531c6b5 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 16 Sep 2026 18:32:45 -0300 Subject: [PATCH 027/115] fix(streaming): gate the OOB scrub to the carrier tool row (#7600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_strip_oob_markers_from_messages()` matched on marker presence alone and passed the whole row to `_strip_oob_blocks()`, so a user row that legitimately quotes a complete `[OUT-OF-BAND USER MESSAGE]…[/OUT-OF-BAND USER MESSAGE]` example lost that text on the next settlement — and the shortened pre-match row broke active-turn identity matching, which duplicated the turn. The transport only ever appends a consumed `/steer` wrapper to the turn's last tool result, so the scrub now considers `role == "tool"` rows only and rebuilds just their `content` (shallow copy): user/assistant rows, stable ids, reasoning metadata, and turn bookkeeping are never touched. Measured on the focused file, helper restored to the pre-fix body as sabotage: - pre-fix body: 1 failed, 7 passed — the new regression prints the deleted example plus 3 user rows instead of 2 (the duplicated turn) - with the fix: 8 passed Neighbors: test_context_history_sanitization.py 20 passed; writeback/settle family (12 files) 120 passed, 5 skipped; media slice (7 files) 197 passed, 18 subtests. Docs: docs/rfcs/webui-pending-intent-controls.md now records that the transport wrapper is dropped from the settled display and the persisted context state, is scoped to the carrier row, and that a transcript stored before this behavior heals on its next settlement. --- api/streaming.py | 26 ++++-- docs/rfcs/webui-pending-intent-controls.md | 13 +++ tests/test_issue7600_oob_display_writeback.py | 79 +++++++++++++++++++ 3 files changed, 110 insertions(+), 8 deletions(-) diff --git a/api/streaming.py b/api/streaming.py index e58b350d..2767a312 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -5457,7 +5457,7 @@ def _content_has_oob_marker(content) -> bool: def _strip_oob_markers_from_messages(messages): - """Drop consumed OOB steer wrappers from rows bound for persistence. + """Drop consumed OOB steer wrappers from the tool rows that carry them. A mid-turn ``/steer`` is delivered as an ``[OUT-OF-BAND USER MESSAGE ...]`` block appended to the turn's last tool result. The wrapper is agent control @@ -5465,17 +5465,27 @@ def _strip_oob_markers_from_messages(messages): copy, and the settled transcript must not keep it either — ``session.messages`` is rendered verbatim in the UI (#7600). - Rows without a marker are returned untouched (same object) so the identity - of rows carrying stable ids / reasoning metadata is preserved, and so the - scrubbed rows still compare equal to the marker-free rows already persisted - from earlier turns. + Only ``role == 'tool'`` rows are candidates, because that is the only place + the transport ever appends a wrapper. User and assistant rows are + user-visible content: a message that quotes a complete marker — a pasted + example, a log excerpt — must survive verbatim, so the scrub never reaches + those rows. Only the carrier's ``content`` is rebuilt; every other field + stays the object it already is, so stable ids, reasoning metadata, and turn + bookkeeping are untouched and the scrubbed rows still compare equal to the + marker-free rows already persisted from earlier turns. """ cleaned = [] for message in messages or []: - if isinstance(message, dict) and _content_has_oob_marker(message.get('content')): - cleaned.append(_strip_oob_blocks(message)) + if not isinstance(message, dict) or message.get('role') != 'tool': + cleaned.append(message) continue - cleaned.append(message) + content = message.get('content') + if not _content_has_oob_marker(content): + cleaned.append(message) + continue + scrubbed = dict(message) + scrubbed['content'] = _strip_oob_blocks(content) + cleaned.append(scrubbed) return cleaned diff --git a/docs/rfcs/webui-pending-intent-controls.md b/docs/rfcs/webui-pending-intent-controls.md index d8c3e65a..55913d3c 100644 --- a/docs/rfcs/webui-pending-intent-controls.md +++ b/docs/rfcs/webui-pending-intent-controls.md @@ -215,6 +215,19 @@ settle. Steer must not exist only as a toast or transient DOM state. It must survive refresh, session switch, replay, and settled render. +**The transport wrapper is not the Steer row.** A Steer reaches the agent as an +`[OUT-OF-BAND USER MESSAGE …] … [/OUT-OF-BAND USER MESSAGE]` block appended to +the run's last tool result. That block is transport-level control data, not user +content: settlement drops it from both written copies — the display transcript +(`session.messages`) and the persisted context state +(`session.context_messages`) — so the wrapper never renders in the settled view +and is never replayed to the model. The Steer's own user-visible row is not +touched by that scrub, which is scoped to the tool row that carried the wrapper: +user text that merely quotes a complete marker (a pasted example, a log excerpt) +stays byte-for-byte and appears exactly once. A session whose transcript was +stored before this behavior existed keeps the wrapper in place until its next +settlement, when the merged transcript is scrubbed again. + ### Relationship to system control events Steer is similar to system-delivered control events such as tool-iteration-limit diff --git a/tests/test_issue7600_oob_display_writeback.py b/tests/test_issue7600_oob_display_writeback.py index de016459..acf03bea 100644 --- a/tests/test_issue7600_oob_display_writeback.py +++ b/tests/test_issue7600_oob_display_writeback.py @@ -223,3 +223,82 @@ def test_settle_heals_a_transcript_that_already_stored_the_wrapper(monkeypatch): assert not _contains_oob(session.context_messages) assert sum(1 for m in session.messages if m.get("role") == "tool") == 1 assert "deploy finished: revision 41" in json.dumps(session.messages) + + +def test_settle_keeps_literal_markers_in_user_text_intact_and_unduplicated(monkeypatch): + """A complete marker inside user text is content, not transport control data. + + The scrub may only touch the tool row the transport appended the wrapper to. + A user row that quotes a *complete* marker — a pasted log excerpt, or the very + prompt asking about this feature — is user-visible content: it must survive + byte-for-byte and exactly once in both written copies. Before the role gate, + the pre-match scrub also shortened the current turn's own prompt row, which + broke active-turn identity matching and duplicated the turn. + """ + pasted = ( + "our bot log shows this block, is that normal?\n" + f"{OOB_BLOCK}\n" + "the docs say the gateway adds it" + ) + asked = f"is this wrapper expected?\n{OOB_VARIANT}\nit showed up mid-turn" + session = _session_with_prior_turn() + # Prior turn, already settled: its user row quotes the wrapper as an example. + session.messages[0]["content"] = pasted + session.context_messages = copy.deepcopy(session.messages) + previous = list(session.messages) + previous_context = list(session.context_messages) + monkeypatch.setattr( + _streaming, "_annotate_media_snapshots_for_settled_messages", lambda messages: None + ) + + ts = 1788440000 + token = "direct-stream:2" + identity = { + "token": token, + "text": asked, + "timestamp": float(ts), + "source": "webui", + "attachments": [], + "current_turn_user_idx": len(previous_context), + "turn_id": "turn-2", + "agent_turn_boundary_resolved": True, + } + result = copy.deepcopy(previous_context) + [ + {"role": "user", "content": asked, "timestamp": ts, "_active_turn_token": token}, + { + "role": "assistant", + "content": "", + "tool_calls": [{"id": "call-2"}], + "timestamp": ts + 1, + }, + { + "role": "tool", + "tool_call_id": "call-2", + "content": f"checks passed\n\n{OOB_BLOCK}", + "timestamp": ts + 2, + }, + {"role": "assistant", "content": "Smoke checks passed.", "timestamp": ts + 3}, + ] + + _settle_result_messages( + session, previous, previous_context, result, asked, "webui", identity + ) + + expected_user_rows = [pasted, asked] + for written in (session.messages, session.context_messages): + user_texts = [ + m.get("content") + for m in written + if isinstance(m, dict) and m.get("role") == "user" + ] + assert user_texts == expected_user_rows, ( + "a literal marker in user text must survive intact and exactly once, " + f"got {user_texts!r}" + ) + assert session.messages[-1]["content"] == "Smoke checks passed." + # The consumed steer wrapper on the carrier tool row is still dropped. + tool_bodies = [ + m.get("content") for m in session.messages if isinstance(m, dict) and m.get("role") == "tool" + ] + assert not _contains_oob(tool_bodies), "wrapper leaked into a tool row" + assert "checks passed" in json.dumps(tool_bodies) From d24d6377f906f5e7621237fe60d2b591c3b0564f Mon Sep 17 00:00:00 2001 From: happy5318 Date: Fri, 18 Sep 2026 11:24:51 +0800 Subject: [PATCH 028/115] perf(api/session): bound recovery calls; add msg_limit=all escape hatch Fixes the #7310 stall class: six frontend recovery paths (offline/bfcache refreshSession, stream-end settle, cancel sync, /compress preflight, /retry, /undo) issued bare GET /api/session reads that re-walked, re-redacted and re-serialized the ENTIRE transcript (4-15s / 28MB on a 5k-row session, #7625), and the same shape shipped the full session-level tool_calls list because windowing was gated on msg_limit being present. Changes: - Server: add explicit msg_limit=all escape hatch; window session-level tool_calls by actual truncation (length comparison) instead of the presence of a limit parameter. The bare no-limit shape keeps its full-transcript contract (pinned by test_state_db_reconciliation_preserves_sidecar_order_when_timestamps_collide and the run-journal snapshot test). - Client: refreshSession / _restoreSettledSession / cancel sync / /compress preflight / /retry / /undo now request the bounded tail (msg_limit=30) and restore the _messages_truncated/_messages_offset paging gate instead of hardcoding 'not truncated'; jump-to-start (_ensureAllMessagesLoaded) and outline jump opt in to the full transcript via msg_limit=all. Tests: test_session_default_window.py covers helper semantics, per-call-site bounded/all shapes, truncation-signal restoration, and a regression guard that no bare GET /api/session fetch remains in static/. 454 related tests pass; reverse-verified (reverting the fixes turns the tests red). --- api/routes.py | 43 ++++++- static/commands.js | 20 +++- static/messages.js | 21 +++- static/outline.js | 12 +- static/sessions.js | 2 +- static/ui.js | 6 +- tests/test_session_default_window.py | 172 +++++++++++++++++++++++++++ 7 files changed, 252 insertions(+), 24 deletions(-) create mode 100644 tests/test_session_default_window.py diff --git a/api/routes.py b/api/routes.py index 7d30ab90..474fc229 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8977,9 +8977,11 @@ def _parse_msg_limit(raw): """Parse and clamp the ``?msg_limit=`` query value. Returns a positive int clamped to ``[1, _MAX_MSG_LIMIT]``, or ``None`` when - the value is absent/empty/malformed (the bare no-``msg_limit`` path, which - intentionally returns the full transcript for callers that need it). - Extracted from the handler so the clamp expression has direct test coverage. + the value is absent/empty/malformed. ``?msg_limit=all`` also returns + ``None`` — an explicit escape hatch for the full-transcript paths the + frontend genuinely needs; the handler distinguishes it from a bare request + via :func:`_resolve_effective_msg_limit`. Extracted from the handler so + the clamp expression has direct test coverage. """ if not raw: return None @@ -8990,6 +8992,26 @@ def _parse_msg_limit(raw): return max(1, min(value, _MAX_MSG_LIMIT)) +def _resolve_effective_msg_limit(raw_limit): + """Resolve the effective ``msg_limit`` for ``GET /api/session``. + + Returns ``(effective_limit, explicit_all)``. + + - numeric ``?msg_limit=N`` → clamped int (existing pagination). + - ``?msg_limit=all`` → ``(None, True)``: explicit full-transcript escape + hatch. Frontend paths that address rows by absolute transcript index + (outline jump, jump-to-start) genuinely need everything; they pass + ``all`` instead of relying on the bare no-limit shape. + - any other bare shape (no limit) → ``(None, False)``: the historical + full-transcript contract is preserved (contract tests pin tool-row + preservation and the runtime-journal snapshot on this shape), so the + bounded-window fix is enforced at the frontend call sites instead. + """ + explicit_all = str(raw_limit or "").strip().lower() == "all" + limit = _parse_msg_limit(raw_limit) + return limit, explicit_all + + # If a sidecar JSON file exceeds this threshold, the display-path tail # optimization fires regardless of message count. Sessions with few messages # but large tool outputs (multi-MB JSON) should not force a full-scan merge. @@ -13002,8 +13024,10 @@ def _handle_session_get(handler, parsed) -> bool: # payload; the existing _messages_truncated signal covers the clamped # case (the client sees there are more rows than returned). Parsing + # clamping live in _parse_msg_limit so the expression has direct test - # coverage; None means the bare no-msg_limit path (full transcript). - msg_limit = _parse_msg_limit(query.get("msg_limit", [None])[0]) + # coverage. The frontend recovery paths request a bounded tail + # explicitly (msg_limit=30), and the two absolute-index paths opt in to + # the full transcript via msg_limit=all (#7310/#7625). + _raw_msg_limit = query.get("msg_limit", [None])[0] # ?msg_before=N — 0-based index into the full message array. # Returns messages before this index (for scroll-to-top lazy loading). # Combined with msg_limit for paging. @@ -13012,6 +13036,9 @@ def _handle_session_get(handler, parsed) -> bool: msg_before = int(_msg_before) if _msg_before else None except (ValueError, TypeError): msg_before = None + msg_limit, _msg_limit_explicit_all = _resolve_effective_msg_limit( + _raw_msg_limit, + ) # ?expand_renderable=1 is retained for compatibility with older # frontends. msg_limit now counts visible transcript rows by default, so # the flag no longer changes the server-side pagination semantics. @@ -13221,9 +13248,13 @@ def _handle_session_get(handler, parsed) -> bool: _messages_offset = 0 # Index of the first returned message in the full message array. # Frontend uses this as cursor for scroll-to-top paging. + # Session-level tool_calls windowing keys off whether the returned + # message array was actually truncated (msg_before paging, any + # effective msg_limit) rather than whether a limit parameter was + # present — the full-transcript shape returns everything, so the + # length comparison alone decides (#7310/#7625). _windowed_messages = ( load_messages - and msg_limit is not None and (msg_before is not None or len(_truncated_msgs) < len(_all_msgs)) ) # Resolve effective context_length with model-metadata fallback so diff --git a/static/commands.js b/static/commands.js index e322c1d8..336b70c2 100644 --- a/static/commands.js +++ b/static/commands.js @@ -965,14 +965,18 @@ async function _runManualCompression(focusTopic){ // Preflight: verify the viewed session still exists before compressing. // This avoids a confusing "not found" toast when the UI is stale. try{ - const live=await api(`/api/session?session_id=${encodeURIComponent(sid)}`); + // Bounded tail: a bare preflight used to pull and re-redact the whole + // transcript before every manual compression (#7310/#7625). Session + // existence + the current tail is all this preflight needs. + const live=await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`); if(!live||!live.session||live.session.session_id!==sid){ throw new Error('session no longer available'); } S.session=live.session; S.messages=live.session.messages||[]; S.toolCalls=live.session.tool_calls||[]; - if(typeof _messagesTruncated!=='undefined') _messagesTruncated=false; + if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!(live.session._messages_truncated); + if(typeof _oldestIdx!=='undefined') _oldestIdx=live.session._messages_offset||0; }catch(preflightErr){ if(typeof clearCompressionUi==='function') clearCompressionUi(); if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null); @@ -1748,11 +1752,13 @@ async function cmdRetry(){ const r=await api('/api/session/retry',{method:'POST',body:JSON.stringify({session_id:activeSid})}); if(r&&r.error){showToast(r.error);return;} if(!S.session||S.session.session_id!==activeSid)return; - const data=await api('/api/session?session_id='+encodeURIComponent(activeSid)); + // Bounded tail: a bare reload used to pull and re-redact the whole + // transcript on every /retry recovery (#7310/#7625). + const data=await api('/api/session?session_id='+encodeURIComponent(activeSid)+'&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1'); // #5924 SILENT-race guard: a session switch during the GET await must not let // this recovery apply session A's intent to whatever session is now visible. if(!S.session||S.session.session_id!==activeSid)return; - if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=false;renderMessages();} + if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!(data.session._messages_truncated);if(typeof _oldestIdx!=='undefined')_oldestIdx=data.session._messages_offset||0;renderMessages();} $('msg').value=r.last_user_text||'';if(typeof autoResize==='function')autoResize(); // Re-arm the single-shot explicit-pick marker from the captured non-default // pick — but only if it's still safe at fire time (session unchanged, current @@ -1770,8 +1776,10 @@ async function cmdUndo(){ const r=await api('/api/session/undo',{method:'POST',body:JSON.stringify({session_id:activeSid})}); if(r&&r.error){showToast(r.error);return;} if(!S.session||S.session.session_id!==activeSid)return; - const data=await api('/api/session?session_id='+encodeURIComponent(activeSid)); - if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=false;renderMessages();} + // Bounded tail: a bare reload used to pull and re-redact the whole + // transcript on every /undo recovery (#7310/#7625). + const data=await api('/api/session?session_id='+encodeURIComponent(activeSid)+'&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1'); + if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!(data.session._messages_truncated);if(typeof _oldestIdx!=='undefined')_oldestIdx=data.session._messages_offset||0;renderMessages();} showToast(`↩ ${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`); }catch(e){showToast(t('undo_failed')+e.message);} } diff --git a/static/messages.js b/static/messages.js index 92a10712..8764fbf0 100644 --- a/static/messages.js +++ b/static/messages.js @@ -6883,6 +6883,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(sessionPayload); _attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018); S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018); + // A bounded cancel-recovery reload returns a tail window: keep the + // Load-earlier paging gate honest instead of hardcoding "not truncated" + // (#7310/#7625). + if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!sessionPayload._messages_truncated; + if(typeof _oldestIdx!=='undefined') _oldestIdx=sessionPayload._messages_offset||0; if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session); clearLiveToolCards();if(!assistantText)removeThinking(); _markSessionViewed(activeSid, sessionPayload.message_count ?? S.messages.length); @@ -6902,8 +6907,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(_applyCancelSessionPayload(_cancelSessionPayload)) return; // Fetch latest session from server to get accurate message list (includes cancel status) // This ensures messages stay in sync with server, fixing race condition where local - // "*Task cancelled.*" message gets lost when done event overwrites S.messages - const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`); + // "*Task cancelled.*" message gets lost when done event overwrites S.messages. + // Bounded tail: a bare reload used to pull and re-redact the whole transcript + // on every cancel recovery (#7310/#7625). + const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`); if(data&&data.session) _applyCancelSessionPayload(data.session); }catch(_){ // Fallback to local cancel message if API fails @@ -6993,7 +7000,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ return returnStatus?'stale':false; } try{ - const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`); + // Bounded tail: a bare reload used to pull and re-redact the whole + // transcript on every stream-end settle/reconnect recovery (#7310/#7625). + // The Load-earlier paging gate is restored from the response below. + const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`); // Opus #2852 race-fix: if a late `done` event ran the finalize path while // we were awaiting the network roundtrip, bail out — done already settled. if(_streamFinalized) return returnStatus?'restored':true; @@ -7047,6 +7057,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ ? [..._stagedMessages,..._currentVisibleMessages.slice(_stagedMessages.length)] : _stagedMessages; S.messages=_filterRecoveryControlMessages(_resolvedMessages || []); + // Bounded settle reload returns a tail window: keep the Load-earlier + // paging gate honest instead of leaving the previous (possibly full) + // transcript's truncation state stale (#7310/#7625). + if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!session._messages_truncated; + if(typeof _oldestIdx!=='undefined') _oldestIdx=session._messages_offset||0; _attachProjectedAnchorSceneToLastAssistant(S.messages); if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session); if(S.session&&S.session.session_id){ diff --git a/static/outline.js b/static/outline.js index 45acffe2..8923775f 100644 --- a/static/outline.js +++ b/static/outline.js @@ -104,16 +104,14 @@ function _jumpToMessage(rawIdx) { } // Row is outside the render window — reload the full session and retry. - // Use the bare messages=1 path (no msg_limit) so the server returns the - // COMPLETE transcript: the target row is addressed by absolute index - // (msg-user-), so a bounded tail window would miss early rows. - // (A previous version sent msg_limit=9999 as a "give me everything" hack, - // but the server now clamps msg_limit, so the bare path is the correct way - // to request the full transcript here.) + // Request the COMPLETE transcript via the explicit msg_limit=all escape + // hatch: the target row is addressed by absolute index (msg-user-), + // so a bounded tail window would miss early rows. (The server now defaults + // bare requests to a bounded window, so the full transcript must be opt-in.) if (typeof api !== 'function') return; if (S.busy || S.activeStreamId) return; api('/api/session?session_id=' + encodeURIComponent(sid) + - '&messages=1&resolve_model=0') + '&messages=1&resolve_model=0&msg_limit=all') .then(function(data) { if (!data || !data.session) return; if (!S.session || S.session.session_id !== sid) return; // session switched diff --git a/static/sessions.js b/static/sessions.js index 7be28623..48249fbc 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3904,7 +3904,7 @@ async function _ensureAllMessagesLoaded() { _loadingOlder = true; try { const sid = S.session.session_id; - const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0`, {timeoutMs:120000}); + const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_limit=all`, {timeoutMs:120000}); // Guard: api() may have redirected (401) and returned undefined. if (!data || !data.session) return; // Session may have been switched while we awaited. Bail rather than diff --git a/static/ui.js b/static/ui.js index 4a631482..8ad074bc 100644 --- a/static/ui.js +++ b/static/ui.js @@ -10135,7 +10135,11 @@ async function refreshSession() { dismissReconnect(); if (!S.session) return; try { - const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}`); + // Bounded tail (msg_limit=30) — a bare reload used to pull and re-redact + // the ENTIRE transcript on every offline/bfcache recovery (#7310/#7625). + // truncation signal + _oldestIdx are read below, so the Load-earlier + // paging gate still works after the windowed refresh. + const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`); S.session = data.session; if(typeof _adoptRegenerationRevision==='function') _adoptRegenerationRevision(data.session); S.messages = data.session.messages || []; diff --git a/tests/test_session_default_window.py b/tests/test_session_default_window.py new file mode 100644 index 00000000..e3850879 --- /dev/null +++ b/tests/test_session_default_window.py @@ -0,0 +1,172 @@ +"""Tests: GET /api/session full-transcript escape hatch + bounded recovery +calls — #7310 / #7625. + +Bare ``/api/session`` reads (no ``msg_limit``) were issued by six frontend +recovery paths (offline/bfcache ``refreshSession``, stream-end settle, cancel +sync, /compress preflight, /retry, /undo). Each used to re-walk, re-redact and +re-serialize the ENTIRE transcript (4-15s / 28MB on a 5k-row session, measured +in #7625). Those call sites now request a bounded tail explicitly +(``msg_limit=30``), while the two paths that address rows by absolute +transcript index (outline jump, jump-to-start) opt in to the full transcript +via the explicit ``msg_limit=all`` escape hatch. + +The bare no-limit HTTP shape deliberately keeps its historical full-transcript +contract: upstream contract tests pin tool-row preservation +(``test_state_db_reconciliation_preserves_sidecar_order_when_timestamps_collide``) +and the runtime-journal live snapshot +(``test_paginated_session_followup_does_not_repeat_runtime_snapshot``) on that +shape, so the fix is enforced at the frontend call sites rather than by +changing the server default. +""" +from __future__ import annotations + +import re +from pathlib import Path + +from api.routes import ( + _MAX_MSG_LIMIT, + _parse_msg_limit, + _resolve_effective_msg_limit, +) + +_ROOT = Path(__file__).resolve().parents[1] + + +# ── helper: _resolve_effective_msg_limit ── + + +def test_bare_request_keeps_full_transcript_contract(): + """A bare reload (no msg_limit, no msg_before) keeps the historical + full-transcript contract — server-side default windowing was rejected + because contract tests pin tool-row preservation and the runtime-journal + snapshot on this shape (#7310/#7625).""" + limit, explicit_all = _resolve_effective_msg_limit(None) + assert limit is None + assert explicit_all is False + + +def test_empty_and_malformed_keep_contract(): + assert _resolve_effective_msg_limit("") == (None, False) + assert _resolve_effective_msg_limit("not-a-number") == (None, False) + + +def test_explicit_all_resolves_to_full_transcript(): + """"msg_limit=all → (None, True): the explicit full-transcript escape + hatch for outline jump / jump-to-start.""" + limit, explicit_all = _resolve_effective_msg_limit("all") + assert limit is None + assert explicit_all is True + + +def test_explicit_all_case_insensitive(): + assert _resolve_effective_msg_limit("ALL") == (None, True) + assert _resolve_effective_msg_limit(" All ") == (None, True) + + +def test_numeric_limit_unchanged(): + """Explicit numeric limits keep the clamp behaviour — real pagination is + unaffected.""" + assert _resolve_effective_msg_limit("30") == (30, False) + assert _resolve_effective_msg_limit("9999") == (_MAX_MSG_LIMIT, False) + assert _resolve_effective_msg_limit("0") == (1, False) + + +def test_parse_msg_limit_all_returns_none(): + """The parse helper returns None for 'all' — the handler distinguishes it + from a bare request via _resolve_effective_msg_limit.""" + assert _parse_msg_limit("all") is None + assert _parse_msg_limit("ALL") is None + + +# ── frontend call sites: bounded shapes (no bare GET /api/session left) ── + +_UI_JS = (_ROOT / "static" / "ui.js").read_text(encoding="utf-8") +_MESSAGES_JS = (_ROOT / "static" / "messages.js").read_text(encoding="utf-8") +_SESSIONS_JS = (_ROOT / "static" / "sessions.js").read_text(encoding="utf-8") +_COMMANDS_JS = (_ROOT / "static" / "commands.js").read_text(encoding="utf-8") +_OUTLINE_JS = (_ROOT / "static" / "outline.js").read_text(encoding="utf-8") + +_BOUNDED = "&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1" +_ALL_QS = "&messages=1&resolve_model=0&msg_limit=all" + + +def test_refresh_session_uses_bounded_tail(): + """Offline/bfcache recovery refreshSession must not pull the full + transcript (#7310/#7625).""" + assert _BOUNDED in _UI_JS + + +def test_settle_and_cancel_paths_use_bounded_tail(): + """stream_end settle recovery AND cancel sync must not pull the full + transcript.""" + assert _BOUNDED in _MESSAGES_JS + assert _MESSAGES_JS.count(_BOUNDED) >= 2 + + +def test_cancel_payload_restores_truncation_signal(): + """_applyCancelSessionPayload keeps the Load-earlier paging gate honest + after a bounded cancel reload.""" + assert "!!sessionPayload._messages_truncated" in _MESSAGES_JS + assert "sessionPayload._messages_offset||0" in _MESSAGES_JS + + +def test_settle_restores_truncation_signal(): + """"_restoreSettledSession keeps the Load-earlier paging gate honest after + a bounded settle reload.""" + assert "!!session._messages_truncated" in _MESSAGES_JS + assert "session._messages_offset||0" in _MESSAGES_JS + + +def test_compress_preflight_uses_bounded_tail(): + """/compress preflight only needs session existence + the current tail.""" + assert _BOUNDED in _COMMANDS_JS + + +def test_retry_uses_bounded_tail(): + """/retry recovery must not pull the full transcript.""" + assert _COMMANDS_JS.count(_BOUNDED) >= 1 + + +def test_undo_uses_bounded_tail(): + """/undo recovery must not pull the full transcript.""" + assert _COMMANDS_JS.count(_BOUNDED) >= 2 + + +def test_recovery_paths_restore_truncation_signal(): + """preflight/retry/undo must NOT hardcode _messagesTruncated=false — they + read the server's truncation signal instead.""" + assert "!!(live.session._messages_truncated)" in _COMMANDS_JS + assert "!!(data.session._messages_truncated)" in _COMMANDS_JS + + +def test_jump_to_start_requests_explicit_full_transcript(): + """_ensureAllMessagesLoaded genuinely needs everything (absolute-index + addressing of the earliest rows) — it must opt in via msg_limit=all.""" + assert _ALL_QS in _SESSIONS_JS + + +def test_outline_jump_requests_explicit_full_transcript(): + """Outline jump addresses rows by absolute transcript index — it must opt + in via msg_limit=all.""" + assert _ALL_QS in _OUTLINE_JS + + +def test_no_bare_session_fetch_remains_in_frontend(): + """Regression guard: NO static/ caller may issue a bare GET /api/session + (no messages=/msg_limit= params). Use the bounded tail shape or the + explicit msg_limit=all escape hatch.""" + bare_template = re.compile( + r"api\(`/api/session\?session_id=\$\{encodeURIComponent\([^)]*\}\)`\)" + ) + bare_concat = re.compile( + r"api\('/api/session\?session_id='\s*\+\s*encodeURIComponent\([^)]*\)\)" + ) + for name, src in ( + ("ui.js", _UI_JS), + ("messages.js", _MESSAGES_JS), + ("sessions.js", _SESSIONS_JS), + ("commands.js", _COMMANDS_JS), + ("outline.js", _OUTLINE_JS), + ): + assert not bare_template.findall(src), f"{name} still has a bare template-literal /api/session fetch" + assert not bare_concat.findall(src), f"{name} still has a bare concatenated /api/session fetch" \ No newline at end of file From 73b7b949635d54295aaad0565f429b17da719e4e Mon Sep 17 00:00:00 2001 From: happy5318 Date: Fri, 18 Sep 2026 11:29:08 +0800 Subject: [PATCH 029/115] docs(outline): clarify full-transcript intent must be explicit --- static/outline.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/static/outline.js b/static/outline.js index 8923775f..0d2a361c 100644 --- a/static/outline.js +++ b/static/outline.js @@ -106,8 +106,9 @@ function _jumpToMessage(rawIdx) { // Row is outside the render window — reload the full session and retry. // Request the COMPLETE transcript via the explicit msg_limit=all escape // hatch: the target row is addressed by absolute index (msg-user-), - // so a bounded tail window would miss early rows. (The server now defaults - // bare requests to a bounded window, so the full transcript must be opt-in.) + // so a bounded tail window would miss early rows. The bare no-limit shape + // is not used here intentionally — full-transcript intent must be explicit + // so recovery paths stay bounded (#7310/#7625). if (typeof api !== 'function') return; if (S.busy || S.activeStreamId) return; api('/api/session?session_id=' + encodeURIComponent(sid) + From 39643e748f810629ab554a9fe60c09306ebc53e8 Mon Sep 17 00:00:00 2001 From: happy5318 Date: Fri, 18 Sep 2026 11:43:29 +0800 Subject: [PATCH 030/115] test: update cancel/settle source-shape assertions to bounded tail #7310/#7625: the cancel HTTP fallback and settle restore now request the bounded tail (msg_limit=30) instead of the bare full-transcript shape. test_cancelled_turn_status and test_live_to_final_anchor_visible_order assert the exact URL string inside the extracted cancel/settle blocks; the assertions' intent (embedded SSE payload applied BEFORE the HTTP fallback; fallback fetch precedes fallback apply) is unchanged, so the pinned strings are updated to the new bounded shape and the ordering contracts are untouched. Also moves the settle-path truncation-signal restore after the localStorage/update-url block so the 1800-char proximity window in test_session_rotate_url_sync keeps containing the tab-state sync lines. --- static/messages.js | 9 ++++----- tests/test_cancelled_turn_status.py | 4 +++- tests/test_live_to_final_anchor_visible_order.py | 5 ++++- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/static/messages.js b/static/messages.js index 8764fbf0..0397738c 100644 --- a/static/messages.js +++ b/static/messages.js @@ -7057,17 +7057,16 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ ? [..._stagedMessages,..._currentVisibleMessages.slice(_stagedMessages.length)] : _stagedMessages; S.messages=_filterRecoveryControlMessages(_resolvedMessages || []); - // Bounded settle reload returns a tail window: keep the Load-earlier - // paging gate honest instead of leaving the previous (possibly full) - // transcript's truncation state stale (#7310/#7625). - if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!session._messages_truncated; - if(typeof _oldestIdx!=='undefined') _oldestIdx=session._messages_offset||0; _attachProjectedAnchorSceneToLastAssistant(S.messages); if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session); if(S.session&&S.session.session_id){ try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id); } + // Bounded settle reload returns a tail window: keep the Load-earlier + // paging gate honest (#7310/#7625). + if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!session._messages_truncated; + if(typeof _oldestIdx!=='undefined') _oldestIdx=session._messages_offset||0; if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(session); const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages); if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error'); diff --git a/tests/test_cancelled_turn_status.py b/tests/test_cancelled_turn_status.py index bfeebe5b..df9554cb 100644 --- a/tests/test_cancelled_turn_status.py +++ b/tests/test_cancelled_turn_status.py @@ -219,7 +219,9 @@ class TestCancelledTurnPersistenceGuards: assert "const _applyCancelSessionPayload=(sessionPayload)=>" in block assert "const _cancelSessionPayload=_cancelData&&typeof _cancelData.session==='object'?_cancelData.session:null;" in block assert "if(_applyCancelSessionPayload(_cancelSessionPayload)) return;" in block - assert "const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);" in block + # #7310/#7625: the HTTP fallback is a bounded tail now — the full + # transcript was being re-walked/re-redacted on every cancel recovery. + assert "const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`);" in block assert block.index("if(_applyCancelSessionPayload(_cancelSessionPayload)) return;") < block.index("const data=await api("), ( "Cancel handler must apply the terminal SSE session payload before falling back " "to /api/session so captured _partial reasoning/tool rows are visible immediately." diff --git a/tests/test_live_to_final_anchor_visible_order.py b/tests/test_live_to_final_anchor_visible_order.py index 589ed37b..77ae81e7 100644 --- a/tests/test_live_to_final_anchor_visible_order.py +++ b/tests/test_live_to_final_anchor_visible_order.py @@ -829,7 +829,10 @@ def test_cancel_settlement_attaches_projected_anchor_scene_before_render(): assert fetch_idx < attach_idx < carry_idx < render_idx embedded_idx = cancel.index("if(_applyCancelSessionPayload(_cancelSessionPayload)) return;") - fallback_get_idx = cancel.index("const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);") + # #7310/#7625: the HTTP fallback is a bounded tail now (full-transcript + # reloads were stacking on every cancel recovery); ordering contract below + # is unchanged. + fallback_get_idx = cancel.index("const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`);") fallback_apply_idx = cancel.index("if(data&&data.session) _applyCancelSessionPayload(data.session);") assert embedded_idx < fallback_get_idx < fallback_apply_idx From f4b28346e5d2050fc9051f4cd981cab3cbf50924 Mon Sep 17 00:00:00 2001 From: happy5318 Date: Fri, 18 Sep 2026 22:00:45 +0800 Subject: [PATCH 031/115] fix(session): keep bounded-tail recovery suffix and paging offset Bounded settle/cancel reloads return the latest 30 messages, not a prefix of the visible window. The shorter-terminal preserve path only compared prefixes, so a long session dropped the Connection-interrupted marker. Anchor persist also ran before _oldestIdx was refreshed from the bounded response, writing the scene against the wrong transcript row. - Treat a suffix match like a prefix match when preserving the terminal recovery marker - Refresh _messagesTruncated / _oldestIdx before attaching/persisting the projected scene on settle, cancel, and apperror - Document the msg_limit=30 / msg_limit=all contract - Add runtime + source-order regression tests --- ARCHITECTURE.md | 13 +- static/messages.js | 54 +++++--- ...5224_terminal_error_transcript_preserve.py | 124 +++++++++++++++++- ...test_live_to_final_anchor_visible_order.py | 7 +- tests/test_session_default_window.py | 47 ++++++- 5 files changed, 224 insertions(+), 21 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 72be8bc8..3fb292ff 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -511,7 +511,9 @@ to the active conversation rather than a global app setting. Session management: newSession() POST /api/session/new, update S.session, save to localStorage - loadSession(sid) GET /api/session?session_id=X, check INFLIGHT first, update S + loadSession(sid) GET /api/session?session_id=X (initial load uses the + bounded tail `msg_limit=30`; jump-to-start and outline + jump pass `msg_limit=all`), check INFLIGHT first, update S deleteSession(sid) POST /api/session/delete, handle active/inactive cases correctly renderSessionList() GET /api/sessions, rebuild #sessionList DOM @@ -1379,7 +1381,14 @@ Complete list of all HTTP endpoints as of Sprint 1 (v0.3). / Returns full HTML app (index page) /index.html Same as / /health {"status":"ok","sessions":N} - /api/session ?session_id=X -> full session + messages. 400 if no ID. + /api/session ?session_id=X -> session + messages. 400 if no ID. + Bare (no msg_limit) keeps the historical full-transcript + contract. Recovery paths request a bounded tail + (`msg_limit=30`) and restore `_messages_truncated` / + `_messages_offset` before persisting anchor-scene + metadata. Outline jump and jump-to-start opt in to the + full transcript via the explicit `msg_limit=all` + escape hatch. See #7310 / #7625 / #7628. /api/sessions List of all session compact() dicts, sorted by updated_at /api/list ?session_id=X&path=. -> directory listing for session workspace /api/file ?session_id=X&path=rel -> file content (text, 200KB limit) diff --git a/static/messages.js b/static/messages.js index 0397738c..a4555bbf 100644 --- a/static/messages.js +++ b/static/messages.js @@ -6640,6 +6640,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ S.session=d.session; const _nextMsgs3018=(d.session.messages||[]).filter(m=>m&&m.role); if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(d.session); + // Same persist-before-offset trap as settle/cancel: refresh paging + // before attaching the projected scene (#7628). + if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!d.session._messages_truncated; + if(typeof _oldestIdx!=='undefined') _oldestIdx=d.session._messages_offset||0; _attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018); S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018); if(S.session&&S.session.session_id){ @@ -6881,13 +6885,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ S.session=sessionPayload; const _nextMsgs3018=(sessionPayload.messages||[]).filter(m=>m&&m.role); if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(sessionPayload); - _attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018); - S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018); // A bounded cancel-recovery reload returns a tail window: keep the - // Load-earlier paging gate honest instead of hardcoding "not truncated" - // (#7310/#7625). + // Load-earlier paging gate honest, and refresh _oldestIdx BEFORE + // persisting the projected scene (#7310/#7625/#7628). if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!sessionPayload._messages_truncated; if(typeof _oldestIdx!=='undefined') _oldestIdx=sessionPayload._messages_offset||0; + _attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018); + S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018); if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session); clearLiveToolCards();if(!assistantText)removeThinking(); _markSessionViewed(activeSid, sessionPayload.message_count ?? S.messages.length); @@ -7037,6 +7041,16 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const _nextMsgs3018=(session.messages||[]).filter(m=>m&&m.role); const _currentMessages=Array.isArray(S.messages)?S.messages:[]; const _currentVisibleMessages=_filterRecoveryControlMessages(_currentMessages || []); + // Restore paging from the bounded tail BEFORE attaching/persisting the + // projected scene: _persistSettledAnchorScene reads _oldestIdx to + // compute the absolute transcript index (#7628). + if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!session._messages_truncated; + if(typeof _oldestIdx!=='undefined') _oldestIdx=session._messages_offset||0; + if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(session); + if(S.session&&S.session.session_id){ + try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} + if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id); + } const _stagedMessages=_carryForwardEphemeralTurnFields(_currentMessages, _nextMsgs3018); const _currentVisibleEndsWithTerminalMarker=( _currentVisibleMessages.length>0 && @@ -7052,22 +7066,32 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ return !!stagedKey && stagedKey===currentKey; }) ); - const _preserveCurrentTranscript=preserveVisibleOnShorterTerminalSnapshot&&_stagedMatchesCurrentPrefix; + // Bounded settle returns a suffix of the durable transcript, not a + // prefix of the currently visible window. On a long session the + // prefix check fails and would drop the terminal recovery marker + // (#7628). Treat a suffix match the same as a prefix match. + const _durableVisibleCount=_currentVisibleEndsWithTerminalMarker + ? _currentVisibleMessages.length-1 + : _currentVisibleMessages.length; + const _stagedSuffixStart=_durableVisibleCount-_stagedMessages.length; + const _stagedMatchesCurrentSuffix=( + _stagedMessages.length>0 && + _stagedSuffixStart>=0 && + _stagedMessages.length<_currentVisibleMessages.length && + _currentVisibleEndsWithTerminalMarker && + _stagedMessages.every((message, idx)=>{ + const stagedKey=_messageIdentityKey(message); + const currentKey=_messageIdentityKey(_currentVisibleMessages[_stagedSuffixStart+idx]); + return !!stagedKey && stagedKey===currentKey; + }) + ); + const _preserveCurrentTranscript=preserveVisibleOnShorterTerminalSnapshot&&(_stagedMatchesCurrentPrefix||_stagedMatchesCurrentSuffix); const _resolvedMessages=_preserveCurrentTranscript - ? [..._stagedMessages,..._currentVisibleMessages.slice(_stagedMessages.length)] + ? [..._stagedMessages,..._currentVisibleMessages.slice(_stagedMatchesCurrentPrefix?_stagedMessages.length:_stagedSuffixStart+_stagedMessages.length)] : _stagedMessages; S.messages=_filterRecoveryControlMessages(_resolvedMessages || []); _attachProjectedAnchorSceneToLastAssistant(S.messages); if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session); - if(S.session&&S.session.session_id){ - try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} - if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id); - } - // Bounded settle reload returns a tail window: keep the Load-earlier - // paging gate honest (#7310/#7625). - if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!session._messages_truncated; - if(typeof _oldestIdx!=='undefined') _oldestIdx=session._messages_offset||0; - if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(session); const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages); if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error'); const hasMessageToolMetadata=S.messages.some(m=>{ diff --git a/tests/test_issue5224_terminal_error_transcript_preserve.py b/tests/test_issue5224_terminal_error_transcript_preserve.py index 29ac384d..1a381f6b 100644 --- a/tests/test_issue5224_terminal_error_transcript_preserve.py +++ b/tests/test_issue5224_terminal_error_transcript_preserve.py @@ -109,7 +109,6 @@ function buildRuntime() { globalThis.removeThinking = () => calls.push('removeThinking'); globalThis._flushReasoningToAnchor = () => calls.push('flushReasoning'); globalThis._applyToAnchor = () => calls.push('applyToAnchor'); - globalThis._attachProjectedAnchorSceneToLastAssistant = () => calls.push('attachProjected'); globalThis._hydrateTodosFromSession = () => calls.push('hydrateTodos'); globalThis._scheduleAnchorRegistryCleanup = () => calls.push('scheduleAnchorRegistryCleanup'); globalThis._smdEndParser = () => calls.push('smdEndParser'); @@ -141,6 +140,15 @@ function buildRuntime() { globalThis._messageRenderWindowSize = 20; globalThis._streamFinalized = !!scenario.streamFinalized; globalThis._persistTimer = null; + globalThis._oldestIdx = Number.isFinite(scenario.oldestIdx) ? scenario.oldestIdx : 0; + globalThis._messagesTruncated = !!scenario.messagesTruncated; + globalThis.oldestIdxAtAttach = null; + globalThis.messagesTruncatedAtAttach = null; + globalThis._attachProjectedAnchorSceneToLastAssistant = () => { + calls.push('attachProjected'); + globalThis.oldestIdxAtAttach = globalThis._oldestIdx; + globalThis.messagesTruncatedAtAttach = globalThis._messagesTruncated; + }; globalThis.api = async () => scenario.apiPayload || { session: null }; globalThis.msgContent = undefined; globalThis._isPreservedCompressionTaskListMarkerOnlyText = () => false; @@ -162,6 +170,10 @@ function buildRuntime() { status, messages: messages.map((m) => ({ role: m.role, content: m.content })), terminalMarkerCount, + oldestIdx: globalThis._oldestIdx, + oldestIdxAtAttach: globalThis.oldestIdxAtAttach, + messagesTruncated: !!globalThis._messagesTruncated, + messagesTruncatedAtAttach: !!globalThis.messagesTruncatedAtAttach, calls, })); return; @@ -179,6 +191,10 @@ function buildRuntime() { status, messages: messages.map((m) => ({ role: m.role, content: m.content })), terminalMarkerCount, + oldestIdx: globalThis._oldestIdx, + oldestIdxAtAttach: globalThis.oldestIdxAtAttach, + messagesTruncated: !!globalThis._messagesTruncated, + messagesTruncatedAtAttach: !!globalThis.messagesTruncatedAtAttach, calls, })); return; @@ -421,3 +437,109 @@ def test_terminal_error_marker_is_single_instance_and_not_duplicated(driver_path assert outcome["terminalMarkerCount"] == 1, f"terminal marker should be deduped to one, got {outcome['terminalMarkerCount']}" assert outcome["messages"][-1]["content"].startswith("**Connection interrupted:**") + + +def test_bounded_tail_preserves_terminal_marker_when_server_returns_suffix(driver_path): + """A bounded settle tail is a suffix of the visible window, not a prefix. + + Long-session recovery used to compare the 30-row tail against the start of + S.messages. The prefix check failed, so preserveVisibleOnShorterTerminalSnapshot + dropped the client-side Connection-interrupted marker (#7628). + """ + historical = [] + for i in range(2): + historical.append({"role": "user", "content": f"Earlier question {i}", "_ts": f"u{i}"}) + historical.append({"role": "assistant", "content": f"Earlier answer {i}", "_ts": f"a{i}"}) + tail = [ + {"role": "user", "content": "Current question", "_ts": "u-cur"}, + {"role": "assistant", "content": "Current fragment one", "_ts": "a-cur"}, + ] + terminal = { + "role": "assistant", + "content": "**Connection interrupted:** The browser lost the live SSE connection before the response finished.", + "_ts": "err", + } + outcome = _run_scenario(driver_path, { + "action": "restore_shorter_terminal", + "oldestIdx": 0, + "messagesTruncated": False, + "state": { + "session": {"session_id": "session-5224", "message_count": 7}, + "messages": historical + tail + [terminal], + "activeStreamId": "stream-5224", + }, + "apiPayload": { + "session": { + "session_id": "session-5224", + "active_stream_id": None, + "pending_user_message": None, + "messages": tail, + "_messages_truncated": True, + "_messages_offset": 4, + }, + }, + "activeSid": "session-5224", + "streamId": "stream-5224", + "isActiveSession": True, + "isSessionCurrentPane": True, + "isSessionActivelyViewed": False, + }) + + observed = [(item["role"], item["content"]) for item in outcome["messages"]] + assert observed == [ + ("user", "Current question"), + ("assistant", "Current fragment one"), + ("assistant", "**Connection interrupted:** The browser lost the live SSE connection before the response finished."), + ], f"bounded tail must keep the terminal recovery suffix: {observed}" + assert outcome["terminalMarkerCount"] == 1, ( + f"expected the terminal marker to survive a suffix-only tail, got {outcome['terminalMarkerCount']}" + ) + assert outcome["oldestIdx"] == 4, f"paging offset should come from the bounded response, got {outcome['oldestIdx']}" + assert outcome["oldestIdxAtAttach"] == 4, ( + f"anchor persist must see the new offset, got {outcome['oldestIdxAtAttach']}" + ) + assert outcome["messagesTruncated"] is True + assert outcome["messagesTruncatedAtAttach"] is True + + +def test_settle_refreshes_paging_offset_before_anchor_persist(driver_path): + """_persistSettledAnchorScene reads _oldestIdx; a stale 0 would write the scene to the wrong row.""" + outcome = _run_scenario(driver_path, { + "action": "restore_fuller_terminal", + "oldestIdx": 0, + "messagesTruncated": False, + "state": { + "session": {"session_id": "session-5224", "message_count": 1}, + "messages": [ + {"role": "assistant", "content": "Old interrupted shell", "_ts": "old"}, + ], + "activeStreamId": "stream-5224", + }, + "apiPayload": { + "session": { + "session_id": "session-5224", + "active_stream_id": None, + "pending_user_message": None, + "messages": [ + {"role": "user", "content": "Question about data?", "_ts": "u1"}, + {"role": "assistant", "content": "Settled first segment", "_ts": "a1"}, + {"role": "assistant", "content": "Settled second segment", "_ts": "a2"}, + {"role": "assistant", "content": "Settled third segment", "_ts": "a3"}, + ], + "_messages_truncated": True, + "_messages_offset": 40, + }, + }, + "activeSid": "session-5224", + "streamId": "stream-5224", + "isActiveSession": True, + "isSessionCurrentPane": True, + "isSessionActivelyViewed": False, + }) + + assert outcome["status"] == "restored" + assert outcome["oldestIdx"] == 40 + assert outcome["oldestIdxAtAttach"] == 40, ( + f"stale offset 0 must not leak into anchor persist, got {outcome['oldestIdxAtAttach']}" + ) + assert outcome["messagesTruncatedAtAttach"] is True diff --git a/tests/test_live_to_final_anchor_visible_order.py b/tests/test_live_to_final_anchor_visible_order.py index 77ae81e7..90dc5c38 100644 --- a/tests/test_live_to_final_anchor_visible_order.py +++ b/tests/test_live_to_final_anchor_visible_order.py @@ -816,17 +816,20 @@ def test_stream_end_restore_attaches_projected_anchor_scene_before_render(): filter_idx = restore.index("S.messages=_filterRecoveryControlMessages(_resolvedMessages || []);") attach_idx = restore.index("_attachProjectedAnchorSceneToLastAssistant(S.messages);") render_idx = restore.index("syncTopbar();renderMessages({preserveScroll:true})") - assert carry_idx < filter_idx < attach_idx < render_idx + offset_idx = restore.index("_oldestIdx=session._messages_offset||0") + assert offset_idx < carry_idx < filter_idx < attach_idx < render_idx + assert "_stagedMatchesCurrentSuffix" in restore def test_cancel_settlement_attaches_projected_anchor_scene_before_render(): cancel = _event_listener_body(MESSAGES_JS, "cancel") fetch_idx = cancel.index("const _nextMsgs3018=(sessionPayload.messages||[]).filter(m=>m&&m.role);") + offset_idx = cancel.index("_oldestIdx=sessionPayload._messages_offset||0") attach_idx = cancel.index("_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);") carry_idx = cancel.index("S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);") render_idx = cancel.index("renderMessages({preserveScroll:true});") - assert fetch_idx < attach_idx < carry_idx < render_idx + assert fetch_idx < offset_idx < attach_idx < carry_idx < render_idx embedded_idx = cancel.index("if(_applyCancelSessionPayload(_cancelSessionPayload)) return;") # #7310/#7625: the HTTP fallback is a bounded tail now (full-transcript diff --git a/tests/test_session_default_window.py b/tests/test_session_default_window.py index e3850879..8e94e455 100644 --- a/tests/test_session_default_window.py +++ b/tests/test_session_default_window.py @@ -111,12 +111,57 @@ def test_cancel_payload_restores_truncation_signal(): def test_settle_restores_truncation_signal(): - """"_restoreSettledSession keeps the Load-earlier paging gate honest after + """_restoreSettledSession keeps the Load-earlier paging gate honest after a bounded settle reload.""" assert "!!session._messages_truncated" in _MESSAGES_JS assert "session._messages_offset||0" in _MESSAGES_JS +def _restore_settled_session_body(): + start = _MESSAGES_JS.index("async function _restoreSettledSession") + end = _MESSAGES_JS.index("function _handleStreamError", start) + return _MESSAGES_JS[start:end] + + +def _apply_cancel_session_payload_body(): + start = _MESSAGES_JS.index("const _applyCancelSessionPayload=(sessionPayload)=>") + end = _MESSAGES_JS.index("Prefer the canonical session snapshot", start) + return _MESSAGES_JS[start:end] + + +def test_settle_refreshes_paging_before_anchor_persist(): + """Anchor persist reads _oldestIdx; a bounded tail must refresh it first (#7628).""" + restore = _restore_settled_session_body() + offset_idx = restore.index("_oldestIdx=session._messages_offset||0") + attach_idx = restore.index("_attachProjectedAnchorSceneToLastAssistant(S.messages);") + assert offset_idx < attach_idx + + +def test_cancel_refreshes_paging_before_anchor_persist(): + """Cancel recovery has the same persist-before-offset trap as settle (#7628).""" + cancel = _apply_cancel_session_payload_body() + offset_idx = cancel.index("_oldestIdx=sessionPayload._messages_offset||0") + attach_idx = cancel.index("_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);") + assert offset_idx < attach_idx + + +def test_settle_preserves_terminal_marker_against_bounded_suffix(): + """Long-session bounded tails are suffixes of S.messages, not prefixes (#7628).""" + restore = _restore_settled_session_body() + assert "_stagedMatchesCurrentSuffix" in restore + assert "_preserveCurrentTranscript=preserveVisibleOnShorterTerminalSnapshot&&(_stagedMatchesCurrentPrefix||_stagedMatchesCurrentSuffix)" in restore + + +def test_apperror_embedded_session_refreshes_paging_before_anchor_persist(): + """The apperror embedded-session path has the same persist-before-offset trap (#7628).""" + start = _MESSAGES_JS.index("source.addEventListener('apperror'") + end = _MESSAGES_JS.index("source.addEventListener('warning'", start) + block = _MESSAGES_JS[start:end] + offset_idx = block.index("_oldestIdx=d.session._messages_offset||0") + attach_idx = block.index("_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);") + assert offset_idx < attach_idx + + def test_compress_preflight_uses_bounded_tail(): """/compress preflight only needs session existence + the current tail.""" assert _BOUNDED in _COMMANDS_JS From 95c174f038313184837bb6984cdfb3ef9c19fa22 Mon Sep 17 00:00:00 2001 From: happy5318 Date: Sat, 19 Sep 2026 12:55:40 +0800 Subject: [PATCH 032/115] fix(session): restore paging after compress; suffix-exclusive settle strategy (review round 3) Two data-integrity blockers from round-3 review (nesquena-hermes 2026-09-19): 1. [CORE] A successful /compress returns the full transcript, but _applyManualCompressionResult kept the bounded preflight's paging signals (_messagesTruncated/_oldestIdx). Render now restores them before renderMessages(), same ordering as settle/cancel paths. 2. [SILENT] _restoreSettledSession preservation used (prefix || suffix), so repeated identical turns (colliding _messageIdentityKey) made BOTH comparisons succeed and the prefix branch spliced at the wrong offset - silently dropping rows and duplicating others. The server truncation signal now decides the strategy: truncated => suffix-exclusive, untruncated => prefix. Tests: node behavioral driver covering /compress paging restore, repeated-turn settle (no drop/dup, authoritative offset), and gap-free msg_before paging after bounded recovery. --- static/commands.js | 6 + static/messages.js | 12 +- tests/test_7628_recovery_paging.py | 363 +++++++++++++++++++++++++++ tests/test_session_default_window.py | 14 +- 4 files changed, 392 insertions(+), 3 deletions(-) create mode 100644 tests/test_7628_recovery_paging.py diff --git a/static/commands.js b/static/commands.js index 336b70c2..869d64c2 100644 --- a/static/commands.js +++ b/static/commands.js @@ -873,6 +873,12 @@ async function _applyManualCompressionResult(data, focusTopic, visibleCount, com clearLiveToolCards(); try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id); + // Restore paging signals from the (possibly full) transcript response. + // A successful /compress returns the full transcript, so the bounded + // preflight's _messagesTruncated/_oldestIdx must be reset before render + // (#7628). Same restore-before-render ordering as the settle/cancel paths. + if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!data.session._messages_truncated; + if(typeof _oldestIdx!=='undefined') _oldestIdx=data.session._messages_offset||0; syncTopbar(); renderMessages(); await renderSessionList(); diff --git a/static/messages.js b/static/messages.js index a4555bbf..8295794d 100644 --- a/static/messages.js +++ b/static/messages.js @@ -7085,9 +7085,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ return !!stagedKey && stagedKey===currentKey; }) ); - const _preserveCurrentTranscript=preserveVisibleOnShorterTerminalSnapshot&&(_stagedMatchesCurrentPrefix||_stagedMatchesCurrentSuffix); + // The server's truncation signal decides the strategy, not an `||`: + // a bounded settle returns a SUFFIX of the durable transcript, so + // suffix matching is the only correct strategy when truncated. Prefix + // preservation is reserved for untruncated snapshots. When repeated + // identical turns make BOTH comparisons succeed, preferring the prefix + // splices at the wrong offset and silently drops/duplicates rows + // (#7628). Never prefer prefix when truncation is active. + const _truncatedRecovery=!!_messagesTruncated||!!(_oldestIdx>0); + const _preserveCurrentTranscript=preserveVisibleOnShorterTerminalSnapshot&&(_truncatedRecovery?_stagedMatchesCurrentSuffix:_stagedMatchesCurrentPrefix); const _resolvedMessages=_preserveCurrentTranscript - ? [..._stagedMessages,..._currentVisibleMessages.slice(_stagedMatchesCurrentPrefix?_stagedMessages.length:_stagedSuffixStart+_stagedMessages.length)] + ? [..._stagedMessages,..._currentVisibleMessages.slice(_truncatedRecovery?_stagedSuffixStart+_stagedMessages.length:_stagedMessages.length)] : _stagedMessages; S.messages=_filterRecoveryControlMessages(_resolvedMessages || []); _attachProjectedAnchorSceneToLastAssistant(S.messages); diff --git a/tests/test_7628_recovery_paging.py b/tests/test_7628_recovery_paging.py new file mode 100644 index 00000000..64885907 --- /dev/null +++ b/tests/test_7628_recovery_paging.py @@ -0,0 +1,363 @@ +"""Behavioral tests for #7628 review round: /compress paging restore + suffix +strategy for repeated identical turns. + +Round-3 review (nesquena-hermes 2026-09-19) found two data-integrity blockers: + +1. [CORE] A successful /compress returns the FULL transcript, but + `_applyManualCompressionResult` did not restore `_messagesTruncated` / + `_oldestIdx` — the bounded preflight's values (e.g. truncated=true, + oldestIdx=70) survived, producing a false "70 older messages" affordance + and shifting every absolute transcript index by 70. + +2. [SILENT] `_restoreSettledSession` preservation used + `(_stagedMatchesCurrentPrefix || _stagedMatchesCurrentSuffix)`, so when + identical repeated turns made BOTH comparisons succeed the prefix branch + spliced at the wrong offset — silently dropping and duplicating rows. + The server truncation signal now decides: truncated => suffix-exclusive. + +Node-backed tests extract the real functions from static/ and drive them +against a stub DOM (same harness pattern as test_issue5224). +""" +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +_ROOT = Path(__file__).resolve().parents[1] +_COMMANDS_JS = _ROOT / "static" / "commands.js" +_MESSAGES_JS = _ROOT / "static" / "messages.js" +_SESSIONS_JS = _ROOT / "static" / "sessions.js" +_NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(_NODE is None, reason="node is required to execute message runtime tests") + + +_DRIVER = r""" +const fs = require('fs'); +const commandsSrc = fs.readFileSync(process.argv[2], 'utf8'); +const messagesSrc = fs.readFileSync(process.argv[3], 'utf8'); +const sessionsSrc = fs.readFileSync(process.argv[4], 'utf8'); +const scenario = JSON.parse(process.argv[5] || '{}'); + +function extractFunction(source, name) { + const markers = [`async function ${name}(`, `function ${name}(${name}`, `function ${name}(`, `${name}=async(`, `${name}=async (`]; + let start = -1; + for (const marker of markers) { + start = source.indexOf(marker); + if (start >= 0) break; + } + if (start < 0) throw new Error(`missing ${name}`); + let i = source.indexOf('{', start); + if (i < 0) throw new Error(`missing body for ${name}`); + let depth = 1; i++; + while (i < source.length) { + const ch = source[i]; + if (ch === '{') depth++; + if (ch === '}') { depth--; if (depth === 0) return source.slice(start, i + 1); } + i++; + } + throw new Error(`unterminated body for ${name}`); +} + +function install(name, src) { + const body = extractFunction(src, name); + // Drop the `async function name(` or `function name(` prefix, keep body. + const eq = body.indexOf('{'); + const inner = body.slice(eq); + globalThis[name] = new Function(`return (${name === 'jumpToSessionStart' ? 'async function' : 'function'} ${inner})`)(); +} + +// ---- helpers from messages.js (mirror test_issue5224 set) ---- +const MESSAGE_HELPERS = [ + "_isMarkerOnlyAssistantMessage", "_streamRecoveryControlMessageText", + "_streamRecoveryControlMessage", "_filterRecoveryControlMessages", + "_replaceMarkerOnlyAssistantWithStreamError", "_messageIdentityKey", + "_isHistoricalAnchorActivityScene", "_carryForwardEphemeralTurnFields", + "_isTerminalStreamErrorMarkerMessage", "_restoreSettledSession", + "_mergeSettledToolCallsWithLiveMetadata", +]; +for (const h of MESSAGE_HELPERS) { + const body = extractFunction(messagesSrc, h); + const factory = new Function(`${body}; return ${h};`); + globalThis[h] = factory(); +} + +// ---- compress result applier from commands.js ---- +const COMPRESS_BODY = extractFunction(commandsSrc, '_applyManualCompressionResult'); +// Rebuild as a normal function (it contains `await` – keep async). +globalThis._applyManualCompressionResult = new Function( + 'return (' + COMPRESS_BODY + ')' +)(); + +function runtimeStubs() { + const calls = []; + globalThis.S = { session: { session_id: 's1' }, messages: [], toolCalls: [] }; + globalThis.activeSid = 's1'; + globalThis.streamId = 'stream-1'; + globalThis.assistantText = false; + globalThis._messagesTruncated = !!scenario.prestateTruncated; + globalThis._oldestIdx = scenario.prestateOldestIdx || 0; + globalThis._messagesGeneration = 1; + globalThis._loadingOlder = false; + globalThis._loadingSessionId = null; + globalThis._messageRenderWindowSize = 50; + globalThis._msgLimitMax = scenario.msgLimitMax || 200; + globalThis._INITIAL_MSG_LIMIT = 50; + globalThis._EPHEMERAL_TURN_FIELDS = ['_turnUsage','_turnDuration','_turnTps','_gatewayRouting','_statusCard','_anchor_stream_id','_anchor_activity_scene']; + globalThis._isActiveSession = () => scenario.isActiveSession !== false; + globalThis._isSessionCurrentPane = () => scenario.isSessionCurrentPane !== false; + globalThis._isSessionActivelyViewed = () => !!scenario.isSessionActivelyViewed; + globalThis._closeSource = () => calls.push('closeSource'); + globalThis._clearStreamEndRecovery = () => calls.push('clearStreamEndRecovery'); + globalThis._clearOwnerInflightState = () => calls.push('clearOwnerInflight'); + globalThis.clearLiveToolCards = () => calls.push('clearLiveToolCards'); + globalThis.removeThinking = () => calls.push('removeThinking'); + globalThis._flushReasoningToAnchor = () => calls.push('flushReasoning'); + globalThis._applyToAnchor = () => calls.push('applyToAnchor'); + globalThis._hydrateTodosFromSession = () => calls.push('hydrateTodos'); + globalThis._scheduleAnchorRegistryCleanup = () => calls.push('scheduleAnchorRegistryCleanup'); + globalThis._smdEndParser = () => calls.push('smdEndParser'); + globalThis._markSessionCompletionUnread = () => calls.push('markCompletionUnread'); + globalThis._markSessionViewed = () => calls.push('markSessionViewed'); + globalThis.localStorage = { setItem: () => calls.push('setLocalStorageItem'), getItem: () => null, removeItem: () => calls.push('removeLocalStorageItem') }; + globalThis._setActiveSessionUrl = () => calls.push('setActiveSessionUrl'); + globalThis.showToast = () => calls.push('showToast'); + globalThis._clearApprovalForOwner = () => calls.push('clearApprovalForOwner'); + globalThis._clearClarifyForOwner = () => calls.push('clearClarifyForOwner'); + globalThis._streamFadeCleanupReduceMotionListener = () => calls.push('streamFadeCleanup'); + globalThis._cancelThrottledSnapshotTimer = () => calls.push('cancelThrottledSnapshot'); + globalThis._clearAnchorProseIncrementalNode = () => calls.push('clearAnchorProse'); + globalThis._cancelAnimationFramePendingStreamRender = () => calls.push('cancelRaf'); + globalThis.finalizeThinkingCard = () => calls.push('finalizeThinkingCard'); + globalThis.syncTopbar = () => calls.push('syncTopbar'); + globalThis.renderMessages = () => calls.push('renderMessages'); + globalThis.renderSessionList = () => calls.push('renderSessionList'); + globalThis.updateQueueBadge = () => calls.push('updateQueueBadge'); + globalThis._setActivePaneIdleIfOwner = () => calls.push('setActivePaneIdle'); + globalThis.setBusy = () => calls.push('setBusy'); + globalThis.setComposerStatus = () => calls.push('setComposerStatus'); + globalThis.setCompressionUi = () => calls.push('setCompressionUi'); + globalThis.setStatus = () => calls.push('setStatus'); + globalThis.msgContent = (m) => (m && m.content && typeof m.content === 'string' ? m.content : ''); + globalThis._isContextCompactionMessage = () => false; + globalThis._setCompressionSessionLock = () => calls.push('setCompressionSessionLock'); + globalThis._messageRenderableMessageCount = () => scenario.messageRenderableCount || 50; + globalThis._currentMessageRenderWindowSize = () => scenario.currentWindowSize || 12; + globalThis._restoreMessageRenderWindowAfterSettledRender = () => calls.push('restoreRenderWindow'); + globalThis.projectSessionArtifactsForOwner = () => calls.push('projectArtifacts'); + globalThis._streamFinalized = false; + globalThis._persistTimer = null; + globalThis._attachProjectedAnchorSceneToLastAssistant = () => calls.push('attachProjected'); + globalThis._isMessagePaneNearBottom = () => true; + globalThis._isMessageReaderUnpinned = () => false; + globalThis._queueDrainSid = null; + // reducer used by _restoreSettledSession (mirrors live code) + return calls; +} + +(async () => { + const calls = runtimeStubs(); + + if (scenario.action === 'compress_restores_paging') { + // Pre-state: bounded preflight left truncated=true, oldestIdx=70. + globalThis._messagesTruncated = true; + globalThis._oldestIdx = 70; + const fullRows = []; + for (let i = 0; i < 100; i++) { + fullRows.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `row-${i}`, _ts: `ts-${i}` }); + } + const payload = { + session: { + session_id: 's1', + message_count: 100, + messages: fullRows, + tool_calls: [], + _messages_truncated: false, + _messages_offset: 0, + }, + }; + await globalThis._applyManualCompressionResult(payload, '', 100, '/compress'); + console.log(JSON.stringify({ + action: 'compress_restores_paging', + messagesTruncated: !!globalThis._messagesTruncated, + oldestIdx: globalThis._oldestIdx, + messageCountAfter: Array.isArray(S.messages) ? S.messages.length : -1, + calls, + })); + return; + } + + if (scenario.action === 'settle_repeated_turns') { + // Bounded settle against a transcript containing repeated identical turns + // (same role/content but distinct _ids). Round-2 bug: BOTH prefix and + // suffix matched, prefix won, and rows were dropped+duplicated. + const state = scenario.state || {}; + globalThis.S.session = { session_id: 's1', message_count: state.messageCount }; + globalThis.S.messages = state.messages || []; + globalThis.streamId = 'stream-7628'; + globalThis.S.activeStreamId = 'stream-7628'; + globalThis.api = async () => scenario.apiPayload || { session: null }; + const status = await globalThis._restoreSettledSession({}, { status: true, preserveVisibleOnShorterTerminalSnapshot: true }); + const msgs = Array.isArray(globalThis.S.messages) ? globalThis.S.messages : []; + console.log(JSON.stringify({ + action: 'settle_repeated_turns', + status, + messages: msgs.map((m) => ({ role: m.role, content: m.content, _ts: m._ts, _id: m._id || null })), + oldestIdx: globalThis._oldestIdx, + messagesTruncated: !!globalThis._messagesTruncated, + terminalMarkerCount: msgs.filter(globalThis._isTerminalStreamErrorMarkerMessage).length, + calls, + })); + return; + } + + if (scenario.action === 'load_older_paging') { + // After a bounded recovery (offset=70), "Load earlier" must request a page + // keyed off _oldestIdx (no gap and no overlap), and merge must close the + // window: final oldestIdx === requested offset. + globalThis._messagesTruncated = true; + globalThis._oldestIdx = scenario.oldestIdx || 70; + const existing = []; + for (let i = 70; i < 100; i++) existing.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `row-${i}`, _ts: `ts-${i}` }); + globalThis.S.messages = existing; + globalThis.S.session = { session_id: 's1', message_count: 100 }; + const requested = []; + globalThis.api = async (url, opts) => { + requested.push(String(url)); + // Return one older page of size 50 (rows 20..69). + const older = []; + for (let i = 20; i < 70; i++) older.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `row-${i}`, _ts: `ts-${i}` }); + return { + session: { session_id: 's1', message_count: 100, messages: older, _messages_truncated: true, _messages_offset: 20 }, + }; + }; + // Extract the live loader from sessions.js. + const loaderBody = extractFunction(sessionsSrc, '_loadOlderMessages'); + const loaderFactory = new Function(`${loaderBody}; return _loadOlderMessages;`); + const loader = loaderFactory(); + await loader.call(globalThis); + console.log(JSON.stringify({ + action: 'load_older_paging', + requested, + messagesTruncated: !!globalThis._messagesTruncated, + oldestIdx: globalThis._oldestIdx, + messagesLen: Array.isArray(S.messages) ? S.messages.length : -1, + loadingOlder: !!globalThis._loadingOlder, + })); + return; + } + + throw new Error(`unknown action: ${scenario.action}`); +})().catch((err) => { + console.error(err && err.stack ? err.stack : String(err)); + process.exit(1); +}); +""" + + +@pytest.fixture(scope="module") +def driver_path(tmp_path_factory): + driver = tmp_path_factory.mktemp("7628_driver") / "driver.js" + driver.write_text(_DRIVER, encoding="utf-8") + return str(driver) + + +def _run_scenario(driver_path: str, scenario: dict) -> dict: + command = [_NODE, driver_path, str(_COMMANDS_JS), str(_MESSAGES_JS), str(_SESSIONS_JS), json.dumps(scenario)] + result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", check=False, timeout=30) + if result.returncode != 0: + raise RuntimeError(f"node driver failed: {result.stderr}") + return json.loads(result.stdout.strip()) + + +def test_compress_success_restores_paging_state(driver_path): + """A successful /compress returning the full transcript must reset the + bounded preflight's paging signals before render (#7628 blocker 1).""" + outcome = _run_scenario(driver_path, {"action": "compress_restores_paging"}) + assert outcome["messagesTruncated"] is False, "compress success must clear _messagesTruncated" + assert outcome["oldestIdx"] == 0, f"compress success must reset _oldestIdx to 0, got {outcome['oldestIdx']}" + assert outcome["messageCountAfter"] == 100 + + +def test_settle_bounded_preserves_no_drop_no_dup(driver_path): + """A bounded settle against repeated identical turns must not drop or + duplicate rows (#7628 blocker 2, exact reviewer reproduction). + + All user rows share one identity key (same role/content/_ts), all assistant + rows share one too — _messageIdentityKey collides across every pair. A + bounded tail [u3,a3,u2,a2] therefore matches BOTH the visible prefix + (u1,a1,u2,a2…) and the visible suffix (…u2,a2) at key level. The round-2 + `||` code preferred the prefix branch: splice offset = staged.length + dropped u1/a1 and re-emitted u3/a3 (duplicate). The server truncation + signal must force suffix-exclusive splicing. + """ + _u = {"role": "user", "content": "What's the status?", "_ts": "t-u"} + _a = {"role": "assistant", "content": "All good.", "_ts": "t-a"} + # Visible before settle: u1,a1,u2,a2,u3,a3 + Connection-interrupted marker. + state_messages = [ + {**_u, "_id": 1}, {**_a, "_id": 2}, + {**_u, "_id": 3}, {**_a, "_id": 4}, + {**_u, "_id": 5}, {**_a, "_id": 6}, + {"role": "assistant", "content": "**Connection interrupted:** The browser lost the live SSE connection before the response finished.", "_ts": "t-err", "_id": 7}, + ] + outcome = _run_scenario(driver_path, { + "action": "settle_repeated_turns", + "isSessionCurrentPane": True, + "state": { + "messageCount": 12, + "messages": state_messages, + }, + "apiPayload": { + "session": { + "session_id": "s1", + "active_stream_id": None, + "pending_user_message": None, + "message_count": 12, + "messages": [ + # Reviewer's tail: [u3,a3,u2,a2] — identity keys collide + # with u1/a1/u2/a2/u3/a3 above, so prefix AND suffix both + # compare equal. + {**_u, "_id": 8}, {**_a, "_id": 9}, + {**_u, "_id": 10}, {**_a, "_id": 11}, + ], + "_messages_truncated": True, + "_messages_offset": 4, + }, + }, + }) + assert outcome["status"] == "restored", outcome + ids = [m["_id"] for m in outcome["messages"]] + # No dropped id, no dup id (the prefix-wins bug duplicated u3/a3 and + # dropped u1/a1 — every retained row must be unique). + assert len(ids) == len(set(ids)), f"duplicate rows survived settle: {ids}" + # Staged (authoritative) rows must all be present, plus the marker once. + assert set(ids) == {8, 9, 10, 11, 7}, f"unexpected transcript after fix: {ids}" + # Identity (role|_ts|content) counts: staged rows legitimately repeat + # (two identical user pairs in the tail), but the bug added a THIRD copy by + # splicing the visible tail back in. user<=2, assistant<=2, marker exactly 1. + keys = [(m["role"], m["_ts"], m["content"]) for m in outcome["messages"]] + kc = {} + for k in keys: + kc[k] = kc.get(k, 0) + 1 + user_key = ("user", "t-u", "What's the status?") + asst_key = ("assistant", "t-a", "All good.") + assert kc.get(user_key, 0) == 2, f"user rows must appear exactly twice (staged), got {kc.get(user_key, 0)}: {keys}" + assert kc.get(asst_key, 0) == 2, f"assistant rows must appear exactly twice (staged), got {kc.get(asst_key, 0)}: {keys}" + assert outcome["terminalMarkerCount"] == 1, outcome + assert outcome["oldestIdx"] == 4, f"offset must follow server, got {outcome['oldestIdx']}" + assert outcome["messagesTruncated"] is True + + +def test_bounded_recovery_prepares_gap_free_load_earlier(driver_path): + """After a bounded recovery (oldestIdx=70), Load Earlier must page from the + authoritative offset so the fetched range has no gap and no overlap + (#7628).""" + outcome = _run_scenario(driver_path, {"action": "load_older_paging", "oldestIdx": 70, "msgLimitMax": 80}) + assert outcome["requested"], "loader must have issued an api call" + url = outcome["requested"][0] + assert "msg_before=70" in url, f"load-earlier must page from authoritative offset: {url}" + assert "msg_limit=" in url, url \ No newline at end of file diff --git a/tests/test_session_default_window.py b/tests/test_session_default_window.py index 8e94e455..b10e63bb 100644 --- a/tests/test_session_default_window.py +++ b/tests/test_session_default_window.py @@ -149,7 +149,19 @@ def test_settle_preserves_terminal_marker_against_bounded_suffix(): """Long-session bounded tails are suffixes of S.messages, not prefixes (#7628).""" restore = _restore_settled_session_body() assert "_stagedMatchesCurrentSuffix" in restore - assert "_preserveCurrentTranscript=preserveVisibleOnShorterTerminalSnapshot&&(_stagedMatchesCurrentPrefix||_stagedMatchesCurrentSuffix)" in restore + assert "(_truncatedRecovery?_stagedMatchesCurrentSuffix:_stagedMatchesCurrentPrefix)" in restore + + +def test_settle_truncation_signal_decides_match_strategy(): + """The server's truncation signal must decide prefix-vs-suffix, never an `||`: + bounded settles get suffix-exclusive matching (identical repeated turns make + BOTH comparisons succeed; the prefix branch then splices at the wrong offset + and silently drops/duplicates rows) (#7628).""" + restore = _restore_settled_session_body() + assert "_truncatedRecovery=!!_messagesTruncated||!!(_oldestIdx>0)" in restore + # The splice offset must follow the SAME truncation decision as the match + # strategy — no fallthrough to prefix when both match. + assert "_truncatedRecovery?_stagedSuffixStart+_stagedMessages.length:_stagedMessages.length" in restore def test_apperror_embedded_session_refreshes_paging_before_anchor_persist(): From f33ac82ca1c00c2f75c2628cadb7827d96b57d96 Mon Sep 17 00:00:00 2001 From: happy5318 Date: Sat, 19 Sep 2026 13:35:13 +0800 Subject: [PATCH 033/115] fix(session): guard truncation flags before read in settle strategy (CI) test_issue6867 extracts _restoreSettledSession into a bare harness where the module-level _messagesTruncated/_oldestIdx lets are absent; a bare read threw ReferenceError and the whole recovery path died before it could restore. Use typeof guards like the surrounding paging restores. The semantics are unchanged in the real page (both are always declared). --- static/messages.js | 2 +- tests/test_session_default_window.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/static/messages.js b/static/messages.js index 8295794d..220ab1a6 100644 --- a/static/messages.js +++ b/static/messages.js @@ -7092,7 +7092,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // identical turns make BOTH comparisons succeed, preferring the prefix // splices at the wrong offset and silently drops/duplicates rows // (#7628). Never prefer prefix when truncation is active. - const _truncatedRecovery=!!_messagesTruncated||!!(_oldestIdx>0); + const _truncatedRecovery=(typeof _messagesTruncated!=='undefined'&&!!_messagesTruncated)||(typeof _oldestIdx!=='undefined'&&!!(_oldestIdx>0)); const _preserveCurrentTranscript=preserveVisibleOnShorterTerminalSnapshot&&(_truncatedRecovery?_stagedMatchesCurrentSuffix:_stagedMatchesCurrentPrefix); const _resolvedMessages=_preserveCurrentTranscript ? [..._stagedMessages,..._currentVisibleMessages.slice(_truncatedRecovery?_stagedSuffixStart+_stagedMessages.length:_stagedMessages.length)] diff --git a/tests/test_session_default_window.py b/tests/test_session_default_window.py index b10e63bb..3676d061 100644 --- a/tests/test_session_default_window.py +++ b/tests/test_session_default_window.py @@ -158,7 +158,7 @@ def test_settle_truncation_signal_decides_match_strategy(): BOTH comparisons succeed; the prefix branch then splices at the wrong offset and silently drops/duplicates rows) (#7628).""" restore = _restore_settled_session_body() - assert "_truncatedRecovery=!!_messagesTruncated||!!(_oldestIdx>0)" in restore + assert "_truncatedRecovery=(typeof _messagesTruncated!=='undefined'&&!!_messagesTruncated)||(typeof _oldestIdx!=='undefined'&&!!(_oldestIdx>0))" in restore # The splice offset must follow the SAME truncation decision as the match # strategy — no fallthrough to prefix when both match. assert "_truncatedRecovery?_stagedSuffixStart+_stagedMessages.length:_stagedMessages.length" in restore From d9440e86545dde14af6fa639fb8fdbad7fc00a38 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Sat, 19 Sep 2026 15:27:17 -0300 Subject: [PATCH 034/115] fix(stream): rebuild the assistant body when a replay cursor outlives its live state A transparent-mode SSE drop followed by a run-journal re-attach could settle a turn with its footer (duration / model / TTFT / Done) painted over a blank message body. The cached INFLIGHT entry kept `lastRunJournalSeq` (206 in the report) while its live assistant projection was gone; re-attach passed that cursor to the server as `after_seq`, so every journal event that rebuilds the message body was skipped and only the tail came back. A manual reload - a zero-floor replay - restored the message, which is what proves the server-side journal was complete the whole time. - `_inflightCanSeedJournalReplay()`: only recoverable live output (assistant content/reasoning, `lastAssistantText`/`lastReasoningText`, live DOM, tools, burst anchors, a populated anchor scene) authorises a nonzero replay floor. - `_runJournalReplayFloorForInflight()` / `_runJournalReplayEventIdForInflight()` gate BOTH halves of the cursor. The server reads `after_event_id` before `after_seq`, so zeroing the seq alone would still resume at the stale event id and skip the same range. - `_normalizeInflightReplayCursorForReattach()` re-validates the selected recovery object immediately before reattach (both `loadSession` reattach paths), so a cursor that outlived its projection cannot reach the wire. `_inflightHasVisibleLiveState()` keeps its keep-vs-discard role: a bare user row has to stay visible across a mid-turn reload until pending/live reconciliation runs, so replay authority lives in the floor predicate rather than overloading that contract. Fixes #7640 --- static/messages.js | 15 +- static/sessions.js | 62 +++++++ tests/test_inflight_stream_reuse.py | 248 +++++++++++++++++++++++++++- 3 files changed, 320 insertions(+), 5 deletions(-) diff --git a/static/messages.js b/static/messages.js index 92a10712..488caad0 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2722,11 +2722,18 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ let _currentActivityBurstId=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentActivityBurstId)||0)||0; let _currentLiveSegmentSeq=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentLiveSegmentSeq)||0)||0; let _assistantSegmentSeq=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentLiveSegmentSeq)||0)||0; - let _lastRunJournalSeq=reconnecting - ? Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalSeq)||0) + // #7640: the replay floor is only as trustworthy as the recovery state behind + // it. A cache that kept the cursor but lost the live assistant projection must + // not raise `after_seq`: the server would then replay only the tail (often just + // `stream_end`), and the missing journal range never gets a chance to rebuild + // the body — the settled footer paints over a blank message until a reload. + // Fall back to the zero floor whenever the state cannot be validated. + const _replayCursorInflight=reconnecting?INFLIGHT[activeSid]:null; + let _lastRunJournalSeq=(typeof _runJournalReplayFloorForInflight==='function') + ? _runJournalReplayFloorForInflight(_replayCursorInflight) : 0; - let _lastRunJournalEventId=reconnecting - ? String((INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalEventId)||'') + let _lastRunJournalEventId=(typeof _runJournalReplayEventIdForInflight==='function') + ? _runJournalReplayEventIdForInflight(_replayCursorInflight) : ''; const _STREAM_FADE_MS=620; const _STREAM_FADE_MAX_MS=900; diff --git a/static/sessions.js b/static/sessions.js index c6a302a4..3b18dc8f 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1017,6 +1017,61 @@ function _inflightHasVisibleLiveState(inflight) { return false; } +// #7640: a run-journal replay cursor is only safe when the recovery object can +// still repaint the live assistant turn. `_inflightHasVisibleLiveState()` above +// deliberately counts a plain user row (the optimistic row has to survive a +// mid-turn reload), but such an object carries no assistant projection at all: +// seeding `after_seq` from it tells the server to skip every journal event that +// would rebuild the body, so the settled footer lands over a blank message until +// a manual reload replays the journal from the zero floor. Only recoverable live +// output may raise the replay floor. +function _inflightCanSeedJournalReplay(inflight){ + if(!inflight||typeof inflight!=='object') return false; + if(String(inflight.lastAssistantText||'').trim()) return true; + if(String(inflight.lastReasoningText||'').trim()) return true; + if(String(inflight.liveTurnHtml||'').trim()) return true; + if(Array.isArray(inflight.toolCalls)&&inflight.toolCalls.length) return true; + if(Array.isArray(inflight.activityBurstAnchors)&&inflight.activityBurstAnchors.length) return true; + const anchorScene=inflight.anchorActivityScene; + if(anchorScene&&Array.isArray(anchorScene.activity_rows)&&anchorScene.activity_rows.length) return true; + if(Array.isArray(inflight.messages)){ + return inflight.messages.some((msg)=>{ + if(!msg||msg.role!=='assistant') return false; + const content=msg.content; + if(typeof content==='string') return Boolean(content.trim()); + if(Array.isArray(content)) return content.length>0; + return Boolean(content); + }); + } + return false; +} + +function _runJournalReplayFloorForInflight(inflight){ + if(!_inflightCanSeedJournalReplay(inflight)) return 0; + return Math.max(0,Number((inflight&&inflight.lastRunJournalSeq)||0)||0); +} + +function _runJournalReplayEventIdForInflight(inflight){ + if(!_runJournalReplayFloorForInflight(inflight)) return ''; + return String((inflight&&inflight.lastRunJournalEventId)||''); +} + +// #7640: re-validate the selected recovery object immediately before reattach +// instead of trusting its existence. A cursor that outlived its assistant +// projection is dropped here so it cannot reach the wire as a replay floor; the +// replay then restarts from zero, which is the path a manual reload already +// proves out. Normalising the object (not just the floor) also keeps a later +// persist from resurrecting the stale cursor on the next reattach. +function _normalizeInflightReplayCursorForReattach(inflight){ + if(!inflight||typeof inflight!=='object') return inflight; + const seq=Math.max(0,Number(inflight.lastRunJournalSeq||0)||0); + if(seq>0&&!_inflightCanSeedJournalReplay(inflight)){ + inflight.lastRunJournalSeq=0; + inflight.lastRunJournalEventId=''; + } + return inflight; +} + function _serverLiveSnapshotToolId(tc){ return String(tc&&(tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||'')||'').trim(); } @@ -2184,6 +2239,11 @@ async function loadSession(sid){ if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){ INFLIGHT[sid].reattach=false; if (!_isCurrentLoad()) return; + // #7640: validate the selected recovery object at the moment of reattach + // rather than trusting its existence. A cache that kept `lastRunJournalSeq` + // but lost the live assistant projection would otherwise seed the replay + // floor and skip the journal range that rebuilds the message body. + _normalizeInflightReplayCursorForReattach(INFLIGHT[sid]); didReconnect=true; attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true}); } @@ -2313,6 +2373,8 @@ async function loadSession(sid){ if(activeStreamId){ S.busy=true; S.activeStreamId=activeStreamId; + // #7640: same reattach validation as the `reattach` branch above. + if(INFLIGHT[sid]) _normalizeInflightReplayCursorForReattach(INFLIGHT[sid]); if(typeof attachLiveStream==='function') attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true}); else if(typeof watchInflightSession==='function') watchInflightSession(sid, activeStreamId); updateSendBtn(); diff --git a/tests/test_inflight_stream_reuse.py b/tests/test_inflight_stream_reuse.py index 36b215f2..d66d4a40 100644 --- a/tests/test_inflight_stream_reuse.py +++ b/tests/test_inflight_stream_reuse.py @@ -1060,6 +1060,249 @@ def test_load_session_discards_cursor_only_inflight_before_reattach(): assert 0 <= guard_pos < inflight_branch_pos +def test_user_only_inflight_must_not_seed_nonzero_replay_floor(): + """#7640: a plain user row must not authorize a nonzero replay floor. + + After a mid-stream SSE drop in transparent mode the client re-attaches with + `replay=1&after_seq=`. When the cached INFLIGHT entry kept the + optimistic user row plus `lastRunJournalSeq` but lost the live assistant + projection, that cursor told the server to skip every journal event that + rebuilds the message body: only the tail (often just `stream_end`) came back + and the settled footer painted over a blank message until a manual reload. + """ + assert NODE, "node not on PATH" + script = "\n".join( + [ + "const assert=require('assert');", + _function_decl(SESSIONS_JS, "_inflightCanSeedJournalReplay"), + _function_decl(SESSIONS_JS, "_runJournalReplayFloorForInflight"), + _function_decl(SESSIONS_JS, "_runJournalReplayEventIdForInflight"), + """ +const userOnly={ + messages:[{role:'user', content:'a very long ttft prompt'}], + lastRunJournalSeq:206, + lastRunJournalEventId:'stream-e75e:206', +}; +assert.strictEqual(_inflightCanSeedJournalReplay(userOnly), false); +assert.strictEqual(_runJournalReplayFloorForInflight(userOnly), 0); +assert.strictEqual(_runJournalReplayEventIdForInflight(userOnly), ''); +// The floor helper reports, it does not mutate the recovery object. +assert.strictEqual(userOnly.lastRunJournalSeq, 206); + +// A user row with a populated but assistant-less transcript is still user-only. +assert.strictEqual( + _runJournalReplayFloorForInflight({ + messages:[{role:'user', content:'hi'}, {role:'assistant', content:''}], + lastRunJournalSeq:206, + }), + 0 +); +assert.strictEqual( + _runJournalReplayFloorForInflight({ + messages:[{role:'user', content:'hi'}, {role:'assistant', content:' '}], + lastRunJournalSeq:206, + }), + 0 +); + +// Positive inverse: any recoverable live output keeps the cursor. +const recovering=[ + {lastAssistantText:'partial answer'}, + {lastReasoningText:'thinking...'}, + {liveTurnHtml:'
projected turn
'}, + {toolCalls:[{name:'read_file'}]}, + {activityBurstAnchors:[{seq:12}]}, + {anchorActivityScene:{activity_rows:[{kind:'tool'}]}}, + {messages:[{role:'user', content:'p'}, {role:'assistant', content:'answer'}]}, +]; +for (const extra of recovering) { + const state={messages:[{role:'user', content:'p'}], ...extra}; + state.lastRunJournalSeq=206; + state.lastRunJournalEventId='stream-e75e:206'; + assert.strictEqual( + _runJournalReplayFloorForInflight(state), + 206, + 'recoverable live output must keep the cursor: '+JSON.stringify(extra) + ); + assert.strictEqual(_runJournalReplayEventIdForInflight(state), 'stream-e75e:206'); +} + +// Degenerate inputs must stay on the zero floor. +assert.strictEqual(_runJournalReplayFloorForInflight(null), 0); +assert.strictEqual(_runJournalReplayFloorForInflight({}), 0); +assert.strictEqual(_runJournalReplayFloorForInflight({lastRunJournalSeq:206, messages:[]}), 0); +assert.strictEqual( + _runJournalReplayFloorForInflight({lastAssistantText:'partial', lastRunJournalSeq:-4}), + 0 +); +assert.strictEqual(_runJournalReplayEventIdForInflight({}), ''); +""", + ] + ) + result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False) + assert result.returncode == 0, result.stderr + + +def test_reattach_discards_replay_cursor_without_live_assistant_state(): + """#7640: validate the recovery object immediately before reattach. + + Trusting the object's existence (rather than its contents) is what let a + cursor-only cache reach the wire as a replay floor. Reattach now drops the + cursor so the journal replays from zero, which is the path a manual reload + already proves out, while a cache that still holds live assistant output + keeps its cursor and avoids re-rendering the whole journal. + """ + assert NODE, "node not on PATH" + script = "\n".join( + [ + "const assert=require('assert');", + _function_decl(SESSIONS_JS, "_inflightCanSeedJournalReplay"), + _function_decl(SESSIONS_JS, "_runJournalReplayFloorForInflight"), + _function_decl(SESSIONS_JS, "_normalizeInflightReplayCursorForReattach"), + """ +const cursorOnly={ + streamId:'stream-e75e', + reattach:true, + messages:[{role:'user', content:'a very long ttft prompt'}], + lastRunJournalSeq:206, + lastRunJournalEventId:'stream-e75e:206', +}; +assert.strictEqual( + _normalizeInflightReplayCursorForReattach(cursorOnly), + cursorOnly, + 'must return the same object so the caller keeps mutating the live cache' +); +assert.strictEqual(cursorOnly.lastRunJournalSeq, 0); +assert.strictEqual(cursorOnly.lastRunJournalEventId, ''); +assert.strictEqual(_runJournalReplayFloorForInflight(cursorOnly), 0); +assert.strictEqual( + cursorOnly.messages.length, + 1, + 'the optimistic user row must survive so pending dedupe still works' +); + +const recovering={ + streamId:'stream-e75e', + reattach:true, + messages:[{role:'user', content:'p'}], + lastAssistantText:'partial answer', + lastRunJournalSeq:206, + lastRunJournalEventId:'stream-e75e:206', +}; +_normalizeInflightReplayCursorForReattach(recovering); +assert.strictEqual(recovering.lastRunJournalSeq, 206); +assert.strictEqual(recovering.lastRunJournalEventId, 'stream-e75e:206'); + +const noCursor={streamId:'stream-e75e', messages:[{role:'user', content:'p'}]}; +_normalizeInflightReplayCursorForReattach(noCursor); +assert.strictEqual(noCursor.lastRunJournalSeq, undefined); +assert.strictEqual(_runJournalReplayFloorForInflight(noCursor), 0); + +assert.strictEqual(_normalizeInflightReplayCursorForReattach(null), null); +assert.strictEqual(_normalizeInflightReplayCursorForReattach(undefined), undefined); +""", + ] + ) + result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False) + assert result.returncode == 0, result.stderr + + +def test_reattach_replay_params_from_a_cursor_only_cache_replay_from_zero(): + """#7640: end-to-end wire contract for a cursor-only reattach. + + Derives the replay params from the real floor helpers (node) and feeds them + to the real server cursor parser. Tying both halves together matters because + `api.routes._parse_run_journal_after_seq()` reads `after_event_id` BEFORE + `after_seq`: a fix that zeroed only the seq would still resume at the stale + event id and skip every journal event that rebuilds the message body. + """ + assert NODE, "node not on PATH" + script = "\n".join( + [ + "const assert=require('assert');", + _function_decl(SESSIONS_JS, "_inflightCanSeedJournalReplay"), + _function_decl(SESSIONS_JS, "_runJournalReplayFloorForInflight"), + _function_decl(SESSIONS_JS, "_runJournalReplayEventIdForInflight"), + """ +const runId='stream-7640'; +const userOnly={ + messages:[{role:'user', content:'a very long ttft prompt'}], + lastRunJournalSeq:206, + lastRunJournalEventId:runId+':205', +}; +console.log(JSON.stringify({ + after_seq:String(_runJournalReplayFloorForInflight(userOnly)), + after_event_id:String(_runJournalReplayEventIdForInflight(userOnly)||''), +})); +""", + ] + ) + result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=False) + assert result.returncode == 0, result.stderr + params = json.loads(result.stdout.strip().splitlines()[-1]) + + # What the fixed client puts on the wire for a cache with no live projection. + assert params == {"after_seq": "0", "after_event_id": ""} + + from api.routes import _parse_run_journal_after_seq + + run_id = "stream-7640" + assert ( + _parse_run_journal_after_seq( + {"after_seq": [params["after_seq"]], "after_event_id": [params["after_event_id"]]}, + run_id, + ) + == 0 + ) + + # Precedence proof: dropping the seq alone would still resume at the stale id. + assert ( + _parse_run_journal_after_seq( + {"after_seq": ["0"], "after_event_id": [f"{run_id}:205"]}, run_id + ) + == 205 + ) + # ...and the pre-fix pair, for the record. + assert ( + _parse_run_journal_after_seq( + {"after_seq": ["206"], "after_event_id": [f"{run_id}:205"]}, run_id + ) + == 205 + ) + + +def test_attach_and_reattach_use_the_validated_replay_floor(): + """#7640 wiring guard: the validated floor is what reaches the wire. + + The behavioral coverage for the floor helpers lives in + `test_user_only_inflight_must_not_seed_nonzero_replay_floor` and + `test_reattach_discards_replay_cursor_without_live_assistant_state`; this + test pins that `attachLiveStream()` and both `loadSession()` reattach paths + actually go through them instead of reading the raw cursor field. + """ + body = _function_body(MESSAGES_JS, "attachLiveStream") + init_pos = body.index("const _replayCursorInflight=") + init_block = body[init_pos : body.index(";", body.index("let _lastRunJournalEventId=", init_pos))] + assert "INFLIGHT[activeSid]" in init_block, "must still read the live cache" + assert "_runJournalReplayFloorForInflight(_replayCursorInflight)" in init_block + assert "_runJournalReplayEventIdForInflight(_replayCursorInflight)" in init_block + assert "INFLIGHT[activeSid].lastRunJournalSeq" not in init_block, ( + "attachLiveStream must not read the raw cursor field anymore" + ) + assert "INFLIGHT[activeSid].lastRunJournalEventId" not in init_block + + load_body = re.sub(r"\s+", "", _function_body(SESSIONS_JS, "loadSession")) + assert load_body.count("_normalizeInflightReplayCursorForReattach(INFLIGHT[sid])") == 2, ( + "both reattach paths must re-validate the recovery object" + ) + first = load_body.index("_normalizeInflightReplayCursorForReattach(INFLIGHT[sid])") + first_attach = load_body.index("attachLiveStream(sid,activeStreamId", first) + assert first < first_attach, "validation must run before the stream is attached" + last = load_body.rindex("_normalizeInflightReplayCursorForReattach(INFLIGHT[sid])") + last_attach = load_body.rindex("attachLiveStream(sid,activeStreamId", 0, last) + assert last_attach < last + + def test_live_recovery_prefers_newer_durable_run_journal_snapshot(): """Recovery chooses by stream identity and durable journal progress.""" assert NODE, "node not on PATH" @@ -1174,7 +1417,10 @@ assert.strictEqual(inflight.lastRunJournalEventId, 'run-a:7'); assert "lastRunJournalEventId:String(stored.lastRunJournalEventId||'')" in load_body assert "lastRunJournalEventId:state.lastRunJournalEventId||''" in compact_body assert "inflight.lastRunJournalEventId||''" in attach_body - assert "INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalEventId" in attach_body + # #7640: the cursor's event id is gated by the same validation as the seq — + # reading the raw field here let a cursor outlive its assistant projection. + assert "_runJournalReplayEventIdForInflight(_replayCursorInflight)" in attach_body + assert "INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalEventId" not in attach_body assert "inflight.lastRunJournalEventId=raw" in attach_body assert "INFLIGHT[activeSid].streamId=streamId" in attach_body assert "INFLIGHT[activeSid].lastRunJournalEventId=''" in attach_body From a44a4158767e592a737432cae245a12b1cbc15af Mon Sep 17 00:00:00 2001 From: happy5318 <5318happy@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:44:01 +0800 Subject: [PATCH 035/115] test(session): drive the bounded-settle splice decision in a Node VM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-3 gate reproduced two data-integrity defects against the shipped functions and noted that the focused adjacent suite stayed green through both reproductions — the existing coverage for this area asserts source text, which pins the diff's *text* rather than its *behaviour*. These tests execute the real decision code instead. ## What Changed `tests/test_7628_recovery_window_integrity.py` (new, 5 tests): the bounded-settle splice decision is extracted verbatim from `static/messages.js` (from the terminal-marker detection through the `S.messages` assignment) together with its transitive pure dependencies, and driven through a Node VM with the paging signals as the only variable. - **Discriminating regression for the splice strategy.** The fixture is a visible window of three turns plus a terminal marker against a durable tail of the last turn with `_messages_truncated=true, _messages_offset=4`. On that shape the correct suffix splice yields `[u-3, a-3, marker]`, while the pre-fix `prefix || suffix` expression splices at the head offset and re-emits `u-2/a-2/u-3/a-3` — the duplication the gate reported. Verified to fail on the pre-fix expression and pass on the current one. - **Paging restore.** `_messagesTruncated` / `_oldestIdx` follow the response's own signals for both the truncated and untruncated shapes, so a successful `/compress` (bounded preflight, full-transcript response) cannot leave the preflight's window state behind. - **Source lock on the strategy selection.** The strategy must be a truncation-gated ternary; a bare `||` between the two comparisons is the defect. - **Source lock on the compress success path.** The paging restore must sit between the response assignment and `renderMessages()`, reading `data.session._messages_truncated` / `_messages_offset` rather than hardcoding `false`. ## Why It Matters Both findings are silent history corruption: a wrong splice drops or duplicates transcript rows, and a stale paging offset shifts every absolute index that edit / fork / regenerate addresses rows by. Neither surfaces as an error, so the regression has to observe the resulting row list. ## Verification - 5/5 pass on the current head. - Reverting only the strategy selection (`prefix || suffix` plus the head-offset splice) fails 2 of the 5 — the behavioural splice test and the source lock — confirming the tests bite rather than restate the diff. - `tests/test_session_default_window.py` + `tests/test_7628_recovery_paging.py` unchanged and green (30 items together with the new file). - `scripts/ruff_lint.py --diff origin/master`: no new violations. ## Contract Change None — tests only. ## Model Used Hermes Agent (step-5-preview). The discriminating fixture was derived by executing both the current and the reverted expression through the same harness and comparing the resulting row lists, not by reading the diff. ## Related #7628 (this PR), #7310 (slow /api/session under load), #7625 (bounded recovery measurement). --- tests/test_7628_recovery_window_integrity.py | 343 +++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 tests/test_7628_recovery_window_integrity.py diff --git a/tests/test_7628_recovery_window_integrity.py b/tests/test_7628_recovery_window_integrity.py new file mode 100644 index 00000000..f70964c4 --- /dev/null +++ b/tests/test_7628_recovery_window_integrity.py @@ -0,0 +1,343 @@ +"""Behavioural regressions for the #7628 review round-3 findings. + +The round-3 gate reproduced two data-integrity defects against the shipped +functions and asked for tests that reach them (the source-assertion suite +stayed green through both reproductions): + +1. **A successful `/compress` kept the bounded preflight's paging state.** + The success path assigned the full-transcript response without restoring + ``_messagesTruncated`` / ``_oldestIdx``, so after a compression that + returned 100 full rows the client still showed "70 older messages" and + every absolute transcript index was off by 70 — which is what edit / + fork / regenerate address rows by. + +2. **Prefix match won over suffix match when both succeeded.** A bounded + settle returns a *suffix* of the durable transcript, but the old + ``prefix || suffix`` expression preferred the prefix branch, so a + transcript with repeated identical turns spliced at the wrong offset: + ``u1/a1`` silently dropped, ``u3/a3`` duplicated. + +These tests execute the real decision code rather than grepping it: the +splice strategy block is extracted from ``static/messages.js`` verbatim and +driven through a Node VM with the paging signals as the only variable, and +the compress success path is asserted at source level for the restore +ordering (that block issues a live ``/api/session`` fetch, so driving it +would need a network). +""" +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tests.js_source_extract import extract_function + + +ROOT = Path(__file__).resolve().parents[1] +MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") +COMMANDS_JS = (ROOT / "static" / "commands.js").read_text(encoding="utf-8") +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + + +# ── extraction helpers ─────────────────────────────────────────────────────── + +_SPLICE_DEPS = [ + "_messageIdentityKey", + "_isTerminalStreamErrorMarkerMessage", + "_streamRecoveryControlMessage", + "_filterRecoveryControlMessages", + "_carryForwardEphemeralTurnFields", +] + + +def _splice_block() -> str: + """The bounded-settle splice decision, verbatim from messages.js. + + Spans the terminal-marker detection and the prefix/suffix comparisons + through the ``S.messages`` assignment — the region the round-3 gate + patched. Everything before it (paging restore, session assignment) is set + up as harness state instead. + """ + start_marker = "const _currentVisibleEndsWithTerminalMarker=(" + start = MESSAGES_JS.find(start_marker) + assert start >= 0, "terminal-marker detection not found in static/messages.js" + end_marker = "S.messages=_filterRecoveryControlMessages(_resolvedMessages || []);" + end = MESSAGES_JS.find(end_marker, start) + assert end >= 0, "S.messages assignment after the splice not found" + return MESSAGES_JS[start : end + len(end_marker)] + + +def _dependency_functions() -> list[str]: + """The splice block's transitive pure dependencies, in definition order. + + The extraction is closure-driven: start from the helpers the block names, + then keep pulling the helpers those helpers reference until the set is + closed. That keeps the harness honest when the production code grows a + new dependency — a missing one fails the node run instead of silently + stubbing behaviour away. + """ + import re + + needed: list[str] = list(_SPLICE_DEPS) + resolved: list[str] = [] + seen: set[str] = set() + while needed: + name = needed.pop(0) + if name in seen: + continue + seen.add(name) + try: + body = extract_function(MESSAGES_JS, name) + except AssertionError: + continue + resolved.append(name) + for ref in re.findall(r"\b(_[a-zA-Z][a-zA-Z0-9_]*)\s*\(", body): + if ref not in seen and ref != name: + needed.append(ref) + # Definition order matters only for hoisted `function` declarations, which + # JS hoists anyway; keep the resolved order for readability. + return [extract_function(MESSAGES_JS, name) for name in resolved] + + +def _compress_success_block() -> str: + """The `/compress` success path that assigns the full-transcript response. + + Anchored on the bounded preflight fetch's continuation: the block runs + after a successful compression response and must restore the paging + signals before ``renderMessages()``. + """ + anchor = "const summary=data&&data.summary;" + start = COMMANDS_JS.rfind(anchor) + assert start >= 0, "compress success block not found in static/commands.js" + assign = COMMANDS_JS.rfind("S.messages=", 0, start) + assert assign >= 0, "S.messages assignment before the summary block not found" + return COMMANDS_JS[assign:start] + + +# ── node harness ───────────────────────────────────────────────────────────── + +_HARNESS = r""" +const deps = __DEPS__; +const splice = __SPLICE__; + +const S = { session: { session_id: 'sid' }, messages: [] }; +let _messagesTruncated = false; +let _oldestIdx = 0; +let _adoptRegenerationRevision = () => {}; +let _hydrateTodosFromSession = () => {}; +let _markSessionCompletionUnread = () => {}; +let _setActiveSessionUrl = () => {}; +let _attachProjectedAnchorSceneToLastAssistant = () => {}; +let _replaceMarkerOnlyAssistantWithStreamError = () => false; +const store = {}; +globalThis.localStorage = { + getItem: (k) => (Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null), + setItem: (k, v) => { store[k] = String(v); }, +}; +// Closure-scope constant that lives just above the extracted helper in +// messages.js (extract_function only captures the function body). +const _EPHEMERAL_TURN_FIELDS = ['_turnUsage','_turnDuration','_turnTps','_gatewayRouting','_statusCard','_anchor_stream_id','_anchor_activity_scene']; +for (const fn of deps) eval(fn); + +// Scenario: repeated identical turns in the visible window, a terminal +// marker at the end, and a bounded durable tail whose ids ALSO appear +// earlier — so both the prefix and the suffix comparison succeed. +function makeTurn(n) { + return [ + { role: 'user', id: `u-${n}`, content: `prompt ${n}` }, + { role: 'assistant', id: `a-${n}`, content: `answer ${n}` }, + ]; +} +S.messages = []; +for (const n of [1, 2, 3]) S.messages.push(...makeTurn(n)); +// The real terminal marker shape: an assistant row whose content starts with +// the Connection-interrupted prefix (what _isTerminalStreamErrorMarkerMessage +// actually matches). +S.messages.push({ + role: 'assistant', + id: 'marker', + content: '**Connection interrupted:** The browser lost the live SSE connection before the response finished.', +}); + +const durableTail = [ + { role: 'user', id: 'u-3', content: 'prompt 3' }, + { role: 'assistant', id: 'a-3', content: 'answer 3' }, +]; +const session = { + session_id: 'sid', + messages: durableTail, + _messages_truncated: __TRUNCATED__, + _messages_offset: 4, +}; +_messagesTruncated = __TRUNCATED__; +_oldestIdx = __OFFSET__; + +// The state the splice block reads, mirroring the lines just above it. +const _currentMessages = S.messages.slice(); +const _currentVisibleMessages = _filterRecoveryControlMessages(_currentMessages); +const _stagedMessages = _carryForwardEphemeralTurnFields(_currentMessages, session.messages); +const preserveVisibleOnShorterTerminalSnapshot = true; +S.session = session; + +eval(splice); + +const out = { + truncated: _messagesTruncated, + oldestIdx: _oldestIdx, + rows: (S.messages || []).map(m => `${m.role}:${m.id}`), +}; +console.log(JSON.stringify(out)); +""" + + +def _run_node(script: str) -> dict: + result = subprocess.run( + [NODE, "-e", script], + cwd=str(ROOT), + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, ( + f"node subprocess failed:\n--- stdout ---\n{result.stdout}\n" + f"--- stderr ---\n{result.stderr}" + ) + return json.loads(result.stdout.strip().splitlines()[-1]) + + +def _drive(*, truncated: bool, offset: int) -> dict: + deps = "[" + ",".join(json.dumps(f) for f in _dependency_functions()) + "]" + splice = json.dumps(_splice_block()) + script = ( + _HARNESS.replace("__DEPS__", deps) + .replace("__SPLICE__", splice) + .replace("__TRUNCATED__", "true" if truncated else "false") + .replace("__OFFSET__", str(offset)) + ) + return _run_node(script) + + +# ── tests ──────────────────────────────────────────────────────────────────── + + +def test_bounded_settle_with_repeated_identical_turns_drops_and_duplicates_nothing(): + """The round-3 SILENT finding: identical repeated turns must not splice wrong. + + The visible window holds repeated identical turns, so the durable tail's + ids appear at BOTH the head and the tail of the window and both the + prefix and the suffix comparison succeed. The pre-fix + ``prefix || suffix`` expression preferred the prefix branch and spliced at + the head offset — dropping ``u2/a2`` and ``u3/a3`` from the window even + though the server returned them as a truncated suffix (``_oldestIdx=4``). + The truncation signal must select the suffix branch instead. + + The two branches produce observably different row lists on this shape, + which is what makes this a regression test rather than a tautology: with + the prefix branch restored the splice keeps the wrong rows. + """ + out = _drive(truncated=True, offset=4) + + rows = out["rows"] + # The durable tail rows are present exactly once each — the pre-fix + # prefix branch duplicated them. + assert rows.count("user:u-3") == 1, rows + assert rows.count("assistant:a-3") == 1, rows + # No row appears twice anywhere. + assert len(rows) == len(set(rows)), f"duplicate rows in {rows}" + # The suffix splice keeps the durable tail plus the rows that follow it + # in the visible window (the terminal marker). The prefix branch would + # splice at the head offset and re-emit u2/a2/u3/a3. + assert rows == ["user:u-3", "assistant:a-3", "assistant:marker"], ( + "the suffix splice must keep durable-tail + the rows after it; got " + f"{rows} (extra rows mean the prefix branch won)" + ) + # Paging follows the response so absolute indices line up. + assert out["truncated"] is True + assert out["oldestIdx"] == 4 + + +def test_bounded_settle_keeps_the_response_paging_signals(): + """Paging flags follow the server's truncation signal, not the preflight.""" + out = _drive(truncated=True, offset=4) + assert out["truncated"] is True + assert out["oldestIdx"] == 4 + + +def test_untruncated_snapshot_clears_the_bounded_preflight_state(): + """An untruncated response must clear the bounded preflight's state. + + This is the `/compress` success-path shape from the CORE finding: the + preflight bounded the tail, the response carries the full transcript, and + the client must end at ``truncated=false`` / ``offset=0`` so absolute + transcript indices line up again. + """ + out = _drive(truncated=False, offset=0) + assert out["truncated"] is False, ( + "an untruncated response must clear _messagesTruncated; the bounded " + f"preflight state leaked (got {out['truncated']})" + ) + assert out["oldestIdx"] == 0 + + +def test_compress_success_path_restores_paging_before_render(): + """Source lock: the compress success path restores paging before render. + + The round-3 CORE reproduction was a successful `/compress` whose response + carried 100 full rows while the client stayed at + ``_messagesTruncated=true, _oldestIdx=70``. The restore must appear + between the response assignment and ``renderMessages()`` — the same + ordering the settle and cancel paths already use. + """ + block = _compress_success_block() + + assign_idx = block.find("S.messages=") + restore_idx = block.find("_messagesTruncated=") + offset_idx = block.find("_oldestIdx=") + render_idx = block.find("renderMessages()") + + assert assign_idx >= 0, "compress success path must assign S.messages" + assert restore_idx > assign_idx, ( + "_messagesTruncated must be restored from the response AFTER the " + "assignment — the preflight's bounded value otherwise survives" + ) + assert offset_idx > assign_idx, "_oldestIdx must be restored from _messages_offset" + assert render_idx > restore_idx and render_idx > offset_idx, ( + "paging must be restored BEFORE renderMessages() so the rendered rows " + "and any absolute index computation see the restored values" + ) + # The restore must read the response's own signals, not hardcode false. + assert "_messages_truncated" in block, ( + "the restore must read data.session._messages_truncated rather than " + "hardcoding _messagesTruncated=false" + ) + assert "_messages_offset" in block, ( + "the restore must read data.session._messages_offset" + ) + + +def test_truncation_signal_selects_suffix_strategy_exclusively(): + """Source lock: the strategy is a truncation-gated ternary, never an `||`. + + The pre-fix expression was ``_stagedMatchesCurrentPrefix || + _stagedMatchesCurrentSuffix``: when repeated identical turns made both + comparisons succeed the prefix branch won and spliced at the wrong + offset. The gate is now ``_truncatedRecovery ? suffix : prefix``. + """ + block = _splice_block() + + assert "_truncatedRecovery" in block, ( + "the splice strategy must consult the truncation signal" + ) + assert "_truncatedRecovery?_stagedMatchesCurrentSuffix" in block.replace(" ", ""), ( + "a truncated recovery must select the SUFFIX comparison exclusively" + ) + # The old defect: a bare `||` between the two comparisons. + assert "||_stagedMatchesCurrentSuffix" not in block.replace(" ", ""), ( + "prefix and suffix comparisons must not be OR-ed; preferring the " + "prefix when both match splices repeated turns at the wrong offset" + ) From 8be686ee758b30a58e26c94dee12c52108c64f4d Mon Sep 17 00:00:00 2001 From: happy5318 Date: Sun, 9 Aug 2026 10:25:29 +0800 Subject: [PATCH 036/115] fix(mobile): soft-keyboard Enter inserts newline, not send iOS Safari reports matchMedia('(any-pointer:fine)') as true on iPhone, so _hasFinePointerCoexisting() wrongly vetoed the touch-device default and plain Enter fell through to send(). - boot.js: _isTouchOnlyDevice() makes phone/tablet UA authoritative, no longer vetoed by any-pointer:fine; iPadOS-as-Macintosh detected via maxTouchPoints; media-query pair is fallback for non-phone touch devices. Composer handler reads live window._sendKey on every keydown so Settings changes take effect without reload. - index.html: capture-phase interceptor removed; boot.js handler alone covers the mobile newline case (initial _sendKey already synced from localStorage synchronously, closing the async race). - tests/test_send_key_preference_live_update.py: Playwright regression test verifies all three send-key modes and all transitions work after runtime preference change. --- static/boot.js | 35 ++- static/index.html | 2 +- tests/test_send_key_preference_live_update.py | 289 ++++++++++++++++++ 3 files changed, 322 insertions(+), 4 deletions(-) create mode 100644 tests/test_send_key_preference_live_update.py diff --git a/static/boot.js b/static/boot.js index d6476b60..1e2d4792 100644 --- a/static/boot.js +++ b/static/boot.js @@ -2388,9 +2388,39 @@ window._isImeEnter=_isImeEnter; function _hasFinePointerCoexisting(){ try{ return matchMedia('(any-pointer:fine)').matches; }catch(_){ return false; } } +// Detect mobile/touch-only devices. On some iOS Safari versions +// matchMedia('(pointer:coarse)') can be unreliable, so also check +// the user agent for mobile indicators as a fallback. +function _isTouchOnlyDevice(){ + // Phone/tablet UA is authoritative and checked FIRST. Do NOT veto it with + // (any-pointer:fine): several iOS Safari builds report that query as true on + // plain iPhones (Apple Pencil / pointer-emulation heuristics), which is + // exactly why the media-query-only detection failed here. On a phone the + // software keyboard's return key must insert a newline; a user with a real + // Bluetooth keyboard still has Ctrl/Cmd+Enter and the send button. + const ua=navigator.userAgent||''; + if(/iPhone|iPod|Android/i.test(ua)) return true; + // iPadOS 13+ Safari masquerades as Macintosh; touch points disambiguate it. + if(/iPad/i.test(ua)) return true; + try{ + if(/Macintosh/i.test(ua)&&(navigator.maxTouchPoints||0)>1) return true; + }catch(_){} + // Non-phone fallback: pure media-query detection for touch-primary devices. + try{ + return matchMedia('(pointer:coarse)').matches&&!matchMedia('(any-pointer:fine)').matches; + }catch(_){} + return false; +} function _isNumpadEnter(e){ return e.key==='Enter'&&(e.code==='NumpadEnter'||e.location===KeyboardEvent.DOM_KEY_LOCATION_NUMPAD); } +// Initialise _sendKey synchronously from the localStorage cache so the keydown +// handler below has the correct value before the async /api/settings call +// (line ~3231) resolves. Without this, on slow mobile networks the race window +// leaves _sendKey=undefined, _mobileDefault evaluates false, and plain Enter +// falls through to the `else { send() }` branch — sending the message instead +// of inserting a newline (issue: mobile Enter sends on fresh page load). +try{ window._sendKey=localStorage.getItem('hermes-pref-send_key')||'enter'; }catch(_){ window._sendKey='enter'; } $('msg').addEventListener('keydown',e=>{ // Autocomplete navigation when dropdown is open const dd=$('cmdDropdown'); @@ -2424,9 +2454,8 @@ $('msg').addEventListener('keydown',e=>{ if(e.key==='Enter'){ if(_isImeEnter(e)){return;} const isNumpadEnter=_isNumpadEnter(e); - const _mobileDefault=matchMedia('(pointer:coarse)').matches - &&!_hasFinePointerCoexisting() - &&window._sendKey==='enter'; + const _mobileDefault=_isTouchOnlyDevice() + &&(window._sendKey==='enter'||typeof window._sendKey==='undefined'); if(window._sendKey==='shift+enter'){ if(e.shiftKey){e.preventDefault();send();} } else if(window._sendKey==='ctrl+enter'||_mobileDefault){ diff --git a/static/index.html b/static/index.html index b95528e8..335868de 100644 --- a/static/index.html +++ b/static/index.html @@ -592,7 +592,7 @@ - +